"""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=2, capabilities=["status", "settings", "library", "library_progressive", "playback", "frame", "device_name", "wifi", "wifi_scan", "wifi_transport"], 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')