同步移动端工程、设备控制改进与发布资料
This commit is contained in:
@@ -1 +1 @@
|
||||
2026-09-10T11:10+08:00
|
||||
2026-09-26T16:47+08:00
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
1.1.1
|
||||
1.1.2
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user