Files
matrix-screen-controller/核桃派软件源代码/app/mobile/control.py
T

261 lines
14 KiB
Python

"""Explicit mobile capability surface, sharing existing device business operations."""
from __future__ import annotations
import base64
import json
import time
import threading
from io import BytesIO
from pathlib import Path
from uuid import UUID, uuid4
from contextlib import nullcontext
from PIL import Image
from app.persistence import atomic_write_bytes
from app.demo_library import demo_template, demo_animation
from app.display.service import ANIMATION_PLAYBACK_SPEEDS, AnimationPlaybackConflictError
from app.templates.store import TemplateConflictError, TemplateNotFoundError
from .session import RpcError, checked_name
from .preview_cache import PreviewCache
SETTINGS = ("brightness", "orientation", "matrix_refresh_rate_limit_hz", "performance_mode_enabled")
class MobileControl:
def __init__(self, control, network, templates, animations, resolve, apply, get_default, set_default,
status, storage, library_order=None):
self.control, self.network = control, network
self.templates, self.animations = templates, animations
self.resolve, self.apply = resolve, apply
self.get_default, self.set_default = get_default, set_default
self.status, self.storage = status, storage
self.tasks = {}
self.library_order = library_order
self.path = Path(control.store.data_dir) / "mobile/identity.json"
self.preview_cache = PreviewCache(control.store.data_dir)
if self.path.exists():
identity = json.loads(self.path.read_text(encoding="utf-8"))
if set(identity) != {"schema", "device_id", "name"} or identity["schema"] != 1:
raise ValueError("Unsupported mobile identity")
UUID(identity["device_id"])
checked_name(identity["name"])
self.identity_record = identity
else:
self.identity_record = dict(schema=1, device_id=str(uuid4()), name="奇妙小屏幕")
self._persist_identity(self.identity_record)
self.preview_cache.reconcile(self._library_items())
def _persist_identity(self, identity):
atomic_write_bytes(self.path, (json.dumps(identity, ensure_ascii=False) + "\n").encode("utf-8"))
def identity(self):
record = self.identity_record
return dict(device_id=record["device_id"], device_name=record["name"],
short_id=record["device_id"].replace("-", "")[:8], protocol_major=1, protocol_minor=1,
capabilities=["status", "settings", "library", "library_progressive", "playback", "frame", "device_name", "wifi", "wifi_scan"],
limits=dict(max_message_bytes=65536, library_page_size=50, preview_interval_ms=2000))
def settings(self):
config = self.control.store.config
values = {key: config[key] for key in SETTINGS}
values["prompt_delay_seconds"] = self.network.get_cached_status()["prompt_delay_seconds"]
return dict(revision=self.control.revision(values), values=values, writable_fields=list(values),
allowed=dict(orientation=[0, 90, 180, 270], matrix_refresh_rate_limit_hz=[15, 20, 30, 45, 60, 80, 100],
playback_speeds=list(ANIMATION_PLAYBACK_SPEEDS)))
def _content(self, params):
reference = {key: params.get(key) for key in ("type", "id")}
resolved = self.resolve(reference)
if params.get("revision") != resolved["revision"]:
raise RpcError("CONFLICT", "内容已改变,请刷新")
return resolved
@staticmethod
def _preview_image(content):
if content["type"] == "template":
return content["image"]
frames = content["frames"]
if not frames:
raise RpcError("NOT_FOUND", "内容没有可预览帧")
frame = frames[0]
if "image" in frame:
return frame["image"]
if "path" not in frame:
raise RpcError("NOT_FOUND", "内容没有可预览帧")
try:
with Image.open(frame["path"]) as image:
image.load()
if image.size != (64, 64):
raise ValueError("invalid animation frame size")
return image.convert("RGB")
except (OSError, ValueError):
raise RpcError("NOT_FOUND", "预览文件不可用") from None
def _library_items(self):
items = []
for kind, records in (("template", [demo_template()] + self.templates.list()["templates"]),
("animation", [demo_animation()] + self.animations.list()["animations"])):
for item in records:
items.append(dict(type=kind, id=item["id"], name=item["name"], revision=item["revision"],
is_demo=bool(item.get("is_demo", False)), playable=kind == "template" or item.get("frame_count", 0) > 0,
thumbnail_revision=item["revision"]))
if self.library_order is not None:
user_items = [item for item in items if not item["is_demo"]]
order = self.library_order.get([{"type": item["type"], "id": item["id"]} for item in user_items])["items"]
by_key = {(item["type"], item["id"]): item for item in user_items}
items = [item for item in items if item["is_demo"]] + [by_key[(ref["type"], ref["id"])] for ref in order]
return items
@staticmethod
def _png(image):
output = BytesIO()
image.save(output, format="PNG")
return dict(mime="image/png", data_base64=base64.b64encode(output.getvalue()).decode("ascii"))
def dispatch(self, method, params):
try:
with self.control.lock, (self.network.configuration_lock if method in ('wifi.get', 'wifi.set') else nullcontext()):
return self._dispatch(method, params)
except (TemplateConflictError, AnimationPlaybackConflictError):
raise RpcError("CONFLICT", "内容或播放会话已改变,请刷新") from None
except TemplateNotFoundError:
raise RpcError("NOT_FOUND", "内容不存在") from None
except (ValueError, TypeError, KeyError):
raise RpcError("BAD_REQUEST", "参数无效,请检查后重试") from None
def _dispatch(self, method, params):
if method == 'wifi.scan':
return dict(networks=self.network.backend.scan(), scanned_at_ms=int(time.time() * 1000))
if method == 'wifi.get':
return self.wifi()
if method == 'wifi.set':
before = self.wifi()
if params.get('expected_revision') != before['revision']:
raise RpcError('CONFLICT', '网络设置已改变,请刷新')
action = params.get('password_action')
security = params.get('security')
if security not in ('open', 'wpa-psk') or action not in ('keep', 'replace', 'none'):
raise ValueError()
saved = before.get('saved') or {}
if security == 'open':
if action != 'none' or params.get('password'):
raise ValueError()
elif action == 'keep':
if params.get('ssid') != saved.get('ssid') or not saved.get('password_configured') or params.get('password'):
raise ValueError()
elif action != 'replace' or not params.get('password'):
raise ValueError()
values = {key: params[key] for key in ('ssid', 'security', 'ipv4_mode', 'address', 'prefix', 'gateway', 'dns_servers', 'activation') if key in params}
values['password'] = params.get('password') if action == 'replace' else None
result = self.network.save_settings(values)
task_id = result['operation_id']
if len(self.tasks) >= 64:
del self.tasks[next(iter(self.tasks))]
self.tasks[task_id] = dict(state='pending' if result['activation'] == 'immediate' else 'succeeded', stage='saved')
if result['activation'] == 'immediate':
threading.Thread(target=self._activate, args=(task_id,), name='mobile-wifi', daemon=True).start()
return dict(revision=self.wifi()['revision'], task_id=task_id)
if method == 'task.get':
task_id = params.get('task_id')
if not isinstance(task_id, str) or task_id not in self.tasks:
raise RpcError('NOT_FOUND', '任务不存在或已过期')
return dict(self.tasks[task_id])
if method == "device.rename":
identity = {**self.identity_record, "name": checked_name(params.get("name"))}
self._persist_identity(identity)
self.identity_record = identity
return self.identity()
if method == "status.get":
return self.status()
if method == "storage.get":
return self.storage()
if method == "settings.get":
return self.settings()
if method == "settings.patch":
if params.get("expected_revision") != self.settings()["revision"]:
raise RpcError("CONFLICT", "设置已改变,请刷新")
changes = params.get("changes")
if not isinstance(changes, dict) or not changes or set(changes) - {*SETTINGS, "prompt_delay_seconds"}:
raise RpcError("BAD_REQUEST", "不支持的设置字段")
# Separate NetworkManager persistence from display configuration.
if "prompt_delay_seconds" in changes and len(changes) != 1:
raise RpcError("BAD_REQUEST", "网络提示延迟须单独提交")
if "prompt_delay_seconds" in changes:
self.network.save_prompt_delay(changes["prompt_delay_seconds"])
else:
self.control.update_config(changes)
return self.settings()
if method in ("library.list", "library.summary"):
items = self._library_items()
revision = self.control.revision(items)
self.preview_cache.reconcile(items)
if method == "library.summary":
return dict(library_revision=revision, item_count=len(items))
limit = params.get("limit", 20)
if type(limit) is not int or not 1 <= limit <= 50:
raise ValueError()
offset = 0
if params.get("cursor"):
cursor = params["cursor"].split(":")
if len(cursor) != 2 or cursor[0] != revision:
raise RpcError("CONFLICT", "内容列表已改变,请重新加载")
offset = int(cursor[1])
if not 0 <= offset <= len(items):
raise ValueError()
next_offset = offset + limit
return dict(library_revision=revision, items=items[offset:next_offset],
next_cursor=f"{revision}:{next_offset}" if next_offset < len(items) else None)
if method == "library.thumbnail":
content = self._content(params)
return self._png(self._preview_image(content))
if method == "library.preview":
content = self._content(params)
image = self._preview_image(content)
item = {key: content[key] for key in ("type", "id", "revision")}
data = self.preview_cache.get(item, image)
return dict(mime="image/png", data_base64=base64.b64encode(data).decode("ascii"))
if method in ("content.play", "content.default.set"):
content = self._content(params)
if method == "content.default.set":
return self.set_default({"type": content["type"], "id": content["id"]})
self.apply(content, notify_activity=True)
return {key: content[key] for key in ("type", "id", "name", "revision", "is_demo")}
if method == "content.default.get":
content = self.get_default()
return {key: content[key] for key in ("type", "id", "name", "revision", "is_demo")}
if method == "playback.patch":
if set(params) - {"session_id", "paused", "position_ms", "speed"}:
raise ValueError()
playback = self.control.display.control_animation_playback(params.get("session_id"),
paused=params.get("paused"), position_ms=params.get("position_ms"), speed=params.get("speed"))
return dict(animation_playback=playback, state_revision=self.control.display.get_state_revision())
if method == "frame.get":
image = self.control.display.get_current_frame()
revision = self.control.revision(base64.b64encode(image.tobytes()).decode("ascii"))
if params.get("known_revision") == revision:
return dict(unchanged=True, frame_revision=revision)
return dict(unchanged=False, frame_revision=revision, sampled_at_ms=int(time.time() * 1000), **self._png(image))
raise RpcError("UNSUPPORTED_CAPABILITY", "设备不支持此操作")
def wifi(self):
status = self.network.get_status(include_secret=False)
saved = status.get('saved')
if saved:
saved = {key: value for key, value in saved.items() if key != 'password'}
saved.setdefault('security', 'wpa-psk' if saved.get('password_configured') else 'open')
revision = self.control.revision(dict(saved=saved, generation=self.network.settings_revision))
return {**status, 'saved': saved, 'revision': revision}
def _activate(self, task_id):
self.tasks[task_id] = dict(state='running', stage='connecting')
try:
self.network.activate_saved(task_id)
operation = self.network.get_cached_status().get('operation') or {}
state = operation.get('state') if operation.get('id') == task_id else 'failed'
self.tasks[task_id] = dict(state='succeeded' if state == 'succeeded' else 'failed', stage='finished')
if state != 'succeeded':
code = operation.get('error_code', 'UNKNOWN')
self.tasks[task_id]['error_code'] = code if code in {'AUTH_FAILED', 'NETWORK_UNAVAILABLE', 'IP_CONFIG_FAILED', 'UNKNOWN'} else 'UNKNOWN'
except Exception:
self.tasks[task_id] = dict(state='failed', stage='finished', error_code='UNKNOWN')