223 lines
12 KiB
Python
223 lines
12 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 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
|
|
|
|
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"
|
|
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)
|
|
|
|
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=0,
|
|
capabilities=["status", "settings", "library", "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 _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 == "library.list":
|
|
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]
|
|
revision = self.control.revision(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(content["image"] if content["type"] == "template" else content["frames"][0]["image"])
|
|
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')
|