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

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
+1 -1
View File
@@ -1 +1 @@
2026-09-10T11:10+08:00
2026-09-26T16:47+08:00
+6
View File
@@ -4,6 +4,10 @@
## 路径与运行方式
移动端控制器独立维护,入口见 `../移动端相关内容/README.md`;设备侧新增契约见 `../整体开发需求/03_移动端接入需求.md`。设备更新只评估并登记手机兼容影响,不自动迭代 App。BLE 与网页应复用公共业务层;不得让 App 依赖网页布局。当前蓝牙接入尚待实现,既有脚本仍执行原禁用策略,不能将需求文档当成设备已启用蓝牙。
普通前端/BLE 入口改动不新增实屏视觉验收。确需视觉检查时先停止、解释原因、询问用户如何启动并等待指示,不自动开摄像头、DroidCam 或视觉程序,不主动切换实屏测试图案。软件统计和逻辑帧检查按正常自动测试执行。
下列源码运行、编译和手工部署命令均在本文件所在的 `核桃派软件源代码/` 目录执行;发布命令另行标明从工作区根目录执行。Windows 本机测试使用根目录下 `测试相关资料/如何测试/本机测试环境/README.md` 的命令。
- 程序:`/opt/matrix-screen-controller`
@@ -78,6 +82,8 @@ sudo sh scripts/update_walnutpi.sh
## 浏览器全量 OTA
OTA 会记录更新前的候选内核、cpufreq 和性能模式状态。原本正常的设备若在更新中失去这些能力,应用更新按事务回滚;原本已回退到原内核的设备允许先更新应用。更新完成后,若已安装的候选 Image、两份 DTB、模块树、受管启动脚本和健康服务均与登记材料一致,独立任务仅安排一次候选重启;结果保存在持久根的 `kernel-recovery.json`,通过 `/api/ota/status.kernel_recovery` 查询。失败后不会在后续 OTA 自动重试。缺失或损坏候选材料时只提示需要专用内核修复包,普通 OTA 不携带大型内核归档。
系统设置显示 `VERSION` 中的正式软件版本和 `FEATURE_UPDATED_AT` 中固定的北京时间,并允许上传完整 `.ota` 文件。页面不展示最近 OTA 结果,但上传、安装、断线恢复和失败反馈保持可见;失败后会自动打开可滚动、可复制的完整诊断日志,关闭后仍可重新查看或复制。设备只在 `/var/lib/matrix-screen-controller/ota/last-failure.log` 原子保留最近一份不超过 1 MiB 的脱敏失败日志,下一次成功更新会将其删除;`GET /api/ota/failure-log` 仅在最近结果失败且日志存在时返回 `text/plain`,并禁止缓存。设备不连接更新服务器,不使用增量、加密或签名;包内 SHA-256 只检查文件损坏。同版和旧版在停服前拒绝,新版失败自动恢复更新前程序与用户数据。
从 1.0.3 首次升级到包含该能力的版本时,安装过程仍由 1.0.3 的旧 worker 控制,因此新日志页面只能在升级成功后使用。新版本测试入口兼容旧 worker 传入的隔离变量,并将 pytest、应用数据、运行数据和 Python 临时文件全部约束到 OTA 事务目录;它不会为安装日志功能而忽略测试失败或绕过原子回滚。
+1 -1
View File
@@ -1 +1 @@
1.1.1
1.1.2
+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,
}
@@ -1,4 +1,4 @@
-r requirements.txt
pytest==8.4.1
httpx==0.28.1
paramiko==4.0.0
@@ -2,3 +2,5 @@ fastapi==0.116.1
uvicorn[standard]==0.35.0
pillow>=10.0
fonttools>=4.0,<5
cryptography==46.0.5
dbus-next==0.2.3
@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import ipaddress
import json
import os
import re
import subprocess
@@ -15,7 +16,14 @@ PRIVATE_CREDENTIAL = PurePosixPath("测试相关资料/核桃派的用户名和
EXAMPLE_CREDENTIAL = PurePosixPath(
"测试相关资料/核桃派的用户名和密码和ip/用户名密码ip.example.txt"
)
PRIVATE_MOBILE_REGISTRATION = PurePosixPath(
"移动端相关内容/安卓app/如何安卓测试/测试设备登记/测试设备.local.json"
)
EXAMPLE_MOBILE_REGISTRATION = PRIVATE_MOBILE_REGISTRATION.with_name("测试设备.example.json")
BINARY_EXTENSIONS = {
".apk",
".aab",
".jar",
".deb",
".dll",
".docx",
@@ -120,7 +128,7 @@ def _host_text_patterns() -> list[re.Pattern[str]]:
def _private_value_markers() -> tuple[str, ...]:
path = WORKSPACE_ROOT.joinpath(*PRIVATE_CREDENTIAL.parts)
if not path.is_file():
return ()
return _mobile_value_markers()
fields: dict[str, str] = {}
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
@@ -132,13 +140,36 @@ def _private_value_markers() -> tuple[str, ...]:
key, value = (part.strip() for part in line.split(separator, 1))
fields[key] = value
public_values = {fields.get(key, "") for key in PUBLIC_IMAGE_KEYS}
return tuple(
return _mobile_value_markers() + tuple(
value
for key in CURRENT_DEVICE_KEYS
if len(value := fields.get(key, "")) >= 4 and value not in public_values
)
def _mobile_value_markers() -> tuple[str, ...]:
path = WORKSPACE_ROOT.joinpath(*PRIVATE_MOBILE_REGISTRATION.parts)
if not path.is_file():
return ()
try:
document = json.loads(path.read_text(encoding="utf-8"))
devices = document["devices"]
if not isinstance(devices, list):
raise ValueError
values = []
for device in devices:
if not isinstance(device, dict):
raise ValueError
serial = device.get("serial", "")
if not isinstance(serial, str):
raise ValueError
if serial and "<" not in serial and len(serial) >= 4:
values.append(serial)
return tuple(values)
except (OSError, UnicodeError, ValueError, KeyError, TypeError):
raise HygieneError("移动端真实登记损坏,无法安全提取私有标识;未输出内容。") from None
def _binary_markers(private_values: tuple[str, ...] = ()) -> list[bytes]:
markers: list[bytes] = []
host_values = {
@@ -178,7 +209,7 @@ def _text_issues(
if any(pattern.search(text) for pattern in _host_text_patterns()):
issues.append("包含开发电脑专属路径、用户名或主机名")
if any(value in text for value in private_values):
issues.append("包含当前设备私有凭据值")
issues.append("包含当前设备私有凭据或测试手机标识")
key_header = re.compile("-----BEGIN " + r"(?:OPENSSH |RSA |EC |DSA )?PRIVATE KEY-----")
if key_header.search(text):
@@ -217,9 +248,12 @@ def audit_paths(paths: list[PurePosixPath], *, scan_binary: bool) -> list[tuple[
findings: list[tuple[str, str]] = []
private_values = _private_value_markers()
for relative in paths:
if relative == PRIVATE_CREDENTIAL:
if relative in (PRIVATE_CREDENTIAL, PRIVATE_MOBILE_REGISTRATION):
findings.append((relative.as_posix(), "真实凭据进入 Git 候选集合"))
continue
if relative.suffix.lower() in {".jks", ".keystore"}:
findings.append((relative.as_posix(), "签名密钥文件进入 Git 候选集合"))
continue
path = WORKSPACE_ROOT.joinpath(*relative.parts)
if not path.is_file():
continue
@@ -243,6 +277,15 @@ def _check_repository_contract(paths: list[PurePosixPath], staged: bool) -> list
ignored = _git("check-ignore", "--quiet", "--", PRIVATE_CREDENTIAL.as_posix(), check=False)
if ignored.returncode != 0:
findings.append((PRIVATE_CREDENTIAL.as_posix(), "真实凭据未被 .gitignore 精确排除"))
mobile_ignored = _git("check-ignore", "--quiet", "--", PRIVATE_MOBILE_REGISTRATION.as_posix(), check=False)
if mobile_ignored.returncode != 0:
findings.append((PRIVATE_MOBILE_REGISTRATION.as_posix(), "真实手机登记未被 .gitignore 排除"))
if staged:
mobile_example = _git("cat-file", "-e", f":{EXAMPLE_MOBILE_REGISTRATION.as_posix()}", check=False)
if mobile_example.returncode != 0:
findings.append((EXAMPLE_MOBILE_REGISTRATION.as_posix(), "手机空白登记示例不在 Git 索引"))
elif EXAMPLE_MOBILE_REGISTRATION not in paths:
findings.append((EXAMPLE_MOBILE_REGISTRATION.as_posix(), "手机空白登记示例不在 Git 候选集合"))
if staged:
example_in_index = _git("cat-file", "-e", f":{EXAMPLE_CREDENTIAL.as_posix()}", check=False)
@@ -13,8 +13,6 @@ from typing import Callable, Sequence
EXPECTED_PINCTRL = "allwinner,sun50i-h616-pinctrl"
DISABLED_SERVICES = (
"lightdm.service",
"bluetooth.service",
"aw859-bluetooth.service",
"gpioc-server.service",
"map_device.service",
)
@@ -96,10 +96,9 @@ make -C "$STAGING/app/display/native" clean all test
systemctl set-default multi-user.target
systemctl disable --now \
lightdm.service \
bluetooth.service \
aw859-bluetooth.service \
gpioc-server.service \
map_device.service
DEFER_SERVICE_START="$DEFER_SERVICE_START" /bin/sh "$STAGING/scripts/install_mobile_bluetooth.sh"
if [ "$DEFER_SERVICE_START" = "1" ]; then
"$STAGING/.venv/bin/python" "$STAGING/scripts/dedicated_host.py" check --service
else
@@ -0,0 +1,48 @@
#!/bin/sh
# Enable the existing WalnutPi vendor controller, without changing WiFi rfkill.
set -eu
test "$(id -u)" = 0 || { echo 'root is required' >&2; exit 1; }
test -x /usr/bin/hciattach
test -x /usr/sbin/rfkill
test -f /lib/systemd/system/aw859-bluetooth.service
install -d -m 0755 /etc/systemd/system/aw859-bluetooth.service.d
cat > /etc/systemd/system/aw859-bluetooth.service.d/50-matrix-mobile.conf <<'EOF'
[Service]
ExecStartPre=
ExecStartPre=/usr/sbin/modprobe hci_uart
ExecStartPre=/usr/sbin/rfkill unblock bluetooth
ExecStart=
ExecStart=/usr/bin/hciattach -n -s 1500000 /dev/ttyBT0 sprd
Restart=on-failure
RestartSec=5
EOF
install -d -m 0755 /etc/systemd/system/bluetooth.service.d
cat > /etc/systemd/system/bluetooth.service.d/50-matrix-mobile.conf <<'EOF'
[Unit]
After=aw859-bluetooth.service
Wants=aw859-bluetooth.service
EOF
python3 - <<'PY'
from pathlib import Path
import re
p = Path('/etc/bluetooth/main.conf')
text = p.read_text(encoding='utf-8')
backup = p.with_name('main.conf.matrix-mobile-original')
if not backup.exists():
with backup.open('x', encoding='utf-8') as stream:
stream.write(text)
for key, value in [('ControllerMode', 'le'), ('AlwaysPairable', 'false')]:
pattern = r'(?m)^\s*' + key + r'\s*=.*$'
if re.search(pattern, text):
text = re.sub(pattern, key + '=' + value, text)
else:
text = text.replace('[General]', '[General]\n' + key + '=' + value, 1)
p.write_text(text, encoding='utf-8')
PY
systemctl unmask bluetooth.service aw859-bluetooth.service
systemctl daemon-reload
systemctl enable bluetooth.service aw859-bluetooth.service
if [ "${DEFER_SERVICE_START:-0}" != 1 ]; then
# Bluetooth failure must not prevent deploying or running the display service.
systemctl start aw859-bluetooth.service bluetooth.service || echo 'Bluetooth unavailable; inspect mobile status' >&2
fi
@@ -32,6 +32,8 @@ RUNTIME_GUARD_BODY = b'# Managed by the OTA component transaction\n[Service]\nRu
SERVICE = 'matrix-screen-controller.service'
WORKER = 'matrix-screen-controller-ota.service'
FRP = 'matrix-screen-frpc.service'
MOBILE_SERVICES = ('aw859-bluetooth.service', 'bluetooth.service')
MOBILE_FILES = ('bluetooth-conf', 'bluetooth-original', 'bluetooth-dropin', 'aw859-dropin')
JOURNAL = Path('ota/component-transaction')
FILES = {
'frpc': Path('/usr/local/bin/frpc'),
@@ -39,6 +41,10 @@ FILES = {
'frpc-dropin': Path('/etc/systemd/system/matrix-screen-frpc.service.d/user.conf'),
'app-unit': Path('/etc/systemd/system/matrix-screen-controller.service'),
'ota-unit': Path('/etc/systemd/system/matrix-screen-controller-ota.service'),
'bluetooth-conf': Path('/etc/bluetooth/main.conf'),
'bluetooth-original': Path('/etc/bluetooth/main.conf.matrix-mobile-original'),
'bluetooth-dropin': Path('/etc/systemd/system/bluetooth.service.d/50-matrix-mobile.conf'),
'aw859-dropin': Path('/etc/systemd/system/aw859-bluetooth.service.d/50-matrix-mobile.conf'),
}
@@ -229,15 +235,18 @@ def begin(source: Path, candidate: Path):
if RUNTIME_GUARD.exists():
raise RuntimeError('存在未清理的 OTA 运行目录保护,请先恢复')
bundle = source / 'system-dependencies/frpc'
mobile_installer = source / 'scripts/install_mobile_bluetooth.sh'
if not bundle.is_dir():
check_installed(source, version)
return
if not mobile_installer.is_file():
return
# Verify the pinned binary, not just a self-reported checksum file.
from app.ota.policy import required_components
expected = required_components(source, SoftwareVersion.parse(version))['frpc']
if hashlib.sha256((bundle / 'frpc').read_bytes()).hexdigest() != expected['sha256']:
raise RuntimeError('frpc payload differs from the registered dependency')
user = account()
if bundle.is_dir():
expected = required_components(source, SoftwareVersion.parse(version))['frpc']
if hashlib.sha256((bundle / 'frpc').read_bytes()).hexdigest() != expected['sha256']:
raise RuntimeError('frpc payload differs from the registered dependency')
user = account() if bundle.is_dir() else None
for root in (DATA, candidate):
if (root / JOURNAL).exists():
raise RuntimeError('存在未完成组件事务,请先恢复')
@@ -249,6 +258,13 @@ def begin(source: Path, candidate: Path):
'permissions': permission_snapshot(DATA), 'files': {},
'active': run(['systemctl', 'is-active', '--quiet', FRP], check=False).returncode == 0,
'enabled': run(['systemctl', 'is-enabled', '--quiet', FRP], check=False).returncode == 0}
if mobile_installer.is_file():
record['mobile_services'] = {
unit: {
'enabled_state': run(['systemctl', 'is-enabled', unit], check=False).stdout.decode().strip(),
'active': run(['systemctl', 'is-active', '--quiet', unit], check=False).returncode == 0,
} for unit in MOBILE_SERVICES
}
try:
for name, path in FILES.items():
record['files'][name] = metadata(path) if path.exists() else None
@@ -273,11 +289,14 @@ def begin(source: Path, candidate: Path):
'/usr/bin/python3', str(HELPER), 'watch'])
protect_runtime()
env = os.environ.copy()
env.update(FRPC_BUNDLE=str(bundle), FRPC_RUN_USER=user)
run(['/bin/sh', str(source / 'scripts/install_frpc_system.sh')], env=env)
if bundle.is_dir():
env.update(FRPC_BUNDLE=str(bundle), FRPC_RUN_USER=user)
run(['/bin/sh', str(source / 'scripts/install_frpc_system.sh')], env=env)
if mobile_installer.is_file():
run(['/bin/sh', str(mobile_installer)], env=env)
mirror_permissions(candidate)
check_installed(source, version)
print('FRP component transaction prepared; waiting for OTA health result')
print('System component transaction prepared; waiting for OTA health result')
except BaseException:
# The watcher handles subsequent worker failure. Restore immediately too,
# so a failed migration never leaves new system files while rolling back.
@@ -289,6 +308,10 @@ def begin(source: Path, candidate: Path):
def restore_components(journal: Path, record: dict):
mobile_services = record.get('mobile_services', {})
for unit in reversed(MOBILE_SERVICES):
if unit in mobile_services:
run(['systemctl', 'stop', unit], check=False)
run(['systemctl', 'stop', FRP], check=False)
for name in ('frpc', 'frpc-unit', 'frpc-dropin'):
path = FILES[name]
@@ -299,6 +322,15 @@ def restore_components(journal: Path, record: dict):
atomic(path, (journal / name).read_bytes(), item['mode'])
apply_metadata(path, item)
restore_permissions(DATA, record['permissions'])
if mobile_services:
for name in MOBILE_FILES:
path = FILES[name]
item = record['files'][name]
if item is None:
path.unlink(missing_ok=True)
else:
atomic(path, (journal / name).read_bytes(), item['mode'])
apply_metadata(path, item)
run(['systemctl', 'daemon-reload'])
# A previously absent unit cannot be disabled; avoid treating absence as error.
if record['files']['frpc-unit'] is not None:
@@ -309,6 +341,24 @@ def restore_components(journal: Path, record: dict):
link.unlink(missing_ok=True)
if record['active']:
run(['systemctl', 'start', FRP])
for unit in MOBILE_SERVICES:
previous = mobile_services.get(unit)
if previous is None:
continue
run(['systemctl', 'unmask', unit])
state = previous['enabled_state']
run(['systemctl', 'disable', unit], check=False)
if state == 'enabled':
run(['systemctl', 'enable', unit])
elif state == 'enabled-runtime':
run(['systemctl', 'enable', '--runtime', unit])
elif state in ('masked', 'masked-runtime'):
args = ['systemctl', 'mask']
if state == 'masked-runtime':
args.append('--runtime')
run(args + [unit])
if previous['active']:
run(['systemctl', 'start', unit])
def locate_journal():
@@ -14,11 +14,13 @@ import sys
import time
import traceback
from typing import Any
import urllib.request
from app.ota.diagnostics import DiagnosticLog, clear_failure_log, persist_failure_log, sha256_file
from app.ota.package import extract_payload, inspect_package
from app.ota.state import read_json, utc_now, write_json, write_last_result
from app.ota.versioning import SoftwareVersion
from app.system.kernel_recovery import CANDIDATE_RELEASE, KernelRecovery
TARGET = Path("/opt/matrix-screen-controller")
RELEASES = Path("/opt/matrix-screen-controller.releases")
@@ -34,6 +36,32 @@ class UpdateFailed(RuntimeError):
pass
def verify_kernel_capability_retained(
before: dict[str, Any],
after: dict[str, Any],
performance_before: dict[str, Any],
performance_after: dict[str, Any],
) -> None:
previously_healthy = (
before.get("release") == CANDIDATE_RELEASE
and before.get("available") is True
and before.get("marker") is True
)
if not previously_healthy:
return
if (after["release"] != CANDIDATE_RELEASE or not after["available"] or not after["marker"]
or set(after["policies"]) != set(before["policies"])):
raise UpdateFailed("OTA lost the validated candidate kernel or cpufreq policy")
requested = performance_before.get("requested")
if (performance_after.get("available") is not True or performance_after.get("requested") is not requested
or (requested is True and performance_after.get("effective") is not True)):
raise UpdateFailed("OTA changed performance mode availability or effective state")
if requested is False and performance_after.get("current_governors") != {
name: value["current"] for name, value in before["policies"].items()
}:
raise UpdateFailed("OTA did not restore the original CPU governors")
def snapshot(root: Path, *, ignored: set[str] | None = None) -> dict[str, tuple[int, str]]:
records: dict[str, tuple[int, str]] = {}
ignored = ignored or set()
@@ -81,6 +109,9 @@ class Transaction:
f"job_id={self.job_id} current_version={self.current_version} "
f"target_version={self.target_version} packaged_at={request.get('packaged_at') or ''}"
)
self.kernel_recovery = KernelRecovery(DATA_ROOT)
self.kernel_before: dict[str, Any] | None = None
self.performance_before: dict[str, Any] | None = None
self.job = {
"id": self.job_id,
"target_version": str(self.target_version),
@@ -118,6 +149,13 @@ class Transaction:
)
def prepare(self) -> None:
self.kernel_before = self.kernel_recovery.capability()
with urllib.request.urlopen("http://127.0.0.1:8080/api/status", timeout=8) as response:
self.performance_before = json.loads(response.read().decode("utf-8"))["system"]["performance_mode"]
self.diagnostics.write(
f"kernel_before={self.kernel_before['release']} "
f"cpufreq_available={self.kernel_before['available']} marker={self.kernel_before['marker']}"
)
if any(path.exists() for path in (self.work_root, self.release, self.data_candidate, self.data_backup)):
raise UpdateFailed("OTA transaction paths already exist")
self.work_root.mkdir(parents=True, mode=0o700)
@@ -273,6 +311,15 @@ class Transaction:
raise UpdateFailed("new service reports the wrong hardware mapping")
if driver.get("oe_timing_backend") != "h618-pwm4" or driver.get("oe_pulse_faults") != 0:
raise UpdateFailed("new service did not pass the PWM4 OE health check")
before = self.kernel_before or {}
if (before.get("available") and before.get("marker")
and before.get("release") == CANDIDATE_RELEASE):
verify_kernel_capability_retained(
before, self.kernel_recovery.capability(), self.performance_before or {},
(status.get("system") or {}).get("performance_mode") or {},
)
else:
self.diagnostics.write("kernel_preexisting_degraded=true; application update may proceed")
self.diagnostics.write(
"health_summary="
f"version:{status.get('service', {}).get('software_version')} "
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Exercise the independent recovery task after a real systemd service stop."""
from __future__ import annotations
import os
from pathlib import Path
import shutil
import subprocess
import time
from uuid import uuid4
def run(*args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(args, check=True, capture_output=True, text=True, timeout=30)
def main() -> int:
if os.geteuid() != 0 or not Path("/run/systemd/system").is_dir():
raise RuntimeError("real systemd lifecycle test requires root on a systemd host")
token = uuid4().hex[:12]
directory_name = f"matrix-kernel-recovery-test-{token}"
main_unit = f"{directory_name}-main"
task_unit = f"{directory_name}-task"
runtime = Path("/run") / directory_name
if runtime.exists():
raise RuntimeError("isolated runtime directory already exists")
try:
run("systemd-run", "--quiet", f"--unit={main_unit}",
f"--property=RuntimeDirectory={directory_name}",
"--property=RuntimeDirectoryPreserve=yes", "/bin/sleep", "120")
for _ in range(30):
if runtime.is_dir():
break
time.sleep(0.1)
else:
raise RuntimeError("temporary service did not create its runtime directory")
if run("systemctl", "show", main_unit, "--property=RuntimeDirectoryPreserve", "--value").stdout.strip() != "yes":
raise RuntimeError("temporary service did not enable runtime preservation")
run("systemctl", "stop", main_unit)
if not runtime.is_dir():
raise RuntimeError("systemd removed the runtime directory after service stop")
proof = runtime / "independent-task.txt"
run("systemd-run", "--quiet", "--wait", "--collect", f"--unit={task_unit}",
"/usr/bin/python3", "-c",
"from pathlib import Path; import sys; Path(sys.argv[1]).write_text('ok', encoding='ascii')",
str(proof))
if proof.read_text(encoding="ascii") != "ok":
raise RuntimeError("independent task did not survive the main service stop")
print("real systemd recovery lifecycle passed")
return 0
finally:
subprocess.run(["systemctl", "stop", main_unit], check=False, capture_output=True, timeout=15)
if runtime.is_dir() and runtime.parent == Path("/run") and runtime.name == directory_name:
shutil.rmtree(runtime)
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,7 +1,7 @@
[Unit]
Description=WalnutPi H618 Matrix Screen Controller
After=NetworkManager.service
Wants=NetworkManager.service
Wants=NetworkManager.service bluetooth.service aw859-bluetooth.service
[Service]
Type=simple
@@ -11,6 +11,7 @@ Environment=MATRIX_DATA_DIR=/var/lib/matrix-screen-controller
Environment=MATRIX_RUNTIME_DIR=/run/matrix-screen-controller
Environment=PYTHONDONTWRITEBYTECODE=1
Environment=MATRIX_MEDIA_WORKER_MODE=systemd
Environment=MATRIX_BLE_ENABLED=1
StateDirectory=matrix-screen-controller
StateDirectoryMode=0711
RuntimeDirectory=matrix-screen-controller
+1 -1
View File
@@ -86,7 +86,7 @@ def test_status_config_and_display_api(tmp_path):
"effective": False,
"current_governors": {},
"restore_governors": {},
"last_error": None,
"last_error": "当前内核未提供 CPU 调频策略;请检查候选内核是否回退",
}
assert status.json()["state"]["display_test"] == {
"active": False,
@@ -49,6 +49,13 @@ def test_check_profile_accepts_walnutpi_contract(tmp_path):
]
def test_bluetooth_is_not_a_display_startup_blocker(tmp_path):
def runner(args):
assert not any('bluetooth' in part for part in args)
return healthy_runner(args)
assert all(check.ok for check in check_profile(make_paths(tmp_path), runner=runner))
def test_service_preflight_does_not_require_default_route(tmp_path):
def runner(args):
assert tuple(args) != ("ip", "route", "show", "default")
@@ -1,7 +1,14 @@
import hashlib
from pathlib import Path
import subprocess
import pytest
from app.system import kernel_recovery as kr
from app.ota.manager import OtaManager
from app.ota.state import write_json, write_last_result
from app.ota.versioning import SoftwareVersion
from scripts.ota_worker import UpdateFailed, verify_kernel_capability_retained
from scripts.axp313a_kernel_health import (
HealthError,
check_cpufreq,
@@ -11,6 +18,159 @@ from scripts.axp313a_kernel_health import (
from scripts.render_dual_kernel_boot import BootScriptError, render
def make_recovery(tmp_path: Path, monkeypatch) -> kr.KernelRecovery:
host = tmp_path / "host"
data = tmp_path / "data"
boot = host / "boot"
boot.mkdir(parents=True)
files = {
"Image-matrix-axp313a1": b"candidate-image",
"sun50i-h616-walnutpi-1b-matrix-axp313a1.dtb": b"candidate-sd-dtb",
"sun50i-h616-walnutpi-1b-emmc-matrix-axp313a1.dtb": b"candidate-emmc-dtb",
}
monkeypatch.setattr(kr, "BOOT_HASHES", {name: hashlib.sha256(value).hexdigest() for name, value in files.items()})
for name, value in files.items():
(boot / name).write_bytes(value)
for name in ("Image", "sun50i-h616-walnutpi-1b.dtb", "sun50i-h616-walnutpi-1b-emmc.dtb",
"boot.cmd.matrix-original", "boot.scr.matrix-original", "matrix-original.SHA256SUMS"):
(boot / name).write_bytes(b"original")
(boot / "boot.cmd").write_text(
"matrix-kernel-good matrix-kernel-good matrix_kernel_candidate=1",
encoding="utf-8",
)
(boot / "boot.scr").write_bytes(b"matrix_kernel_candidate=1 Image-matrix-axp313a1")
health = host / "etc/systemd/system/matrix-axp313a-health.service"
health.parent.mkdir(parents=True)
health.write_text("ConditionKernelCommandLine=matrix_kernel_candidate=1", encoding="utf-8")
script = host / "opt/matrix-screen-controller-system/axp313a_kernel_health.py"
script.parent.mkdir(parents=True)
script.write_text("health", encoding="utf-8")
enabled = host / "etc/systemd/system/multi-user.target.wants/matrix-axp313a-health.service"
enabled.parent.mkdir(parents=True)
enabled.write_text("enabled", encoding="utf-8")
modules = host / "lib/modules" / kr.CANDIDATE_RELEASE
(modules / "kernel").mkdir(parents=True)
(modules / "modules.dep").write_text("index", encoding="ascii")
module = modules / "kernel/example.ko"
module.write_bytes(b"module")
digest = hashlib.sha256(b"kernel/example.ko\0" + hashlib.sha256(b"module").digest()).hexdigest()
monkeypatch.setattr(kr, "MODULE_COUNT", 1)
monkeypatch.setattr(kr, "MODULE_DIGEST", digest)
release = host / "proc/sys/kernel/osrelease"
release.parent.mkdir(parents=True)
release.write_text("6.1.31", encoding="ascii")
boot_id = host / "proc/sys/kernel/random/boot_id"
boot_id.parent.mkdir(parents=True)
boot_id.write_text("old-boot", encoding="ascii")
return kr.KernelRecovery(data, host_root=host)
def test_candidate_validation_and_single_recovery_attempt(tmp_path, monkeypatch):
recovery = make_recovery(tmp_path, monkeypatch)
assert recovery.verify_candidate() is None
assert recovery.prepare_attempt("1.2.0:time") is True
assert recovery.prepare_attempt("1.2.0:time") is False
monkeypatch.setattr(kr.subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess(args, 1))
recovery.arm_and_reboot(reboot=False)
assert recovery.status()["state"] == "pending"
with pytest.raises(RuntimeError, match="no unused"):
recovery.arm_and_reboot(reboot=False)
recovery.host("/proc/sys/kernel/random/boot_id").write_text("new-boot", encoding="ascii")
recovery.host("/proc/sys/kernel/osrelease").write_text(kr.CANDIDATE_RELEASE, encoding="ascii")
policy = recovery.host("/sys/devices/system/cpu/cpufreq/policy0")
policy.mkdir(parents=True)
(policy / "scaling_governor").write_text("schedutil", encoding="ascii")
(policy / "scaling_available_governors").write_text("schedutil performance", encoding="ascii")
recovery.finalize_after_boot(wait_seconds=1)
assert recovery.status()["state"] == "succeeded"
def test_recovery_failure_and_damaged_candidate_do_not_retry(tmp_path, monkeypatch):
recovery = make_recovery(tmp_path, monkeypatch)
recovery.host("/boot/Image-matrix-axp313a1").write_bytes(b"damaged")
assert recovery.prepare_attempt("1.2.0:time") is False
assert recovery.status()["state"] == "requires_package"
assert recovery.prepare_attempt("1.2.1:later") is False
other = make_recovery(tmp_path / "other", monkeypatch)
assert other.prepare_attempt("1.2.0:time") is True
monkeypatch.setattr(kr.subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess(args, 1))
other.arm_and_reboot(reboot=False)
other.host("/proc/sys/kernel/random/boot_id").write_text("new-boot", encoding="ascii")
other.finalize_after_boot(wait_seconds=1)
assert other.status()["state"] == "failed"
assert other.prepare_attempt("1.2.1:later") is False
def test_scheduled_recovery_that_never_runs_becomes_persistent_failure(tmp_path, monkeypatch):
recovery = make_recovery(tmp_path, monkeypatch)
assert recovery.prepare_attempt("1.2.0:time") is True
monkeypatch.setattr(kr.time, "time", lambda: recovery.read_record()["recorded_at"] + 181)
assert recovery.status()["state"] == "failed"
assert recovery.read_record()["state"] == "failed"
assert recovery.prepare_attempt("1.2.1:later") is False
def test_delayed_recovery_task_does_not_reboot(tmp_path, monkeypatch):
recovery = make_recovery(tmp_path, monkeypatch)
assert recovery.prepare_attempt("1.2.0:time") is True
monkeypatch.setattr(kr.time, "time", lambda: recovery.read_record()["recorded_at"] + 181)
monkeypatch.setattr(kr.subprocess, "run", lambda *args, **kwargs: pytest.fail("late task ran a command"))
recovery.arm_and_reboot()
assert recovery.read_record()["state"] == "failed"
def test_ota_rejects_new_cpufreq_loss_and_keeps_preexisting_degradation():
before = {"release": kr.CANDIDATE_RELEASE, "available": True, "marker": True,
"policies": {"policy0": {"current": "schedutil", "performance": True}}}
after = {**before, "policies": {}, "available": False}
off = {"requested": False, "available": True, "effective": False,
"current_governors": {"policy0": "schedutil"}}
with pytest.raises(UpdateFailed, match="lost the validated"):
verify_kernel_capability_retained(before, after, off, off)
verify_kernel_capability_retained({**before, "available": False}, after, off, off)
with pytest.raises(UpdateFailed, match="governors"):
verify_kernel_capability_retained(before, before, off, {**off, "current_governors": {"policy0": "performance"}})
def test_legacy_worker_completion_schedules_lightweight_recovery(tmp_path, monkeypatch):
class Display:
def start_ota_indicator(self, _path):
pass
def stop_ota_indicator(self):
pass
class Recovery:
def __init__(self):
self.attempts = []
def status(self):
return {"state": "ready", "reason": ""}
def prepare_attempt(self, result_id):
self.attempts.append(result_id)
return len(self.attempts) == 1
recovery = Recovery()
manager = OtaManager(
code_root=tmp_path / "code", data_root=tmp_path / "data", runtime_root=tmp_path / "run",
software_version=SoftwareVersion.parse("1.2.0"), display=Display(),
)
monkeypatch.setattr(manager, "_production_recovery", lambda: recovery)
calls = []
monkeypatch.setattr("app.ota.manager.subprocess.run", lambda command, **kwargs: calls.append(command))
write_json(manager.status_path, {"schema_version": 1, "active": True, "job": {"id": "old-worker"}})
assert manager.resume_or_start_monitor(manager.schedule_kernel_recovery_after_ota)
write_last_result(manager.data_root, {"status": "success", "target_version": "1.2.0",
"installed_at": "2026-09-26T08:00:00Z"})
write_json(manager.status_path, {"schema_version": 1, "active": False, "job": {"id": "old-worker"}})
manager._monitor_thread.join(timeout=3)
assert recovery.attempts == ["1.2.0:2026-09-26T08:00:00Z"]
assert len(calls) == 1
assert "systemd-run" in calls[0]
BOOT_SOURCE = '''# DO NOT EDIT THIS FILE
setenv docker_optimizations "on"
setenv bootargs "root=/dev/mmcblk1p2"
@@ -0,0 +1,167 @@
import pytest
from fastapi.testclient import TestClient
from app.main import create_app
from app.mobile.session import RpcError
def test_identity_persistence_and_rename(tmp_path):
app = create_app(project_root=tmp_path, driver_kind='mock')
mobile = app.state.mobile_control
before = mobile.identity()
renamed = mobile.dispatch('device.rename', {'name': '客厅小屏幕'})
assert renamed['device_id'] == before['device_id']
assert renamed['device_name'] == '客厅小屏幕'
other = create_app(project_root=tmp_path, driver_kind='mock')
assert other.state.mobile_control.identity() == renamed
def test_web_change_invalidates_mobile_revision(tmp_path):
app = create_app(project_root=tmp_path, driver_kind='mock')
mobile = app.state.mobile_control
settings = mobile.dispatch('settings.get', {})
client = TestClient(app)
assert client.put('/api/config', json={'brightness': 37}).status_code == 200
with pytest.raises(RpcError, match='CONFLICT'):
mobile.dispatch('settings.patch', {'expected_revision': settings['revision'], 'changes': {'brightness': 20}})
revision = mobile.settings()['revision']
mobile.dispatch('settings.patch', {'expected_revision': revision, 'changes': {'brightness': 25}})
assert client.get('/api/config').json()['brightness'] == 25
def test_library_and_frame_readback(tmp_path):
app = create_app(project_root=tmp_path, driver_kind='mock')
mobile = app.state.mobile_control
first = mobile.dispatch('library.list', {'limit': 1})
assert len(first['items']) == 1 and first['next_cursor']
second = mobile.dispatch('library.list', {'cursor': first['next_cursor'], 'limit': 1})
assert second['items'][0]['id'] != first['items'][0]['id']
item = first['items'][0]
assert mobile.dispatch('library.thumbnail', item)['mime'] == 'image/png'
mobile.dispatch('content.play', item)
frame = mobile.dispatch('frame.get', {})
assert mobile.dispatch('frame.get', {'known_revision': frame['frame_revision']})['unchanged']
with pytest.raises(RpcError, match='CONFLICT'):
mobile.dispatch('content.play', {**item, 'revision': 'old'})
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'):
mobile.dispatch('shell.run', {'command': 'anything'})
with pytest.raises(RpcError, match='BAD_REQUEST'):
mobile.dispatch('settings.patch', {'expected_revision': mobile.settings()['revision'], 'changes': {'low_voltage_protection_enabled': False}})
def test_wifi_open_network_and_password_not_returned(tmp_path):
mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control
revision = mobile.dispatch('wifi.get', {})['revision']
result = mobile.dispatch('wifi.set', dict(expected_revision=revision, ssid='Test Open', security='open',
password_action='none', ipv4_mode='dhcp', dns_servers=[], activation='next_boot'))
assert mobile.dispatch('task.get', {'task_id': result['task_id']})['state'] == 'succeeded'
wifi = mobile.dispatch('wifi.get', {})
assert 'password' not in wifi['saved']
assert not wifi['saved']['password_configured']
assert wifi['saved']['security'] == 'open'
assert wifi['revision'] != revision
mobile.dispatch('wifi.set', dict(expected_revision=wifi['revision'], ssid='Test Private', security='wpa-psk',
password_action='replace', password='public-test-fixture', ipv4_mode='dhcp', activation='next_boot'))
wifi = mobile.dispatch('wifi.get', {})
assert 'password' not in wifi['saved'] and wifi['saved']['password_configured']
def test_scan_escaped_ssid_deduplicates_and_marks_unsupported():
from app.network.manager import NmcliNetworkManager
class Stub(NmcliNetworkManager):
def _run(self, args, *, timeout=10):
assert timeout == 15 and '--rescan' in args
return '*:Test\\:WiFi:WPA2:42\n:Test\\:WiFi:WPA2:81\n:Open:--:37\n:EAP:WPA2 802.1X:80'
rows = Stub().scan()
assert rows[0] == dict(ssid='Test:WiFi', security='wpa-psk', signal_percent=81, connected=True)
assert rows[1]['security'] == 'unsupported'
assert rows[2]['security'] == 'open'
def test_mobile_default_and_animation_controls_share_web_state(tmp_path):
app = create_app(project_root=tmp_path, driver_kind='mock')
with TestClient(app) as client:
mobile = app.state.mobile_control
items = mobile.dispatch('library.list', {})['items']
animation = next(item for item in items if item['type'] == 'animation' and item['playable'])
mobile.dispatch('content.default.set', animation)
assert mobile.dispatch('content.default.get', {})['id'] == animation['id']
assert client.get('/api/display/default-content').json()['id'] == animation['id']
playback = client.get('/api/status').json()['state']['animation_playback']
controlled = mobile.dispatch('playback.patch', dict(
session_id=playback['session_id'], paused=True, position_ms=0, speed=1.5))
assert controlled['animation_playback']['paused']
current = client.get('/api/status').json()['state']['animation_playback']
assert current['position_ms'] == 0 and current['speed'] == 1.5
mobile.dispatch('content.play', animation)
with pytest.raises(RpcError, match='CONFLICT'):
mobile.dispatch('playback.patch', dict(session_id=playback['session_id'], paused=False))
def test_mobile_library_uses_persistent_mixed_order_and_invalidates_cursor(tmp_path):
from app.demo_library import demo_template
app = create_app(project_root=tmp_path, driver_kind='mock')
mobile = app.state.mobile_control
templates, animations = mobile.templates, mobile.animations
import base64
t = templates.create('user-template', {'version': 1, 'width': 64, 'height': 64, 'pixelRgb': base64.b64encode(bytes(64*64*3)).decode('ascii'), 'elements': []})
# Empty animations are valid library items, even though not playable.
a = animations.create('user-animation')
defaults = [{'type': 'template', 'id': t['id']}, {'type': 'animation', 'id': a['id']}]
store = app.state.library_order_store
current = store.get(defaults)
first = mobile.dispatch('library.list', {'limit': 1})
order = list(reversed(defaults))
store.update(order, default_items=defaults, expected_revision=current['revision'])
items = mobile.dispatch('library.list', {})['items']
assert [{'type': i['type'], 'id': i['id']} for i in items if not i['is_demo']] == order
with pytest.raises(RpcError, match='CONFLICT'):
mobile.dispatch('library.list', {'cursor': first['next_cursor']})
@pytest.mark.parametrize('reason,expected', [(53, 'NETWORK_UNAVAILABLE'), (5, 'IP_CONFIG_FAILED'), (11, 'UNKNOWN'), (7, 'UNKNOWN')])
def test_wifi_failure_classification_does_not_guess_authentication(monkeypatch, reason, expected):
import subprocess
from app.network.manager import NmcliNetworkManager, NetworkManagerError
class Backend(NmcliNetworkManager):
def _run(self, args, *, timeout=10):
if 'up' in args:
raise NetworkManagerError('sanitized')
return str(reason) + ' (reason)'
monkeypatch.setattr(subprocess, 'run', lambda *a, **k: subprocess.CompletedProcess(a, 0, '', ''))
with pytest.raises(NetworkManagerError) as error:
Backend().activate('public-test-id')
assert error.value.error_code == expected
def test_wifi_auth_requires_explicit_wrong_key_event(monkeypatch):
import subprocess
from app.network.manager import NmcliNetworkManager, NetworkManagerError
class Backend(NmcliNetworkManager):
def _run(self, args, *, timeout=10):
if 'up' in args: raise NetworkManagerError('sanitized')
return '7 (no-secrets)'
monkeypatch.setattr(subprocess, 'run', lambda *a, **k: subprocess.CompletedProcess(a, 0,
'wlan0: CTRL-EVENT-SSID-TEMP-DISABLED id=0 ssid="fixture" auth_failures=1 duration=10 reason=WRONG_KEY', ''))
with pytest.raises(NetworkManagerError) as error:
Backend().activate('public-test-id')
assert error.value.error_code == 'AUTH_FAILED'
assert str(error.value) == 'WiFi activation failed'
@pytest.mark.parametrize('failure', ['AUTH_FAILED', 'NETWORK_UNAVAILABLE', 'IP_CONFIG_FAILED', 'UNKNOWN'])
def test_task_returns_only_sanitized_failure_code(tmp_path, failure):
from app.network.manager import NetworkManagerError
mobile = create_app(project_root=tmp_path, driver_kind='mock').state.mobile_control
def fail(*args, **kwargs):
raise NetworkManagerError('private backend detail must not escape', error_code=failure)
mobile.dispatch('wifi.set', dict(expected_revision=mobile.wifi()['revision'], ssid='Test Open', security='open', password_action='none', ipv4_mode='dhcp', dns_servers=[], activation='next_boot'))
mobile.network.backend.activate = fail
task = 'public-test-task'
mobile._activate(task)
result = mobile.dispatch('task.get', {'task_id': task})
assert result == {'state': 'failed', 'stage': 'finished', 'error_code': failure}
assert 'private' not in str(result)
@@ -0,0 +1,88 @@
import json
from pathlib import Path
import pytest
from app.mobile.protocol import Handshake, ProtocolError, Reassembler, fragments, json_object
from cryptography.hazmat.primitives.asymmetric import ec
@pytest.mark.parametrize("mtu", [23, 64, 247, 517])
def test_fragments_roundtrip_and_replay(mtu):
data = ("奇妙小屏幕" * 200).encode()
parts = list(fragments(3, 0, data, mtu))
receiver = Reassembler()
for part in parts[:-1]:
assert receiver.accept(part) is None
assert receiver.accept(parts[-1]) == (3, data)
with pytest.raises(ProtocolError):
receiver.accept(parts[0])
def test_fragment_reordering_and_deadline():
parts = list(fragments(1, 0, b"hello world", 23))
with pytest.raises(ProtocolError):
Reassembler().accept(parts[1])
now = [0.0]
receiver = Reassembler(lambda: now[0])
receiver.accept(parts[0])
now[0] = 15
with pytest.raises(ProtocolError, match="TIMEOUT"):
receiver.accept(parts[1])
def pair():
client, server = Handshake(), Handshake()
return client.finish(server.hello, server=False), server.finish(client.hello, server=True)
def test_bidirectional_encryption_and_replay():
client, server = pair()
wire = client.encrypt('你好'.encode())
assert server.decrypt(wire) == '你好'.encode()
assert client.decrypt(server.encrypt(b'reply')) == b'reply'
with pytest.raises(ProtocolError):
server.decrypt(wire)
with pytest.raises(ProtocolError):
server.decrypt(client.encrypt(b'next'))
def test_tamper_direction_and_connection_isolation():
client, server = pair()
wire = client.encrypt(b'command')
with pytest.raises(ProtocolError):
client.decrypt(wire)
with pytest.raises(ProtocolError):
server.decrypt(wire[:-1] + bytes([wire[-1] ^ 1]))
_, other = pair()
with pytest.raises(ProtocolError):
other.decrypt(wire)
def test_invalid_hello_and_json():
handshake = Handshake()
hello = json.loads(handshake.hello)
hello['protocol_major'] = 2
with pytest.raises(ProtocolError, match='UNSUPPORTED_VERSION'):
handshake.finish(json.dumps(hello).encode(), server=True)
for raw in (b'{"a":1,"a":2}', b'{"a":NaN}', b'[]', b'\xff'):
with pytest.raises(ProtocolError):
json_object(raw)
def test_shared_golden_vector():
path = Path(__file__).resolve().parents[2] / '移动端相关内容/安卓app/安卓程序源代码/sharedCore/src/jvmTest/resources/protocol-v1.json'
vector = json.loads(path.read_text(encoding='utf-8'))
def raw(key):
return bytes.fromhex(vector[key])
client = Handshake(private_key=ec.derive_private_key(int(vector['client_scalar']), ec.SECP256R1()), random_bytes=raw('client_random'))
server = Handshake(private_key=ec.derive_private_key(int(vector['server_scalar']), ec.SECP256R1()), random_bytes=raw('server_random'))
assert client.hello == raw('client_hello')
assert server.hello == raw('server_hello')
cc, sc = client.finish(server.hello, server=False), server.finish(client.hello, server=True)
assert cc.transcript == raw('transcript')
assert cc.encrypt(raw('request')) == raw('client_record')
assert sc.encrypt(raw('response')) == raw('server_record')
assert sc.decrypt(raw('client_record')) == raw('request')
assert cc.decrypt(raw('server_record')) == raw('response')
assert [p.hex() for p in fragments(3, 0, raw('client_record'), 23)] == vector['fragments_mtu23']
@@ -0,0 +1,74 @@
import json
import pytest
from app.mobile.protocol import Handshake, ProtocolError, fragments
from app.mobile.session import SessionManager
def connection(clock=lambda: 0):
calls = []
manager = SessionManager(lambda method, params: calls.append(method) or {}, lambda: {"device_id": "test"}, clock=clock)
assert manager.connect('a')
assert not manager.connect('b')
client = Handshake()
result = None
for part in fragments(1, 0, client.hello, 247):
result = manager.accept('a', part)
cipher = client.finish(result[1], server=False)
return manager, cipher, calls
def request(manager, cipher, mid, rid, method, params=None):
raw = json.dumps(dict(id=rid, method=method, params=params or {})).encode()
for part in fragments(3, mid, cipher.encrypt(raw), 247):
result = manager.accept('a', part)
return json.loads(cipher.decrypt(result[1]))
def test_no_control_before_open():
manager, cipher, calls = connection()
with pytest.raises(ProtocolError):
request(manager, cipher, 1, '1', 'settings.patch')
assert calls == []
def test_single_owner_duplicate_and_disconnect():
manager, cipher, calls = connection()
assert request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机'})['ok']
assert manager.status()['client_name'] == '手机'
assert request(manager, cipher, 2, '2', 'content.play')['ok']
assert not request(manager, cipher, 3, '2', 'content.play')['ok']
assert calls == ['content.play']
manager.disconnect('b')
assert manager.status()['connected']
manager.disconnect('a')
assert not manager.status()['connected']
assert manager.connect('b')
def test_handshake_and_idle_deadlines():
now = [0]
manager, cipher, _ = connection(lambda: now[0])
now[0] = 10
assert manager.expired()
manager, cipher, _ = connection(lambda: now[0])
request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机'})
now[0] = 29
assert not manager.expired()
now[0] = 30
assert manager.expired()
def test_negotiated_receive_mtu_resets_on_disconnect():
manager, cipher, _ = connection()
assert manager.peer_mtu == 23
assert request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机', 'receive_mtu': 247})['ok']
assert manager.peer_mtu == 247
manager.disconnect('a')
assert manager.peer_mtu == 23
@pytest.mark.parametrize('mtu', [0, 22, 518, True, '247'])
def test_invalid_receive_mtu_never_opens_session(mtu):
manager, cipher, _ = connection()
assert not request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机', 'receive_mtu': mtu})['ok']
assert not manager.opened
@@ -133,6 +133,31 @@ def test_failed_install_restores_system_bytes_and_running_state(host):
assert ['systemctl', 'start', c.FRP] in host
def test_mobile_rollback_restores_files_masks_and_running_state(host):
journal, record = make_journal()
record['mobile_services'] = {
'aw859-bluetooth.service': {'enabled_state': 'masked', 'active': False},
'bluetooth.service': {'enabled_state': 'enabled', 'active': True},
}
record['files']['bluetooth-original'] = None
for name in c.MOBILE_FILES:
c.FILES[name].write_bytes(b'changed-by-mobile-install')
c.restore_components(journal, record)
assert not c.FILES['bluetooth-original'].exists()
for name in set(c.MOBILE_FILES) - {'bluetooth-original'}:
assert c.FILES[name].read_bytes() == ('old-' + name).encode()
assert ['systemctl', 'mask', 'aw859-bluetooth.service'] in host
assert ['systemctl', 'enable', 'bluetooth.service'] in host
assert ['systemctl', 'start', 'bluetooth.service'] in host
assert ['systemctl', 'start', 'aw859-bluetooth.service'] not in host
def test_legacy_component_journal_does_not_touch_bluetooth(host):
journal, record = make_journal()
c.restore_components(journal, record)
assert not any(unit in args for args in host for unit in c.MOBILE_SERVICES)
def test_power_loss_between_data_renames_recovers_original(host):
journal, record = make_journal()
(c.DATA/'user-content').write_bytes(b'unchanged')
@@ -181,6 +206,35 @@ def test_normal_migration_does_not_touch_components(host):
assert host == []
def test_mobile_only_patch_enters_durable_component_transaction(host, monkeypatch):
from types import SimpleNamespace
source = c.RELEASES / '1.1.2-mobilepatch'
source.mkdir()
(source / 'VERSION').write_text('1.1.2', encoding='utf-8')
(source / 'scripts').mkdir()
(source / 'scripts/install_mobile_bluetooth.sh').write_text('# test fixture', encoding='utf-8')
candidate = c.DATA.with_name('matrix-screen-controller.ota.mobilepatch')
candidate.mkdir()
c.RUNTIME.mkdir(parents=True, exist_ok=True)
(c.RUNTIME / 'ota-request.json').write_text(json.dumps({
'job_id': 'mobilepatch', 'current_version': '1.1.1', 'target_version': '1.1.2'
}), encoding='utf-8')
monkeypatch.setattr(c.os, 'geteuid', lambda: 0, raising=False)
monkeypatch.setattr(c.os, 'uname', lambda: SimpleNamespace(machine='aarch64'), raising=False)
monkeypatch.setattr(c, 'check_installed', lambda *args: None)
monkeypatch.setattr(c, 'protect_runtime', lambda: None)
monkeypatch.setattr(c, 'mirror_permissions', lambda *args: None)
# Host test verifies transaction ordering; POSIX durability is covered by
# real Linux lifecycle checks, not Windows read-only descriptor fsync.
monkeypatch.setattr(c.os, 'fsync', lambda *args: None)
c.begin(source, candidate)
record = json.loads((c.DATA / c.JOURNAL / 'state.json').read_text(encoding='utf-8'))
assert set(record['mobile_services']) == set(c.MOBILE_SERVICES)
assert (candidate / c.JOURNAL / 'state.json').is_file()
assert ['/bin/sh', str(source / 'scripts/install_mobile_bluetooth.sh')] in host
assert not any('install_frpc_system.sh' in str(arg) for args in host for arg in args)
def release_project(tmp_path, monkeypatch):
from scripts import export_release as exporter
source = tmp_path / 'software'
@@ -114,7 +114,7 @@ def test_unavailable_cpufreq_is_reported_without_fake_success(tmp_path):
"effective": False,
"current_governors": {},
"restore_governors": {},
"last_error": None,
"last_error": "当前内核未提供 CPU 调频策略;请检查候选内核是否回退",
}
with pytest.raises(PerformanceModeError, match="unavailable"):
manager.transact(True, lambda _enabled: None)
@@ -1,6 +1,7 @@
from __future__ import annotations
import importlib.util
import json
from pathlib import Path, PurePosixPath
import pytest
@@ -110,3 +111,30 @@ def test_repository_contract_has_example_and_ignored_private_file():
assert hygiene.EXAMPLE_CREDENTIAL in paths
assert hygiene.PRIVATE_CREDENTIAL not in paths
assert hygiene._check_repository_contract(paths, staged=False) == []
def test_mobile_markers_are_checked_even_without_board_credentials(tmp_path, monkeypatch):
monkeypatch.setattr(hygiene, "WORKSPACE_ROOT", tmp_path)
private = tmp_path.joinpath(*hygiene.PRIVATE_MOBILE_REGISTRATION.parts)
private.parent.mkdir(parents=True)
private.write_text(json.dumps({"devices": [{"serial": "FAKE-SERIAL-PRIVATE-123"}]}), encoding="utf-8")
markers = hygiene._private_value_markers()
assert "FAKE-SERIAL-PRIVATE-123" in markers
assert hygiene._text_issues(PurePosixPath("docs/report.md"), "FAKE-SERIAL-PRIVATE-123", markers)
def test_corrupt_mobile_registration_fails_closed(tmp_path, monkeypatch):
monkeypatch.setattr(hygiene, "WORKSPACE_ROOT", tmp_path)
private = tmp_path.joinpath(*hygiene.PRIVATE_MOBILE_REGISTRATION.parts)
private.parent.mkdir(parents=True)
private.write_text("broken", encoding="utf-8")
with pytest.raises(hygiene.HygieneError, match="移动端真实登记损坏"):
hygiene._private_value_markers()
def test_mobile_private_and_signing_files_are_rejected():
findings = hygiene.audit_paths(
[hygiene.PRIVATE_MOBILE_REGISTRATION, PurePosixPath("keys/debug.keystore")],
scan_binary=False,
)
assert len(findings) == 2
+15 -1
View File
@@ -7,11 +7,25 @@ import pytest
from app.config.store import ConfigStore
from app.display.service import DisplayService
from app.display.wifi_indicator import render_wifi_indicator
from app.network.manager import MockNetworkManager
from app.network.manager import MockNetworkManager, NmcliNetworkManager
from app.network.service import WifiNetworkService, validate_wifi_settings
from app.network.store import WifiConfigError, WifiConfigStore
@pytest.mark.parametrize('raw_dns', ['192.0.2.1,192.0.2.2', '192.0.2.1\n192.0.2.2', '192.0.2.1, 192.0.2.2\n192.0.2.1'])
def test_nmcli_multiple_dns_can_roundtrip_without_password(monkeypatch, raw_dns):
backend = NmcliNetworkManager()
values = {'802-11-wireless.ssid': 'test-network', 'ipv4.method': 'manual',
'ipv4.addresses': '192.0.2.20/24', 'ipv4.gateway': '192.0.2.1',
'ipv4.dns': raw_dns, '802-11-wireless-security.key-mgmt': 'wpa-psk'}
monkeypatch.setattr(backend, '_connection_value', lambda uuid, field, **kwargs: values.get(field, ''))
saved = backend.read_saved('test-profile')
assert saved['dns_servers'] == ['192.0.2.1', '192.0.2.2']
checked = validate_wifi_settings(saved, previous=saved)
assert checked['dns_servers'] == saved['dns_servers']
assert checked['password'] is None
UUID = "12345678-1234-5678-1234-567812345678"