同步移动端工程、设备控制改进与发布资料

This commit is contained in:
2026-09-26 19:21:12 +08:00
parent 912ba432cf
commit 98eadc5c8b
137 changed files with 9274 additions and 85 deletions
+47
View File
@@ -0,0 +1,47 @@
"""Shared device operations used by REST and mobile adapters."""
from __future__ import annotations
import hashlib
import json
import threading
from app.config.store import validate_config
class DeviceControl:
def __init__(self, store, display, power_monitor, performance_mode):
self.store = store
self.display = display
self.power_monitor = power_monitor
self.performance_mode = performance_mode
self.lock = threading.RLock()
@staticmethod
def revision(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False,
separators=(",", ":"), allow_nan=False).encode("utf-8")).hexdigest()
def update_config(self, values):
with self.lock:
config = validate_config({**self.store.config, **values})
if "low_voltage_protection_enabled" in values:
enabled = config["low_voltage_protection_enabled"]
self.store.update({"low_voltage_protection_enabled": enabled})
self.power_monitor.set_protection_enabled(enabled)
if enabled:
self.power_monitor.sample_now()
if "orientation" in values:
self.display.set_orientation(config["orientation"])
if "brightness" in values:
self.display.set_brightness(config["brightness"])
if "matrix_refresh_rate_limit_hz" in values:
self.display.set_refresh_rate_limit(config["matrix_refresh_rate_limit_hz"])
if "performance_mode_enabled" in values:
self.performance_mode.transact(config["performance_mode_enabled"],
lambda enabled: self.store.update({"performance_mode_enabled": enabled}))
remaining = {key: config[key] for key in (
"default_font", "default_text_size", "preview_refresh_interval_ms",
"custom_test_color", "workspace_order", "animation_preview_max_concurrent") if key in values}
if remaining:
self.store.update(remaining)
return self.store.config
+36 -37
View File
@@ -15,6 +15,10 @@ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr, field_validator, model_validator
from app.control import DeviceControl
from app.mobile.control import MobileControl
from app.mobile.session import SessionManager
from app.mobile.bluez import BluezRuntime
from app.config.store import (
COLOR_PALETTE_LIMIT,
DEFAULT_DISPLAY,
@@ -561,6 +565,8 @@ def create_app(
staging_root=ota_staging_root,
)
control = DeviceControl(store, display, power_monitor, performance_mode)
def resolve_content(reference: dict[str, str]) -> dict[str, Any]:
checked = normalize_default_display(reference)
content_type = checked["type"]
@@ -831,8 +837,9 @@ def create_app(
},
)
@app.get("/api/status")
def get_status() -> dict:
mobile_runtime = None
def read_device_status() -> dict:
status = display.get_status()
status["service"] = {
"app_version": app.state.app_version,
@@ -855,8 +862,25 @@ def create_app(
store.config["performance_mode_enabled"]
),
}
status["mobile"] = mobile_runtime.status() if mobile_runtime else {"available": False, "connected": False, "reason": "initialization_failed"}
return status
try:
mobile = MobileControl(control, network, templates, animations, resolve_content,
apply_resolved_content, effective_default_content, set_default_content,
read_device_status, storage_monitor.get_status, library_order=library_order)
mobile_sessions = SessionManager(mobile.dispatch, mobile.identity)
mobile_runtime = BluezRuntime(mobile_sessions, enabled=os.environ.get("MATRIX_BLE_ENABLED") == "1")
app.state.mobile_control = mobile
app.state.mobile_sessions = mobile_sessions
except Exception:
logger.exception("Mobile control initialization failed; display remains available")
app.state.mobile_runtime = mobile_runtime
@app.get("/api/status")
def get_status() -> dict:
return read_device_status()
@app.get("/api/ota/status")
def get_ota_status() -> dict:
return {
@@ -1123,41 +1147,7 @@ def create_app(
def put_config(update: ConfigUpdate) -> dict:
values = update.model_dump(exclude_none=True)
try:
config = validate_config({**store.config, **values})
if "low_voltage_protection_enabled" in values:
enabled = config["low_voltage_protection_enabled"]
store.update({"low_voltage_protection_enabled": enabled})
power_monitor.set_protection_enabled(enabled)
if enabled:
power_monitor.sample_now()
if "orientation" in values:
display.set_orientation(config["orientation"])
if "brightness" in values:
display.set_brightness(config["brightness"])
if "matrix_refresh_rate_limit_hz" in values:
display.set_refresh_rate_limit(
config["matrix_refresh_rate_limit_hz"]
)
if "performance_mode_enabled" in values:
performance_mode.transact(
config["performance_mode_enabled"],
lambda enabled: store.update({"performance_mode_enabled": enabled}),
)
remaining = {
key: config[key]
for key in (
"default_font",
"default_text_size",
"preview_refresh_interval_ms",
"custom_test_color",
"workspace_order",
"animation_preview_max_concurrent",
)
if key in values
}
if remaining:
store.update(remaining)
return store.config
return control.update_config(values)
except (ConfigError, PerformanceModeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
@@ -2023,6 +2013,8 @@ def create_app(
@app.on_event("startup")
def startup() -> None:
if mobile_runtime:
mobile_runtime.start()
performance_mode.start(store.config["performance_mode_enabled"])
media_imports.start()
power_monitor.sample_now()
@@ -2034,8 +2026,13 @@ def create_app(
network.start()
except Exception:
logger.exception("Failed to restore normal content after OTA completion")
try:
ota.schedule_kernel_recovery_after_ota()
except Exception:
logger.exception("Failed to evaluate kernel recovery after OTA completion")
ota.set_completion_callback(restore_after_ota)
ota.finalize_kernel_recovery_after_boot()
if ota.resume_or_start_monitor(restore_after_ota):
logger.warning("OTA transaction is active; keeping the update indicator visible")
elif maintenance_black:
@@ -2046,6 +2043,8 @@ def create_app(
@app.on_event("shutdown")
def shutdown() -> None:
if mobile_runtime:
mobile_runtime.close()
logger.info("Shutting down; clearing display")
media_imports.stop()
power_monitor.close()
@@ -0,0 +1 @@
"""Versioned mobile control transport, independent of web presentation."""
@@ -0,0 +1,252 @@
"""BlueZ GATT adapter. Failure is isolated from the display service."""
import asyncio
import logging
import threading
from dbus_next import Variant, DBusError, BusType, PropertyAccess
from dbus_next.aio import MessageBus
from dbus_next.service import ServiceInterface, method, dbus_property
from .protocol import fragments
log = logging.getLogger(__name__)
ROOT = '/org/qimiaoscreen/mobile'
SERVICE = ROOT + '/service0'
AD = ROOT + '/advertisement0'
SERVICE_UUID = '9f57a001-6c31-4c58-bc22-1f728b641001'
RX_UUID = '9f57a002-6c31-4c58-bc22-1f728b641001'
TX_UUID = '9f57a003-6c31-4c58-bc22-1f728b641001'
class ObjectManager(ServiceInterface):
def __init__(self, objects):
super().__init__('org.freedesktop.DBus.ObjectManager')
self.objects = objects
@method()
def GetManagedObjects(self) -> 'a{oa{sa{sv}}}':
return {path: {obj.name: obj.properties()} for path, obj in self.objects.items()}
class GattService(ServiceInterface):
def __init__(self):
super().__init__('org.bluez.GattService1')
@dbus_property(access=PropertyAccess.READ)
def UUID(self) -> 's':
return SERVICE_UUID
@dbus_property(access=PropertyAccess.READ)
def Primary(self) -> 'b':
return True
def properties(self):
return dict(UUID=Variant('s', self.UUID), Primary=Variant('b', True))
class Characteristic(ServiceInterface):
def __init__(self, uuid, runtime):
super().__init__('org.bluez.GattCharacteristic1')
self.uuid, self.runtime = uuid, runtime
self.notifying = False
@dbus_property(access=PropertyAccess.READ)
def UUID(self) -> 's':
return self.uuid
@dbus_property(access=PropertyAccess.READ)
def Service(self) -> 'o':
return SERVICE
@dbus_property(access=PropertyAccess.READ)
def Flags(self) -> 'as':
return ['write'] if self.uuid == RX_UUID else ['notify']
@dbus_property(access=PropertyAccess.READ)
def Value(self) -> 'ay':
return b''
@dbus_property(access=PropertyAccess.READ)
def Notifying(self) -> 'b':
return self.notifying
@method()
async def WriteValue(self, value: 'ay', options: 'a{sv}'):
if self.uuid != RX_UUID:
raise DBusError('org.bluez.Error.NotPermitted', 'Not permitted')
await self.runtime.write(value, options)
@method()
def StartNotify(self):
if self.uuid != TX_UUID:
raise DBusError('org.bluez.Error.NotSupported', 'Not supported')
self.notifying = True
@method()
def StopNotify(self):
self.notifying = False
def properties(self):
return dict(UUID=Variant('s', self.UUID), Service=Variant('o', SERVICE), Flags=Variant('as', self.Flags))
class Advertisement(ServiceInterface):
def __init__(self, short_id):
super().__init__('org.bluez.LEAdvertisement1')
self.local_name = 'QMS-' + short_id
@dbus_property(access=PropertyAccess.READ)
def Type(self) -> 's':
return 'peripheral'
@dbus_property(access=PropertyAccess.READ)
def ServiceUUIDs(self) -> 'as':
return [SERVICE_UUID]
@dbus_property(access=PropertyAccess.READ)
def LocalName(self) -> 's':
return self.local_name
@method()
def Release(self):
pass
class BluezRuntime:
def __init__(self, sessions, enabled=False):
self.sessions = sessions
self.enabled = enabled
self.stop_event = threading.Event()
self.thread = None
self.state = dict(available=False, reason='disabled')
self.bus = self.manager = self.ad_manager = None
self.advertising = False
self.tx = None
self.message_id = 0
self.write_lock = None
self.counters = dict(rx_fragments=0, tx_fragments=0, rx_bytes=0, tx_bytes=0, last_response_kind=0, mtu=23)
def status(self):
return {**self.state, **self.sessions.status(), 'transport': dict(self.counters)}
def start(self):
if not self.enabled or self.thread:
return
self.thread = threading.Thread(target=lambda: asyncio.run(self._run()), name='mobile-ble', daemon=True)
self.thread.start()
def close(self):
self.stop_event.set()
if self.thread:
self.thread.join(timeout=8)
async def _proxy(self, path):
introspection = await asyncio.wait_for(self.bus.introspect('org.bluez', path), 5)
return self.bus.get_proxy_object('org.bluez', path, introspection)
async def _disconnect(self, owner):
was_owner = self.sessions.owner == owner
try:
proxy = await self._proxy(owner)
await asyncio.wait_for(proxy.get_interface('org.bluez.Device1').call_disconnect(), 3)
except Exception:
pass
self.sessions.disconnect(owner)
if was_owner:
self.message_id = 0
async def write(self, value, options):
self.counters['rx_fragments'] += 1
self.counters['rx_bytes'] += len(value)
owner = options.get('device')
if not owner or options.get('offset', Variant('q', 0)).value != 0:
raise DBusError('org.bluez.Error.NotAuthorized', 'Invalid request')
owner = owner.value
if not self.sessions.connect(owner):
await self._disconnect(owner)
raise DBusError('org.bluez.Error.NotAuthorized', 'Busy')
async with self.write_lock:
try:
if not self.tx.notifying:
raise ValueError()
response = await asyncio.to_thread(self.sessions.accept, owner, bytes(value))
if response is None:
return
mtu = min(self.sessions.peer_mtu, max(23, options.get('mtu', Variant('q', 23)).value))
self.counters['mtu'] = mtu
self.counters['last_response_kind'] = response[0]
for fragment in fragments(response[0], self.message_id, response[1], mtu):
self.tx.emit_properties_changed({'Value': fragment})
self.counters['tx_fragments'] += 1
self.counters['tx_bytes'] += len(fragment)
await asyncio.sleep(0.002)
self.message_id += 1
except Exception:
await self._disconnect(owner)
raise DBusError('org.bluez.Error.NotAuthorized', 'Session rejected') from None
async def _run(self):
while not self.stop_event.is_set():
try:
await self._serve()
except Exception:
self.state = dict(available=False, reason='bluetooth_unavailable')
log.warning('Mobile BLE unavailable; display service remains active')
finally:
if self.sessions.owner is not None:
self.sessions.disconnect(self.sessions.owner)
self.message_id = 0
self.advertising = False
if self.bus:
self.bus.disconnect()
self.bus = None
for _ in range(5):
if self.stop_event.is_set():
return
await asyncio.sleep(1)
async def _serve(self):
self.bus = await asyncio.wait_for(MessageBus(bus_type=BusType.SYSTEM).connect(), 5)
root_proxy = await self._proxy('/')
self.manager = root_proxy.get_interface('org.freedesktop.DBus.ObjectManager')
objects = await asyncio.wait_for(self.manager.call_get_managed_objects(), 5)
adapters = [path for path, interfaces in objects.items() if 'org.bluez.GattManager1' in interfaces and 'org.bluez.LEAdvertisingManager1' in interfaces]
if not adapters:
raise RuntimeError('No GATT peripheral adapter')
adapter = await self._proxy(sorted(adapters)[0])
properties = adapter.get_interface('org.freedesktop.DBus.Properties')
await properties.call_set('org.bluez.Adapter1', 'Powered', Variant('b', True))
await properties.call_set('org.bluez.Adapter1', 'Pairable', Variant('b', False))
self.ad_manager = adapter.get_interface('org.bluez.LEAdvertisingManager1')
self.tx = Characteristic(TX_UUID, self)
exports = {SERVICE: GattService(), SERVICE + '/rx': Characteristic(RX_UUID, self), SERVICE + '/tx': self.tx}
self.bus.export(ROOT, ObjectManager(exports))
for path, interface in exports.items():
self.bus.export(path, interface)
self.bus.export(AD, Advertisement(self.sessions.identity()['short_id']))
await asyncio.wait_for(adapter.get_interface('org.bluez.GattManager1').call_register_application(ROOT, {}), 5)
self.write_lock = asyncio.Lock()
self.state = dict(available=True, reason=None)
while not self.stop_event.is_set():
objects = await asyncio.wait_for(self.manager.call_get_managed_objects(), 5)
connected = [path for path, interfaces in objects.items()
if interfaces.get('org.bluez.Device1', {}).get('Connected', Variant('b', False)).value]
owner = self.sessions.owner
if owner and owner not in connected:
self.sessions.disconnect(owner)
self.message_id = 0
for path in connected:
if not self.sessions.connect(path):
await self._disconnect(path)
if self.sessions.expired():
await self._disconnect(self.sessions.owner)
should_advertise = self.sessions.owner is None
if should_advertise != self.advertising:
if should_advertise:
await asyncio.wait_for(self.ad_manager.call_register_advertisement(AD, {}), 5)
else:
await asyncio.wait_for(self.ad_manager.call_unregister_advertisement(AD), 5)
self.advertising = should_advertise
await asyncio.sleep(0.5)
if self.sessions.owner:
await self._disconnect(self.sessions.owner)
@@ -0,0 +1,222 @@
"""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')
@@ -0,0 +1,165 @@
"""QMS BLE v1 framing and ephemeral authenticated encryption.
This encrypts traffic, but does not authenticate the identity of an App.
Each instance belongs to exactly one connection; discard it after any error.
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import struct
import time
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
MAX_MESSAGE = 65536
HEADER = struct.Struct("!HBBIHHI")
LABEL = b"QMS-BLE-1"
class ProtocolError(ValueError):
"""Fixed public error: never include untrusted payload or crypto details."""
def fragments(kind: int, message_id: int, payload: bytes, mtu: int):
if kind not in (1, 2, 3, 4) or not 0 <= message_id <= 0xFFFFFFFF:
raise ProtocolError("BAD_REQUEST")
if not 1 <= len(payload) <= MAX_MESSAGE or not 23 <= mtu <= 517:
raise ProtocolError("BAD_REQUEST")
capacity = mtu - 19
count = (len(payload) + capacity - 1) // capacity
for index in range(count):
yield HEADER.pack(0x514D, 1, kind, message_id, index, count, len(payload)) + payload[index * capacity:(index + 1) * capacity]
class Reassembler:
def __init__(self, clock=time.monotonic):
self.clock = clock
self.next_message = 0
self.pending = None
self.buffer = bytearray()
self.index = 0
self.started = 0.0
def accept(self, part: bytes):
if not 16 < len(part) <= 514:
raise ProtocolError("BAD_REQUEST")
magic, wire, kind, mid, index, count, total = HEADER.unpack(part[:16])
if magic != 0x514D or wire != 1 or kind not in (1, 2, 3, 4) or not 1 <= count <= total <= MAX_MESSAGE:
raise ProtocolError("BAD_REQUEST")
signature = (kind, mid, count, total)
if self.pending is None:
if index != 0 or mid != self.next_message:
raise ProtocolError("BAD_REQUEST")
self.pending = signature
self.started = self.clock()
if self.clock() - self.started >= 15:
raise ProtocolError("TIMEOUT")
if self.pending != signature or index != self.index or index >= count:
raise ProtocolError("BAD_REQUEST")
self.buffer.extend(part[16:])
self.index += 1
if len(self.buffer) > total or (self.index < count and len(self.buffer) >= total):
raise ProtocolError("BAD_REQUEST")
if self.index != count:
return None
if len(self.buffer) != total:
raise ProtocolError("BAD_REQUEST")
result = kind, bytes(self.buffer)
self.pending = None
self.buffer.clear()
self.index = 0
self.next_message += 1
return result
def json_object(raw: bytes) -> dict:
def unique(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate")
result[key] = value
return result
try:
value = json.loads(raw.decode("utf-8"), object_pairs_hook=unique,
parse_constant=lambda _: (_ for _ in ()).throw(ValueError()))
if not isinstance(value, dict):
raise ValueError()
return value
except (ValueError, UnicodeError, RecursionError):
raise ProtocolError("BAD_REQUEST") from None
class Handshake:
def __init__(self, *, private_key=None, random_bytes=None):
# Explicit values are exclusively for public interoperability fixtures.
self.key = private_key or ec.generate_private_key(ec.SECP256R1())
self.random = random_bytes if random_bytes is not None else os.urandom(32)
if len(self.random) != 32:
raise ValueError("random length")
public = self.key.public_key().public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
self.hello = json.dumps(dict(protocol_major=1, protocol_minor=0,
public_key=base64.b64encode(public).decode("ascii"),
random=base64.b64encode(self.random).decode("ascii")), separators=(",", ":")).encode("utf-8")
def finish(self, peer_hello: bytes, *, server: bool):
if len(peer_hello) > 1024:
raise ProtocolError("BAD_REQUEST")
peer = json_object(peer_hello)
if type(peer.get("protocol_major")) is not int or peer["protocol_major"] != 1:
raise ProtocolError("UNSUPPORTED_VERSION")
if type(peer.get("protocol_minor")) is not int or peer["protocol_minor"] < 0:
raise ProtocolError("BAD_REQUEST")
try:
public = base64.b64decode(peer["public_key"], validate=True)
random = base64.b64decode(peer["random"], validate=True)
if len(public) != 65 or public[0] != 4 or len(random) != 32:
raise ValueError()
peer_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), public)
secret = self.key.exchange(ec.ECDH(), peer_key)
except (KeyError, TypeError, ValueError):
raise ProtocolError("BAD_REQUEST") from None
c, s = (peer_hello, self.hello) if server else (self.hello, peer_hello)
cr, sr = (random, self.random) if server else (self.random, random)
transcript = hashlib.sha256(struct.pack("!I", len(c)) + c + struct.pack("!I", len(s)) + s).digest()
material = HKDF(algorithm=hashes.SHA256(), length=72,
salt=hashlib.sha256(cr + sr).digest(), info=LABEL + transcript).derive(secret)
return Cipher(material, transcript, server=server)
class Cipher:
def __init__(self, material: bytes, transcript: bytes, *, server: bool):
self.tx_direction = 1 if server else 0
self.rx_direction = 1 - self.tx_direction
self.keys = (AESGCM(material[:32]), AESGCM(material[32:64]))
self.prefixes = (material[64:68], material[68:72])
self.transcript = transcript
self.tx_sequence = self.rx_sequence = 0
self.failed = False
def encrypt(self, payload: bytes) -> bytes:
if self.failed or not 1 <= len(payload) <= MAX_MESSAGE - 24 or self.tx_sequence >= 2**64:
raise ProtocolError("BAD_REQUEST")
seq = struct.pack("!Q", self.tx_sequence)
d = self.tx_direction
result = seq + self.keys[d].encrypt(self.prefixes[d] + seq, payload, LABEL + self.transcript + bytes([d]) + seq)
self.tx_sequence += 1
return result
def decrypt(self, record: bytes) -> bytes:
try:
if self.failed or not 25 <= len(record) <= MAX_MESSAGE or int.from_bytes(record[:8], "big") != self.rx_sequence:
raise ValueError()
seq, d = record[:8], self.rx_direction
result = self.keys[d].decrypt(self.prefixes[d] + seq, record[8:], LABEL + self.transcript + bytes([d]) + seq)
self.rx_sequence += 1
return result
except Exception:
self.failed = True
raise ProtocolError("BAD_REQUEST") from None
@@ -0,0 +1,133 @@
"""Single-owner encrypted RPC session; transport cannot bypass this gate."""
from __future__ import annotations
import json
import threading
import time
from .protocol import Handshake, ProtocolError, Reassembler
class RpcError(Exception):
def __init__(self, code, message):
self.code, self.message = code, message
super().__init__(code)
def checked_name(value):
if not isinstance(value, str) or not value.strip() or len(value) > 40:
raise RpcError("BAD_REQUEST", "名称须为 1 至 40 个字符")
if any(ord(char) < 32 or ord(char) == 127 for char in value):
raise RpcError("BAD_REQUEST", "名称包含不支持的字符")
return value.strip()
class SessionManager:
def __init__(self, dispatch, identity, *, clock=time.monotonic):
self.dispatch = dispatch
self.identity = identity
self.clock = clock
self.lock = threading.RLock()
self.owner = None
self.receiver = None
self.cipher = None
self.opened = False
self.client_name = None
self.last_request = -1
self.connected_at = self.last_activity = 0
self.generation = 0
self.peer_mtu = 23
def connect(self, owner):
with self.lock:
if self.owner is not None:
return self.owner == owner
self.owner = owner
self.receiver = Reassembler(self.clock)
self.connected_at = self.last_activity = self.clock()
return True
def disconnect(self, owner):
with self.lock:
if self.owner != owner:
return
self.owner = self.receiver = self.cipher = self.client_name = None
self.opened = False
self.last_request = -1
self.peer_mtu = 23
self.generation += 1
def expired(self):
with self.lock:
if self.owner is None:
return False
deadline = self.last_activity + 20 if self.opened else self.connected_at + 10
return self.clock() >= deadline or (
self.receiver.pending is not None and self.clock() - self.receiver.started >= 15)
def status(self):
with self.lock:
return dict(connected=self.opened, client_name=self.client_name if self.opened else None,
generation=self.generation)
def accept(self, owner, fragment):
with self.lock:
if owner != self.owner or self.expired():
raise ProtocolError("NOT_READY")
message = self.receiver.accept(fragment)
if message is None:
return None
kind, payload = message
if self.cipher is None:
if kind != 1:
raise ProtocolError("NOT_READY")
handshake = Handshake()
self.cipher = handshake.finish(payload, server=True)
return 2, handshake.hello
if kind != 3:
raise ProtocolError("BAD_REQUEST")
from .protocol import json_object
request = json_object(self.cipher.decrypt(payload))
request_id = request.get("id")
if not isinstance(request_id, str) or not 1 <= len(request_id) <= 64 or not request_id.isascii() or not request_id.isdecimal():
raise ProtocolError("BAD_REQUEST")
number = int(request_id)
response = dict(id=request_id)
try:
if number <= self.last_request:
raise RpcError("CONFLICT", "请求编号已使用")
self.last_request = number
method, params = request.get("method"), request.get("params")
if not isinstance(method, str) or not isinstance(params, dict):
raise RpcError("BAD_REQUEST", "请求格式无效")
if not self.opened:
if method != "session.open":
raise ProtocolError("NOT_READY")
name = checked_name(params.get("client_name"))
peer_mtu = params.get('receive_mtu', 23)
if type(peer_mtu) is not int or not 23 <= peer_mtu <= 517:
raise RpcError('BAD_REQUEST', '接收分片上限无效')
result = self.identity()
self.peer_mtu = peer_mtu
self.client_name = name
self.opened = True
self.generation += 1
elif method == "session.ping":
result = dict(alive=True)
elif method == "session.rename":
self.client_name = checked_name(params.get("client_name"))
result = dict(client_name=self.client_name)
elif method == "session.open":
raise RpcError("CONFLICT", "会话已经建立")
else:
result = self.dispatch(method, params)
response.update(ok=True, result=result)
self.last_activity = self.clock()
except RpcError as error:
response.update(ok=False, error=dict(code=error.code, message=error.message, retryable=False))
except ProtocolError:
raise
except Exception:
response.update(ok=False, error=dict(code="DEVICE_ERROR", message="设备操作失败,请刷新状态", retryable=False))
raw = json.dumps(response, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
return 3, self.cipher.encrypt(raw)
+79 -11
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
import subprocess
import threading
import time
import re
from copy import deepcopy
from dataclasses import dataclass
from typing import Any, Protocol, Sequence
@@ -10,9 +12,13 @@ from uuid import uuid4
class NetworkManagerError(RuntimeError):
"""A sanitized NetworkManager operation failure."""
def __init__(self, message: str, *, error_code: str = "UNKNOWN"):
super().__init__(message)
self.error_code = error_code
class NetworkBackend(Protocol):
def scan(self) -> list[dict[str, Any]]: ...
def discover_connection_uuid(self) -> str | None: ...
def read_saved(self, connection_uuid: str, *, include_secret: bool = False) -> dict[str, Any]: ...
def read_active(self) -> dict[str, Any]: ...
@@ -42,6 +48,40 @@ class NmcliNetworkManager:
executable: str = "/usr/bin/nmcli"
interface: str = "wlan0"
def scan(self) -> list[dict[str, Any]]:
rows = self._run(['--terse', '--escape', 'yes', '--fields', 'IN-USE,SSID,SECURITY,SIGNAL',
'device', 'wifi', 'list', 'ifname', self.interface, '--rescan', 'yes'], timeout=15)
strongest = {}
for row in rows.splitlines():
fields, current, escaped = [], [], False
for character in row:
if escaped:
current.append(character)
escaped = False
elif character == '\\':
escaped = True
elif character == ':':
fields.append(''.join(current))
current = []
else:
current.append(character)
fields.append(''.join(current))
if len(fields) != 4 or not fields[1]:
continue
active, ssid, security, signal = fields
if not signal.isdecimal():
continue
kind = 'open' if security in ('', '--') else (
'wpa-psk' if ('WPA1' in security or 'WPA2' in security) and '802.1X' not in security else 'unsupported')
item = dict(ssid=ssid, security=kind, signal_percent=min(100, max(0, int(signal))), connected=active == '*')
key = (ssid, kind)
if key not in strongest or item['signal_percent'] > strongest[key]['signal_percent']:
item['connected'] = item['connected'] or strongest.get(key, {}).get('connected', False)
strongest[key] = item
elif item['connected']:
strongest[key]['connected'] = True
return sorted(strongest.values(), key=lambda item: -item['signal_percent'])[:100]
def _run(
self,
arguments: Sequence[str],
@@ -105,6 +145,7 @@ class NmcliNetworkManager:
raw_dns = self._connection_value(connection_uuid, "ipv4.dns")
password = None
password_configured = False
key_mgmt = self._connection_value(connection_uuid, "802-11-wireless-security.key-mgmt")
if include_secret:
password = self._connection_value(
connection_uuid,
@@ -113,10 +154,6 @@ class NmcliNetworkManager:
)
password_configured = bool(password and password != "--")
else:
key_mgmt = self._connection_value(
connection_uuid,
"802-11-wireless-security.key-mgmt",
)
password_configured = bool(key_mgmt and key_mgmt != "--")
prefix = None
plain_address = address
@@ -129,13 +166,17 @@ class NmcliNetworkManager:
return {
"connection_uuid": connection_uuid,
"ssid": ssid,
"security": "open" if key_mgmt in ("", "--") else "wpa-psk" if key_mgmt == "wpa-psk" else "unsupported",
"password": password if password_configured else None,
"password_configured": password_configured,
"ipv4_mode": "dhcp" if method == "auto" else "manual",
"address": plain_address or None,
"prefix": prefix,
"gateway": self._connection_value(connection_uuid, "ipv4.gateway") or None,
"dns_servers": [item.strip() for item in raw_dns.splitlines() if item.strip()],
"dns_servers": list(dict.fromkeys(
item.strip() for line in raw_dns.splitlines() for item in line.split(",")
if item.strip() and item.strip() != "--"
)),
}
def read_active(self) -> dict[str, Any]:
@@ -174,9 +215,10 @@ class NmcliNetworkManager:
def _modify_arguments(settings: dict[str, Any]) -> list[str]:
arguments = [
"802-11-wireless.ssid", settings["ssid"],
"802-11-wireless-security.key-mgmt", "wpa-psk",
]
if settings.get("password") is not None:
if settings.get('security', 'wpa-psk') != 'open':
arguments.extend(['802-11-wireless-security.key-mgmt', 'wpa-psk'])
if settings.get("password") is not None and settings.get('security') != 'open':
arguments.extend(["802-11-wireless-security.psk", settings["password"]])
if settings["ipv4_mode"] == "dhcp":
arguments.extend([
@@ -206,6 +248,8 @@ class NmcliNetworkManager:
return connection_uuid
def save_connection(self, connection_uuid: str, settings: dict[str, Any]) -> None:
if settings.get('security') == 'open':
self._run(['connection', 'modify', 'uuid', connection_uuid, 'remove', '802-11-wireless-security'])
self._run([
"connection", "modify", "uuid", connection_uuid,
*self._modify_arguments(settings),
@@ -213,12 +257,36 @@ class NmcliNetworkManager:
def activate(self, connection_uuid: str, *, timeout: int = 5) -> None:
bounded = max(1, min(int(timeout), 30))
self._run([
"--wait", str(bounded), "connection", "up", "uuid", connection_uuid,
], timeout=bounded + 2)
started = int(time.time())
try:
self._run([
"--wait", str(bounded), "connection", "up", "uuid", connection_uuid,
], timeout=bounded + 2)
except NetworkManagerError:
code = "UNKNOWN"
try:
reason = self._run(["--get-values", "GENERAL.REASON", "device", "show", self.interface])
number = int(reason.split(" ", 1)[0])
if number == 53:
code = "NETWORK_UNAVAILABLE"
elif number in (5, 6, 15, 16, 17):
code = "IP_CONFIG_FAILED"
# A timeout or missing-secret request alone is not proof of a wrong password.
# Only the supplicant's explicit WRONG_KEY event from this attempt qualifies.
if code == "UNKNOWN":
result = subprocess.run(["journalctl", "-u", "wpa_supplicant.service", "--since", f"@{started}",
"--output=cat", "--no-pager"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=3)
pattern = re.compile(r"^" + re.escape(self.interface) + r": CTRL-EVENT-SSID-TEMP-DISABLED .*\breason=WRONG_KEY\b", re.MULTILINE)
if result.returncode == 0 and pattern.search(result.stdout):
code = "AUTH_FAILED"
except (NetworkManagerError, ValueError, OSError, subprocess.SubprocessError):
pass
raise NetworkManagerError("WiFi activation failed", error_code=code) from None
class MockNetworkManager:
def scan(self):
return []
def __init__(
self,
saved: dict[str, Any] | None = None,
@@ -269,7 +337,7 @@ class MockNetworkManager:
with self._lock:
previous_password = self.saved.get("password") if self.saved else None
self.saved = {"connection_uuid": connection_uuid, **deepcopy(settings)}
if settings.get("password") is None:
if settings.get("password") is None and settings.get('security') != 'open':
self.saved["password"] = previous_password
self.saved["password_configured"] = bool(self.saved.get("password"))
@@ -30,10 +30,16 @@ def validate_wifi_settings(
raise WifiConfigError("ssid must contain 1..32 UTF-8 bytes")
password_value = raw.get("password")
security = raw.get('security', 'wpa-psk')
if security not in ('open', 'wpa-psk'):
raise WifiConfigError('unsupported WiFi security')
password = None if password_value in (None, "") else str(password_value)
if password is not None and not 8 <= _utf8_length(password) <= 63:
raise WifiConfigError("password must contain 8..63 UTF-8 bytes")
if previous is None or previous.get("ssid") != ssid:
if security == 'open':
if password is not None:
raise WifiConfigError('open networks cannot have a password')
elif previous is None or previous.get("ssid") != ssid:
if password is None:
raise WifiConfigError("a new password is required when the SSID changes")
elif password is None and not previous.get("password_configured"):
@@ -79,8 +85,9 @@ def validate_wifi_settings(
return {
"ssid": ssid,
"security": security,
"password": password,
"password_configured": bool(password or (previous or {}).get("password_configured")),
"password_configured": security != 'open' and bool(password or (previous or {}).get("password_configured")),
"ipv4_mode": mode,
"address": address,
"prefix": prefix if mode == "manual" else None,
@@ -105,6 +112,8 @@ class WifiNetworkService:
self.monotonic = monotonic
self.poll_seconds = poll_seconds
self._lock = threading.RLock()
self.configuration_lock = self._lock
self.settings_revision = str(uuid4())
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self._activation_active = False
@@ -256,6 +265,7 @@ class WifiNetworkService:
else "WiFi 配置已保存,将在下次断电开机后生效"
)
self.store.set_operation({"id": operation_id, "state": state, "message": message})
self.settings_revision = str(uuid4())
return {
"operation_id": operation_id,
"activation": activation,
@@ -297,7 +307,8 @@ class WifiNetworkService:
self.store.set_operation({
"id": operation_id,
"state": "failed",
"message": str(exc),
"message": "WiFi activation failed",
"error_code": getattr(exc, "error_code", "UNKNOWN"),
})
finally:
with self._lock:
@@ -115,6 +115,9 @@ class WifiConfigStore:
"state": str(operation.get("state") or "idle"),
"message": str(operation.get("message") or ""),
}
if operation.get("error_code") is not None:
code = operation["error_code"]
safe["error_code"] = code if code in {"AUTH_FAILED", "NETWORK_UNAVAILABLE", "IP_CONFIG_FAILED", "UNKNOWN"} else "UNKNOWN"
if safe["state"] not in {"idle", "scheduled", "applying", "succeeded", "failed"}:
raise WifiConfigError("invalid wifi operation state")
with self._lock:
@@ -11,6 +11,7 @@ from typing import Any, AsyncIterable, Callable
from uuid import uuid4
from app.display.service import DisplayService
from app.system.kernel_recovery import KernelRecovery
from .diagnostics import failure_log_metadata, read_failure_log
from .package import MAX_OTA_UPLOAD_BYTES, OtaPackageError, inspect_package
@@ -84,8 +85,45 @@ class OtaManager:
"last_result": last_result,
"failure_log_available": failure_log_available,
"failure_log_bytes": failure_log_bytes,
"kernel_recovery": self.kernel_recovery_status(),
}
def _production_recovery(self) -> KernelRecovery | None:
if os.name == "nt" or self.data_root != Path("/var/lib/matrix-screen-controller"):
return None
return KernelRecovery(self.data_root)
def kernel_recovery_status(self) -> dict[str, str]:
recovery = self._production_recovery()
return recovery.status() if recovery else {"state": "not_needed", "reason": ""}
def schedule_kernel_recovery_after_ota(self) -> None:
recovery = self._production_recovery()
if recovery is None:
return
result = read_last_result(self.data_root)
if not result or result.get("status") != "success" or result.get("target_version") != str(self.software_version):
return
result_id = f"{result.get('target_version')}:{result.get('installed_at')}"
if not recovery.prepare_attempt(result_id):
return
command = [
"systemd-run", "--quiet", "--collect", "--unit=matrix-kernel-recovery",
"--on-active=5s", "/usr/bin/python3",
"/opt/matrix-screen-controller/app/system/kernel_recovery.py", "arm-and-reboot",
]
try:
subprocess.run(command, check=True, capture_output=True, timeout=10)
except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
recovery.write_record("failed", f"could not schedule kernel recovery: {exc}", attempts=0)
def finalize_kernel_recovery_after_boot(self) -> None:
recovery = self._production_recovery()
if recovery is None:
return
thread = threading.Thread(target=recovery.finalize_after_boot, name="kernel-recovery-finalize", daemon=True)
thread.start()
def failure_log(self) -> bytes | None:
last_result = read_last_result(self.data_root)
return read_failure_log(self.data_root, last_result)
@@ -41,6 +41,7 @@
<p id="last-message" class="last-message" aria-live="polite">控制台已就绪</p>
</div>
<div class="topbar-statuses" aria-label="设备实时状态">
<span id="topbar-mobile" class="resource-indicator" role="status" aria-live="polite">手机未连接</span>
<button id="topbar-current-content" class="current-content-indicator" type="button" aria-haspopup="dialog" aria-controls="current-display-dialog" aria-live="polite" aria-atomic="true">
当前画面:正在读取…
</button>
@@ -3618,6 +3618,18 @@
"source": "index.html::#workspace-templates > div:nth-of-type(1) > div:nth-of-type(1) > p:nth-of-type(1)::text[0]",
"text": "内容"
},
"copy.9977eafad4a6d6e9": {
"kind": "html_text",
"placeholders": [],
"render": {
"selector": "#topbar-mobile",
"text_index": 0,
"type": "static_text"
},
"scope": "global",
"source": "index.html::#topbar-mobile::text[0]",
"text": "手机未连接"
},
"copy.9a395662799ecd7a": {
"kind": "html_text",
"placeholders": [],
@@ -16,6 +16,7 @@ import {
} from "../current-display-playback.js";
const statusEls = {
mobile: document.getElementById("topbar-mobile"),
orientation: document.getElementById("status-orientation"),
brightness: document.getElementById("status-brightness"),
currentContent: document.getElementById("topbar-current-content"),
@@ -760,9 +761,22 @@ export async function refreshDeviceStorage() {
}
}
let lastMobileSession = null;
export async function refreshStatus({ source = "manual" } = {}) {
try {
const data = await apiJson("/api/status");
const mobile = data.mobile;
if (statusEls.mobile) {
statusEls.mobile.textContent = mobile?.connected
? `手机:${mobile.client_name || "已连接"}`
: (mobile?.available === false ? "手机蓝牙暂不可用" : "手机未连接");
const session = `${data.service?.instance_id || ""}:${mobile?.generation || 0}`;
if (mobile?.connected && lastMobileSession !== null && session !== lastMobileSession) {
announce(`${mobile.client_name || "手机"}已连接`);
}
lastMobileSession = session;
}
syncCurrentDisplayDialogStatus(data);
statusEls.orientation.textContent = `${data.state.orientation}°`;
showDeviceBrightness(data.state);
@@ -263,6 +263,20 @@ function applyOtaStatus(status) {
otaProgressGroup.hidden = true;
setOtaFeedback("");
}
if (job?.phase !== "failed") {
const recovery = status?.kernel_recovery;
if (recovery?.state === "pending") {
setOtaFeedback("应用更新完成;正在进行一次候选内核恢复重启…", "pending");
} else if (recovery?.state === "succeeded") {
setOtaFeedback("候选内核与 CPU 调频策略已恢复。", "ready");
} else if (recovery?.state === "failed") {
setOtaFeedback(`内核恢复失败:${recovery.reason || "请查看设备诊断"}`, "error");
} else if (recovery?.state === "requires_package") {
setOtaFeedback("应用已更新;内核材料缺失或损坏,需要专用修复包。", "error");
} else if (recovery?.state === "ready") {
setOtaFeedback("当前设备运行原内核,性能模式不可用;下次 OTA 将尝试轻量恢复。", "error");
}
}
}
}
@@ -281,7 +295,7 @@ async function pollOtaUntilFinished() {
const status = await fetchOtaStatus();
if (disconnected) setOtaFeedback("设备服务已恢复,正在确认更新结果…", "pending");
applyOtaStatus(status);
if (!status.active) return;
if (!status.active && status.kernel_recovery?.state !== "pending") return;
} catch {
disconnected = true;
setOtaFeedback("设备正在切换软件版本,等待服务恢复…", "pending");
@@ -297,7 +311,7 @@ async function loadOtaStatus() {
try {
const status = await fetchOtaStatus();
applyOtaStatus(status);
if (status.active) pollOtaUntilFinished();
if (status.active || status.kernel_recovery?.state === "pending") pollOtaUntilFinished();
} catch (error) {
otaCurrentVersion.textContent = "读取失败";
otaFeatureUpdatedAt.textContent = "读取失败";
@@ -0,0 +1,289 @@
from __future__ import annotations
import argparse
import hashlib
import json
import os
from pathlib import Path
import subprocess
import time
from typing import Any
from uuid import uuid4
CANDIDATE_RELEASE = "6.1.31-matrix-axp313a1"
RECORD_NAME = "kernel-recovery.json"
BOOT_HASHES = {
"Image-matrix-axp313a1": "f8b6801cb9a300ee126635fd8834c5cdc6bd55857bb4438982db7f6fd78a8541",
"sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb": "10ef0eca3b9ec2063a8e4f47d0afc5e30b6e1a2ed8c4fcf9b0450630e2c11f66",
"sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb": "d0203cd03eb2c316cb36ed94016cbeb469d499ce06d866aaeef0494c45037c12",
}
MODULE_COUNT = 3035
MODULE_DIGEST = "8326558d841bc137c87d153d68903da574ebccaaee1bc6d6c129a8526af29346"
class KernelRecovery:
def __init__(self, data_root: Path, *, host_root: Path = Path("/")) -> None:
self.data_root = Path(data_root)
self.host_root = Path(host_root)
self.record_path = self.data_root / RECORD_NAME
def host(self, absolute: str) -> Path:
return self.host_root / absolute.lstrip("/")
@staticmethod
def _digest(path: Path) -> bytes:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.digest()
def boot_id(self) -> str:
return self.host("/proc/sys/kernel/random/boot_id").read_text(encoding="ascii").strip()
def release(self) -> str:
return self.host("/proc/sys/kernel/osrelease").read_text(encoding="ascii").strip()
def capability(self) -> dict[str, Any]:
policies = {}
for policy in sorted(self.host("/sys/devices/system/cpu/cpufreq").glob("policy*")):
if not policy.is_dir():
continue
try:
current = (policy / "scaling_governor").read_text(encoding="ascii").strip()
available = (policy / "scaling_available_governors").read_text(encoding="ascii").split()
except OSError:
current, available = "", []
policies[policy.name] = {"current": current, "performance": "performance" in available}
try:
release = self.release()
except OSError:
release = "unavailable"
return {
"release": release,
"policies": policies,
"available": bool(policies) and all(value["current"] and value["performance"] for value in policies.values()),
"marker": self.host("/boot/matrix-kernel-good").is_file(),
}
def verify_candidate(self) -> str | None:
boot = self.host("/boot")
for name, expected in BOOT_HASHES.items():
path = boot / name
try:
if self._digest(path).hex() != expected:
return f"candidate file differs from registered artifact: {name}"
except OSError:
return f"candidate file is missing or unreadable: {name}"
original = (
"Image", "sun50i-h616-walnutpi-1b.dtb", "sun50i-h616-walnutpi-1b-emmc.dtb",
"boot.cmd.matrix-original", "boot.scr.matrix-original", "matrix-original.SHA256SUMS",
)
if any(not (boot / name).is_file() for name in original):
return "original kernel rollback files are incomplete"
try:
command = (boot / "boot.cmd").read_text(encoding="utf-8")
script = (boot / "boot.scr").read_bytes()
except (OSError, UnicodeError):
return "managed boot script is unreadable"
if command.count("matrix_kernel_candidate=1") != 1 or command.count("matrix-kernel-good") != 2:
return "managed boot script is missing or ambiguous"
if b"matrix_kernel_candidate=1" not in script or b"Image-matrix-axp313a1" not in script:
return "compiled boot script does not select the candidate"
health_unit = self.host("/etc/systemd/system/matrix-axp313a-health.service")
health_script = self.host("/opt/matrix-screen-controller-system/axp313a_kernel_health.py")
try:
unit = health_unit.read_text(encoding="utf-8")
except OSError:
return "candidate health service is missing"
if "ConditionKernelCommandLine=matrix_kernel_candidate=1" not in unit or not health_script.is_file():
return "candidate health service is incomplete"
enabled = self.host("/etc/systemd/system/multi-user.target.wants/matrix-axp313a-health.service")
if not enabled.exists():
return "candidate health service is not enabled"
module_root = self.host(f"/lib/modules/{CANDIDATE_RELEASE}")
if not (module_root / "modules.dep").is_file():
return "candidate module dependency index is missing"
modules = sorted(module_root.rglob("*.ko"), key=lambda item: item.relative_to(module_root).as_posix())
if len(modules) != MODULE_COUNT:
return "candidate module count differs from registered artifact"
aggregate = hashlib.sha256()
try:
for module in modules:
aggregate.update(module.relative_to(module_root).as_posix().encode("ascii"))
aggregate.update(b"\0")
aggregate.update(self._digest(module))
except (OSError, UnicodeError):
return "candidate modules are unreadable"
if aggregate.hexdigest() != MODULE_DIGEST:
return "candidate modules differ from registered artifact"
return None
def read_record(self) -> dict[str, Any] | None:
if not self.record_path.exists():
return None
try:
value = json.loads(self.record_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError):
return {"state": "failed", "reason": "kernel recovery record is invalid; manual inspection required"}
if not isinstance(value, dict) or value.get("schema_version") != 1:
return {"state": "failed", "reason": "kernel recovery record has an unsupported schema"}
return value
def write_record(self, state: str, reason: str, **fields: Any) -> None:
self.data_root.mkdir(parents=True, exist_ok=True)
document = {"schema_version": 1, "state": state, "reason": reason,
"recorded_at": time.time(), **fields}
temporary = self.record_path.with_name(f".{RECORD_NAME}.{uuid4().hex}.tmp")
try:
with temporary.open("x", encoding="utf-8", newline="\n") as handle:
json.dump(document, handle, ensure_ascii=False, sort_keys=True)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, self.record_path)
finally:
temporary.unlink(missing_ok=True)
def status(self) -> dict[str, str]:
record = self.read_record()
if record:
if record.get("state") == "pending" and record.get("attempts") == 0:
recorded_at = record.get("recorded_at")
if isinstance(recorded_at, (int, float)) and time.time() - recorded_at > 180:
self.write_record("failed", "kernel recovery task did not start", attempts=0)
return {"state": "failed", "reason": "kernel recovery task did not start"}
if record.get("state") == "pending" and record.get("attempts") == 1:
recorded_at = record.get("recorded_at")
if (isinstance(recorded_at, (int, float)) and time.time() - recorded_at > 300
and self.boot_id() == record.get("source_boot_id")):
self.write_record("failed", "candidate reboot did not occur", attempts=1)
return {"state": "failed", "reason": "candidate reboot did not occur"}
if record.get("state") == "succeeded":
capability = self.capability()
if capability["release"] != CANDIDATE_RELEASE:
return {"state": "failed", "reason": "candidate kernel was lost after a successful recovery"}
if not capability["available"] or not capability["marker"]:
return {"state": "pending", "reason": "waiting for candidate boot health"}
return {"state": str(record.get("state", "failed")), "reason": str(record.get("reason", ""))}
capability = self.capability()
if capability["release"] == CANDIDATE_RELEASE and capability["available"] and capability["marker"]:
return {"state": "not_needed", "reason": ""}
return {"state": "ready", "reason": "candidate kernel is not active"}
def prepare_attempt(self, ota_result_id: str) -> bool:
capability = self.capability()
if capability["release"] == CANDIDATE_RELEASE and capability["available"] and capability["marker"]:
return False
if self.read_record() is not None:
return False
if capability["release"] != "6.1.31":
self.write_record("failed", "automatic recovery only supports the original 6.1.31 kernel",
ota_result_id=ota_result_id)
return False
reason = self.verify_candidate()
if reason:
self.write_record("requires_package", reason, ota_result_id=ota_result_id)
return False
self.write_record("pending", "waiting for one candidate boot", ota_result_id=ota_result_id,
source_boot_id=self.boot_id(), attempts=0)
return True
def arm_and_reboot(self, *, reboot: bool = True) -> None:
record = self.read_record()
if not record or record.get("state") != "pending" or record.get("attempts") != 0:
raise RuntimeError("no unused kernel recovery attempt is pending")
recorded_at = record.get("recorded_at")
if isinstance(recorded_at, (int, float)) and time.time() - recorded_at > 180:
self.write_record("failed", "kernel recovery task started too late", attempts=0)
return
if self.boot_id() != record.get("source_boot_id"):
self.write_record("failed", "boot changed before kernel recovery was armed", attempts=0)
return
if self.release() != "6.1.31":
self.write_record("failed", "automatic recovery requires the original 6.1.31 kernel", attempts=0)
return
for _ in range(60):
worker = subprocess.run(["systemctl", "is-active", "--quiet", "matrix-screen-controller-ota.service"],
check=False, timeout=5)
if worker.returncode != 0 and not (self.data_root / "ota/component-transaction").exists():
break
time.sleep(1)
else:
self.write_record("failed", "OTA component transaction did not finish", attempts=1)
return
reason = self.verify_candidate()
if reason:
self.write_record("requires_package", reason, attempts=1)
return
marker = self.host("/boot/matrix-kernel-good")
temporary = marker.with_name(".matrix-kernel-good.recovery")
if marker.exists() or temporary.exists():
self.write_record("failed", "candidate boot marker already exists; manual inspection required", attempts=1)
return
try:
with temporary.open("x", encoding="ascii", newline="\n") as handle:
handle.write(f"{CANDIDATE_RELEASE}\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, marker)
if hasattr(os, "sync"):
os.sync()
self.write_record("pending", "candidate reboot requested", attempts=1,
source_boot_id=record["source_boot_id"], ota_result_id=record.get("ota_result_id", ""))
if reboot:
subprocess.run(["systemctl", "reboot"], check=True, timeout=15)
except Exception as exc:
temporary.unlink(missing_ok=True)
marker.unlink(missing_ok=True)
self.write_record("failed", f"could not request candidate reboot: {exc}", attempts=1)
raise
def finalize_after_boot(self, *, wait_seconds: int = 100) -> None:
record = self.read_record()
if record and record.get("state") == "succeeded" and self.boot_id() != record.get("successful_boot_id"):
for _ in range(wait_seconds):
capability = self.capability()
if capability["release"] != CANDIDATE_RELEASE:
self.write_record("failed", "candidate kernel was lost after a successful recovery", attempts=1)
return
if capability["available"] and capability["marker"]:
self.write_record("succeeded", "candidate kernel and cpufreq passed boot health",
attempts=1, successful_boot_id=self.boot_id())
return
time.sleep(1)
self.write_record("failed", "candidate boot health did not complete", attempts=1)
return
if not record or record.get("state") != "pending" or record.get("attempts") != 1:
return
if self.boot_id() == record.get("source_boot_id"):
return
for _ in range(wait_seconds):
capability = self.capability()
if capability["release"] != CANDIDATE_RELEASE:
self.write_record("failed", "candidate boot fell back to the original kernel", attempts=1)
return
if capability["available"] and capability["marker"]:
self.write_record("succeeded", "candidate kernel and cpufreq passed boot health",
attempts=1, successful_boot_id=self.boot_id())
return
time.sleep(1)
self.write_record("failed", "candidate boot health did not complete", attempts=1)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("command", choices=("arm-and-reboot", "status"))
parser.add_argument("--data-root", type=Path, default=Path("/var/lib/matrix-screen-controller"))
args = parser.parse_args()
recovery = KernelRecovery(args.data_root)
if args.command == "status":
print(json.dumps(recovery.status(), ensure_ascii=False))
else:
recovery.arm_and_reboot()
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -224,6 +224,11 @@ class PerformanceModeManager:
except OSError as exc:
self._last_error = f"could not read current governors: {exc}"
effective_requested = self._requested if requested is None else requested
error = self._last_error
if not self._policy_paths() and error is None:
error = "当前内核未提供 CPU 调频策略;请检查候选内核是否回退"
elif self._policy_paths() and not self._restore_governors and error is None:
error = "无法读取 CPU 调频策略的原始值"
effective = bool(
effective_requested
and current
@@ -235,7 +240,7 @@ class PerformanceModeManager:
"effective": effective,
"current_governors": current,
"restore_governors": dict(self._restore_governors),
"last_error": self._last_error,
"last_error": error,
}