同步移动端工程、设备控制改进与发布资料
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user