Improve controller behavior and update project requirements

This commit is contained in:
2026-09-30 15:42:36 +08:00
parent 55374356ed
commit 071f06d365
52 changed files with 2205 additions and 335 deletions
+1 -1
View File
@@ -1 +1 @@
2026-09-27T17:51+08:00
2026-09-29T22:15+08:00
+54 -16
View File
@@ -9,12 +9,14 @@ 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")
@@ -30,6 +32,7 @@ class MobileControl:
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:
@@ -40,6 +43,7 @@ class MobileControl:
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"))
@@ -47,8 +51,8 @@ class MobileControl:
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"],
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):
@@ -66,6 +70,42 @@ class MobileControl:
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()
@@ -145,20 +185,12 @@ class MobileControl:
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]
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()
@@ -175,7 +207,13 @@ class MobileControl:
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"])
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":
@@ -0,0 +1,48 @@
"""Regenerable BLE library previews, isolated from user-owned media."""
from __future__ import annotations
import hashlib
import re
from io import BytesIO
from pathlib import Path
from PIL import Image
from app.persistence import atomic_write_bytes
_PREVIEW_FILE = re.compile(r"[0-9a-f]{64}\.png\Z")
class PreviewCache:
def __init__(self, data_dir: Path):
self.root = Path(data_dir) / "mobile" / "previews"
self.root.mkdir(parents=True, exist_ok=True)
@staticmethod
def name(item: dict) -> str:
identity = "\0".join(str(item[key]) for key in ("type", "id", "revision"))
return hashlib.sha256(identity.encode("utf-8")).hexdigest() + ".png"
def reconcile(self, items: list[dict]) -> None:
expected = {self.name(item) for item in items}
for path in self.root.iterdir():
if path.is_file() and _PREVIEW_FILE.fullmatch(path.name) and path.name not in expected:
path.unlink(missing_ok=True)
def get(self, item: dict, image: Image.Image) -> bytes:
path = self.root / self.name(item)
if path.exists():
try:
data = path.read_bytes()
with Image.open(BytesIO(data)) as cached:
cached.verify()
if cached.format == "PNG" and cached.size == (16, 16):
return data
except (OSError, ValueError):
pass
output = BytesIO()
image.convert("RGB").resize((16, 16), Image.Resampling.LANCZOS).save(output, format="PNG", optimize=True)
data = output.getvalue()
atomic_write_bytes(path, data)
return data
@@ -44,6 +44,60 @@ def test_library_and_frame_readback(tmp_path):
mobile.dispatch('content.play', {**item, 'revision': 'old'})
def test_progressive_preview_and_revision_cleanup(tmp_path):
import base64
from io import BytesIO
from PIL import Image
app = create_app(project_root=tmp_path, driver_kind='mock')
mobile = app.state.mobile_control
assert mobile.identity()['protocol_minor'] == 1
assert 'library_progressive' in mobile.identity()['capabilities']
created = mobile.templates.create('预览清理', {
'version': 1, 'width': 64, 'height': 64,
'pixelRgb': base64.b64encode(bytes(64 * 64 * 3)).decode('ascii'), 'elements': [],
})
summary = mobile.dispatch('library.summary', {})
items = mobile.dispatch('library.list', {})['items']
assert summary == {'library_revision': mobile.dispatch('library.list', {})['library_revision'], 'item_count': len(items)}
item = next(item for item in items if item['id'] == created['id'])
small = base64.b64decode(mobile.dispatch('library.preview', item)['data_base64'])
large = base64.b64decode(mobile.dispatch('library.thumbnail', item)['data_base64'])
assert len(small) < len(large)
with Image.open(BytesIO(small)) as image:
assert image.format == 'PNG' and image.size == (16, 16)
with Image.open(BytesIO(large)) as image:
assert image.format == 'PNG' and image.size == (64, 64)
old_file = mobile.preview_cache.root / mobile.preview_cache.name(item)
assert old_file.read_bytes() == small
mobile.templates.rename(created['id'], '预览清理新版', created['revision'])
assert mobile.dispatch('library.summary', {})['library_revision'] != summary['library_revision']
assert not old_file.exists()
with pytest.raises(RpcError, match='CONFLICT'):
mobile.dispatch('library.preview', item)
empty = mobile.animations.create('空动图')
empty_item = next(row for row in mobile.dispatch('library.list', {})['items'] if row['id'] == empty['id'])
with pytest.raises(RpcError, match='CONFLICT'):
mobile.dispatch('library.preview', empty_item)
def test_user_animation_preview_reads_persisted_first_frame(tmp_path):
import base64
from io import BytesIO
from PIL import Image
mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control
scene = {'version': 1, 'width': 64, 'height': 64,
'pixelRgb': base64.b64encode(bytes((12, 34, 56)) * (64 * 64)).decode('ascii'), 'elements': []}
created = mobile.animations.create_from_frames('用户动图', [{'scene': scene, 'duration_ms': 100}])
item = next(row for row in mobile.dispatch('library.list', {})['items'] if row['id'] == created['id'])
small = base64.b64decode(mobile.dispatch('library.preview', item)['data_base64'])
large = base64.b64decode(mobile.dispatch('library.thumbnail', item)['data_base64'])
with Image.open(BytesIO(small)) as image:
assert image.size == (16, 16) and image.getpixel((0, 0)) == (12, 34, 56)
with Image.open(BytesIO(large)) as image:
assert image.size == (64, 64) and image.getpixel((0, 0)) == (12, 34, 56)
def test_forbidden_operation_and_settings_fields(tmp_path):
mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control
with pytest.raises(RpcError, match='UNSUPPORTED_CAPABILITY'):