Add packaged per-camera X4 wake and preview recovery

This commit is contained in:
DCCONSTRUCTIONS
2026-09-10 14:20:52 +03:00
parent f164606828
commit d45b329de8
22 changed files with 1045 additions and 16 deletions
@@ -0,0 +1,17 @@
import {Button,SettingsCard,StatusBadge} from '@nodedc/ui-react';
import type {RecoveryStatus} from './model';
export function RecoverySettings({value,online,enabled,pending,change}:{
value:RecoveryStatus; online:boolean; enabled:boolean; pending:boolean; change:(enabled:boolean)=>void;
}) {
const failed=['exhausted','unavailable','preview_failed'].includes(value.phase);
const label=!value.enabled?'Выключено':failed?'Требуется проверка камеры':
['waiting','waking','restoring_preview'].includes(value.phase)?'Восстанавливаем подключение':'Включено';
return <SettingsCard title="Восстановление подключения" description="После потери связи камера сможет включаться по Bluetooth. Ранее запущенный просмотр возобновится, запись на карту повторно не запускается."
actions={<StatusBadge tone={failed&&value.enabled?'warning':'neutral'}>{label}</StatusBadge>}>
<p>На X4 включите «Пробуждение по Bluetooth» и оставьте USB подключённым. Перед ручным выключением камеры отключите восстановление здесь.</p>
<Button disabled={!enabled||(!online&&!value.enabled)} loading={pending} onClick={()=>change(!value.enabled)}>
{value.enabled?'Отключить восстановление':'Включить восстановление'}
</Button>
</SettingsCard>;
}
@@ -4,6 +4,7 @@ import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensi
import {perform} from '../../../../packages/sensor-ui/src/contracts';
import {LiveViewport} from '../../../../packages/sensor-ui/src/LiveViewport';
import {CameraSettings} from './CameraSettings';
import {RecoverySettings} from './RecoverySettings';
import {cameraStatus,fileName,type CameraFiles,type CameraSettings as Settings} from './model';
export function X4Detail({device,transport,enabled,back,refresh,failure}:SensorDetailProps){
@@ -22,7 +23,7 @@ export function X4Detail({device,transport,enabled,back,refresh,failure}:SensorD
},[transport,device.id,device.snapshot.context.session_id,status.function_mode]);
useEffect(()=>{if(available)void load().catch(error=>{if(mounted.current)failure(error);});},[available,load]);
async function action(name:string,parameters:Record<string,unknown>={},key=name){
if(running.current||!enabled||(!available&&name!=='refresh'))return;
if(running.current||!enabled||(!available&&!['refresh','recovery.configure','preview.stop'].includes(name)))return;
running.current=true;setPending(key);failure(null);
try{
const result=name==='refresh'?(available?await load():undefined):await perform<unknown>(transport,device,name,parameters);
@@ -40,11 +41,12 @@ export function X4Detail({device,transport,enabled,back,refresh,failure}:SensorD
return <div className="sensor-content"><div><Button onClick={back}>К устройствам</Button></div>
<SettingsCard title={device.name} description={`${device.model}${device.firmware?' · '+device.firmware:''}`} actions={<IconButton label="Обновить состояние камеры" loading={pending==='refresh'} disabled={!enabled||busy} onClick={()=>void action('refresh')}><Icon name="refresh"/></IconButton>}>
{!available&&<p>Нет свежей связи с камерой.</p>}
<div className="sensor-actions"><Button disabled={!available||busy||status.preview!==0} loading={pending==='preview.start'} onClick={()=>void action('preview.start')}>Начать просмотр</Button><Button disabled={!available||busy||status.preview===0} loading={pending==='preview.stop'} onClick={()=>void action('preview.stop')}>Остановить просмотр</Button><Button disabled={!available||busy||status.recording!==0} loading={pending==='verify'} onClick={()=>void action('verify')}>Проверить изображение</Button></div>
<div className="sensor-actions"><Button disabled={!available||busy||status.preview!==0} loading={pending==='preview.start'} onClick={()=>void action('preview.start')}>Начать просмотр</Button><Button disabled={!enabled||busy||(!available&&!status.recovery?.preview_wanted)||(available&&status.preview===0&&!status.recovery?.preview_wanted)} loading={pending==='preview.stop'} onClick={()=>void action('preview.stop')}>Остановить просмотр</Button><Button disabled={!available||busy||status.recording!==0} loading={pending==='verify'} onClick={()=>void action('verify')}>Проверить изображение</Button></div>
{status.alerts?.temperature_high&&<p>Камера сообщает о перегреве.</p>}
{status.alerts?.storage_full&&<p>На карте камеры закончилось место.</p>}
{status.alerts?.battery_low&&<p>Камера сообщает о низком заряде.</p>}
</SettingsCard>
{status.recovery?.supported&&<RecoverySettings value={status.recovery} online={available} enabled={enabled&&!busy} pending={pending==='recovery.configure'} change={value=>void action('recovery.configure',{enabled:value,wakeup_confirmed:value})}/>}
<LiveViewport device={viewDevice} layer="preview" title="Изображение X4" note="Запись сохраняется на карте памяти камеры." inactiveMessage="Нажмите «Начать просмотр», чтобы получить изображение." transport={transport} failure={failure}/>
<SettingsCard title="Съёмка" description="Фото и видеозапись сохраняются на карте памяти X4." actions={<StatusBadge tone={available&&status.recording===1?'success':status.recording===0?'neutral':'warning'}>{available?recordLabel:'Состояние недоступно'}</StatusBadge>}>
<div className="sensor-actions"><Button disabled={!available||busy||status.recording!==0||mode!==7} loading={pending==='record.start'} onClick={()=>void action('record.start')}>Начать запись</Button><Button disabled={!available||busy||status.recording===0} loading={pending==='record.stop'} onClick={()=>void action('record.stop')}>Остановить запись</Button><Button disabled={!available||busy||status.recording!==0||mode!==6} loading={pending==='photo.capture'} onClick={()=>void action('photo.capture')}>Сделать фото</Button></div>
@@ -1,9 +1,14 @@
import type {Sensor} from '../../../../packages/sensor-ui/src/contracts';
export interface RecoveryStatus {
supported:boolean; enabled:boolean; preview_wanted:boolean; phase:string;
}
export interface CameraStatus {
connected:boolean; preview:number|null; recording:number|null; function_mode:number|null;
firmware?:string; battery?:{level:number}; storage?:{free_bytes:number};
alerts?:{battery_low?:boolean;storage_full?:boolean;temperature_high?:boolean};
recovery?:RecoveryStatus;
}
export interface CameraSettings {
mode:number; photo_modes:number[]; video_modes:number[]; photo_sizes:number[];
+8 -1
View File
@@ -3,6 +3,7 @@
import hashlib
import json
import re
import signal
import sys
from pathlib import Path
@@ -33,7 +34,9 @@ def wake_payload(target):
ident = "instax4_" + hashlib.sha256(serial.encode()).hexdigest()[:32]
if target["device_id"] != ident or target["wakeup_enabled"] is not True:
raise ValueError("USB identity and enabled camera wakeup must be confirmed")
if not re.fullmatch(r"(?:[0-9A-F]{2}:){5}[0-9A-F]{2}", target["bluetooth_address"]):
if target["bluetooth_address"] is not None and not re.fullmatch(
r"(?:[0-9A-F]{2}:){5}[0-9A-F]{2}", target["bluetooth_address"]
):
raise ValueError("Prior correlated Bluetooth address is required")
# Company ID 0x004c is passed separately to BlueZ. X4 remote spec section1.
return (
@@ -189,6 +192,10 @@ def run(target, report):
def main():
def cancel(*_):
raise RuntimeError("Wake cancelled")
signal.signal(signal.SIGTERM, cancel)
report = {
"schema": "missioncore.insta360.ble-wake/v1",
"started": timestamp(),
+2 -1
View File
@@ -16,7 +16,7 @@ sys.path.insert(0, str(REPOSITORY / "scripts/packaging"))
from debian import package # noqa: E402
from fetch_sdk import verify # noqa: E402
VERSION = "0.1.3-6"
VERSION = "0.1.3-7"
WHEELS = {
"aiohappyeyeballs",
"aiohttp",
@@ -160,6 +160,7 @@ def build(output):
"ble_diagnostic.py",
"ble_options.py",
"ble_wake.py",
"wake_service.py",
):
files.append(
("usr/lib/mission-core-node/insta360/" + name, (PACKAGING / name).read_bytes(), 0o644)
@@ -23,7 +23,7 @@ data = subprocess.check_output(["/usr/bin/dpkg-deb", "--fsys-tarfile", str(packa
with tarfile.open(fileobj=io.BytesIO(data)) as archive:
payload = archive.extractfile("usr/share/mission-core-node/insta360/payload.zip").read()
bundle = json.load(archive.extractfile("usr/share/mission-core-node/insta360/bundle.json"))
for name in ("ble_diagnostic.py", "ble_options.py", "ble_wake.py"):
for name in ("ble_diagnostic.py", "ble_options.py", "ble_wake.py", "wake_service.py"):
packaged = archive.extractfile("usr/lib/mission-core-node/insta360/" + name).read()
if packaged != (ROOT / "packaging" / name).read_bytes():
raise ValueError("Packaged BLE module differs from the qualified source")
@@ -0,0 +1,35 @@
[Unit]
Description=Mission Core bounded X4 Bluetooth wake
After=bluetooth.service
Wants=bluetooth.service
ConditionPathExists=/var/lib/mission-core-insta360/active.path
[Service]
Type=simple
Group=mission-core-node
ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/wake_service.py
RuntimeDirectory=mission-core-x4-recovery
RuntimeDirectoryMode=0750
StateDirectory=mission-core-x4-recovery
StateDirectoryMode=0700
UMask=0077
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictAddressFamilies=AF_UNIX
CapabilityBoundingSet=
TasksMax=20
MemoryMax=128M
CPUQuota=30%
LimitCORE=0
TimeoutStopSec=8
KillMode=control-group
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+1
View File
@@ -184,6 +184,7 @@ def prepare_locked():
trusted(maintenance).unlink()
run("/usr/bin/systemctl", "daemon-reload")
for service in (
"mission-core-insta360-wake.service",
"mission-core-insta360.service",
"mission-core-insta360-supervisor.service",
):
+1
View File
@@ -5,6 +5,7 @@ case "$1" in
/usr/bin/python3 -I /usr/lib/mission-core-node/insta360/prepare.py --quiesce
if [ -d /run/systemd/system ]; then
systemctl stop mission-core-insta360-supervisor.service
systemctl stop mission-core-insta360-wake.service
systemctl stop 'mission-core-x4@*.service'
systemctl stop mission-core-insta360.service
fi
@@ -0,0 +1,174 @@
"""Fixed Bluetooth privilege boundary. Never accepts serials, paths or SDK calls."""
import json
import os
import pwd
import re
import subprocess
import sys
import tempfile
import threading
import time
from contextlib import suppress
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from layout import CODE, trusted, write # noqa: E402
from runtime.http import Server # noqa: E402
from runtime.identity import read_binding # noqa: E402
from runtime.lifecycle import acquisition # noqa: E402
ROOT = Path("/var/lib/mission-core-x4-recovery")
SOCKET = Path("/run/mission-core-x4-recovery/driver.sock")
IDENTIFIER = re.compile(r"instax4_[0-9a-f]{32}")
def connected():
values = []
for path in Path("/sys/bus/usb/devices").iterdir():
with suppress(OSError, ValueError):
values.append(read_binding(path.name))
return values
class WakeService:
def __init__(self, root=ROOT):
self.root = root
self.lock = threading.RLock()
self.advertiser = threading.Lock()
self.running = {}
def load(self, ident):
path = self.root / (ident + ".json")
if not path.exists():
return {"enabled": False, "attempts": []}
return json.loads(trusted(path).read_text())
def save(self, ident, value):
write(self.root / (ident + ".json"), json.dumps(value).encode(), 0o600)
def control(self, ident, enabled):
with self.lock:
value = self.load(ident)
if not enabled and "serial" not in value:
return {"enabled": False}
if enabled:
matches = [x for x in connected() if x.device_id == ident]
if len(matches) != 1 or not re.fullmatch(r"[A-Za-z0-9]{7,64}", matches[0].serial):
raise ValueError("Connect the exact X4 before enabling recovery")
if len(list(self.root.glob("instax4_*.json"))) >= 500 and "serial" not in value:
raise ValueError("Recovery inventory is full")
value["serial"] = matches[0].serial
value["enabled"] = enabled
self.save(ident, value)
if not enabled and ident in self.running:
self.running[ident].terminate()
return {"enabled": enabled}
def wake(self, ident, attempt):
if not self.advertiser.acquire(blocking=False):
return {"state": "busy"}
try:
# Installation cannot replace code/state while a wake is in flight.
with acquisition():
return self.wake_locked(ident, attempt)
finally:
self.advertiser.release()
def wake_locked(self, ident, attempt):
with self.lock:
value = self.load(ident)
if not value["enabled"] or "serial" not in value:
return {"state": "disabled"}
serial = value["serial"]
if any(x.device_id == ident for x in connected()):
return {"state": "connected"}
# A radio suffix is weaker than full USB identity. Refuse known collisions.
for path in self.root.glob("instax4_*.json"):
other = json.loads(trusted(path).read_text()).get("serial", "")
if other and other != serial and other[-6:] == serial[-6:]:
return {"state": "identity_conflict"}
now = time.time()
attempts = [x for x in value["attempts"] if now - x["at"] < 600]
if any(x["id"] == attempt for x in attempts):
return {"state": "already_attempted"}
if len(attempts) >= 3:
return {"state": "exhausted"}
value["attempts"] = attempts + [{"id": attempt, "at": now}]
# Persist before advertising; a crash cannot silently replay the attempt.
self.save(ident, value)
target = {
"device_id": ident,
"serial": serial,
"bluetooth_address": None,
"wakeup_enabled": True,
}
with tempfile.TemporaryFile(dir=self.root) as output:
process = subprocess.Popen(
["/usr/bin/python3", "-I", str(CODE / "ble_wake.py")],
stdin=subprocess.PIPE,
stdout=output,
stderr=subprocess.DEVNULL,
)
self.running[ident] = process
process.stdin.write(json.dumps(target).encode())
process.stdin.close()
# Release state lock during hardware wait so disable can cancel.
self.lock.release()
try:
try:
process.wait(timeout=45)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
output.seek(0)
raw = output.read(1024 * 1024 + 1)
finally:
self.lock.acquire()
self.running.pop(ident, None)
state = "unavailable"
try:
if len(raw) <= 1024 * 1024:
report = json.loads(raw)
write(self.root / (ident + ".last-wake"), raw, 0o600)
if process.returncode == 0 and report.get("sdk_usb_returned"):
state = "connected"
except (ValueError, OSError):
pass
return {"state": state if self.load(ident)["enabled"] else "disabled"}
def dispatch(self, method, route, value, _headers):
if method == "GET" and route == "/health":
return {"ready": True}
if method != "POST" or not isinstance(value, dict):
raise ValueError("Unsupported recovery request")
ident = value.get("device_id")
if not isinstance(ident, str) or not IDENTIFIER.fullmatch(ident):
raise ValueError("Invalid recovery identity")
if route == "/control" and set(value) == {"device_id", "enabled"}:
if type(value["enabled"]) is not bool:
raise ValueError("Recovery requires an explicit boolean")
return self.control(ident, value["enabled"])
if route == "/wake" and set(value) == {"device_id", "attempt"}:
if not isinstance(value["attempt"], str) or not re.fullmatch(
r"[0-9a-f]{32}", value["attempt"]
):
raise ValueError("Invalid wake attempt")
return self.wake(ident, value["attempt"])
raise ValueError("Unsupported recovery request")
def main():
if os.geteuid() or sys.argv[1:]:
raise RuntimeError("Use the installed fixed wake service")
trusted(ROOT, True)
if SOCKET.exists():
SOCKET.unlink()
allowed = {0, pwd.getpwnam("mission-core-insta360").pw_uid}
with Server(SOCKET, WakeService().dispatch, allowed) as server:
SOCKET.chmod(0o660)
server.serve_forever(poll_interval=0.5)
if __name__ == "__main__":
main()
+34 -4
View File
@@ -30,14 +30,19 @@ class Broker:
self.media = None
self.media_operations = {}
self.capture_locks = {}
self.recovery = None
@staticmethod
def validate_identifier(identifier):
worker_socket(identifier)
def capture_lock(self, identifier):
with self.media_lock:
if identifier not in self.capture_locks and len(self.capture_locks) >= 500:
raise RuntimeError("Camera inventory exceeds the protocol limit")
return self.capture_locks.setdefault(identifier, threading.Lock())
return self.capture_locks.setdefault(identifier, threading.RLock())
def capture_operation(self, identifier, value):
def capture_operation(self, identifier, value, restoring=False):
path = worker_socket(identifier)
lock = self.capture_lock(identifier)
with self.media_lock:
@@ -58,6 +63,10 @@ class Broker:
key = (identifier, session)
starting = value["action_id"] == "preview.start"
starting_idle = starting and current.get("status", {}).get("preview") != 1
if restoring and (
not current.get("online") or current.get("status", {}).get("recording") != 0
):
return dict(UNKNOWN)
if starting:
if not current.get("online"):
raise RuntimeError("Camera state is unavailable")
@@ -67,12 +76,16 @@ class Broker:
except (OSError, RuntimeError, ValueError):
if starting_idle:
self.media.capture(key, path, False)
if not starting and self.recovery and not restoring:
self.recovery.preview_intent(identifier, value, dict(UNKNOWN))
raise
if (starting_idle and result.get("state") != "complete") or (
not starting and result.get("state") == "complete"
):
# Releasing a decoder never sends STOP to the camera or SD.
self.media.capture(key, path, False)
if self.recovery and not restoring:
self.recovery.preview_intent(identifier, value, result)
return result
def verify_operation(self, identifier, value):
@@ -204,9 +217,21 @@ class Broker:
if not re.fullmatch(r"node_[0-9a-f]{64}", node):
raise ValueError("Node identity is required")
if method == "GET" and route == "/inventory":
return {"items": [self.item(item, node) for item in self.snapshots()]}
snapshots = self.snapshots()
if self.recovery:
snapshots = self.recovery.augment(snapshots)
return {"items": [self.item(item, node) for item in snapshots]}
if method == "POST" and route == "/operation":
identifier = value["session"]["device_id"]
if value.get("action_id") == "recovery.configure" and self.recovery:
return self.recovery.operation(identifier, value)
if value.get("action_id") == "preview.stop" and self.recovery:
try:
current = request(worker_socket(identifier), "/snapshot", timeout=2)
except (OSError, RuntimeError, ValueError):
current = {}
if not current.get("online"):
return self.recovery.operation(identifier, value)
if value.get("action_id") in ("preview.start", "preview.stop"):
return self.capture_operation(identifier, value)
if value.get("action_id") in ("offer", "close-peer"):
@@ -265,6 +290,11 @@ def main():
if path.exists():
path.unlink()
allowed = {0, pwd.getpwnam("mission-core-node").pw_uid}
with Server(path, Broker().dispatch, allowed) as server:
broker = Broker()
from .recovery import Recovery
broker.recovery = Recovery(broker, STATE / "recovery")
broker.recovery.start()
with Server(path, broker.dispatch, allowed) as server:
path.chmod(0o660)
server.serve_forever(poll_interval=0.5)
+10
View File
@@ -35,6 +35,16 @@ class VideoHeader(ctypes.Structure):
def parameters(action, value):
"""Validate before any SDK call; parameters never select a file or library."""
if action == "recovery.configure":
if (
not isinstance(value, dict)
or set(value) != {"enabled", "wakeup_confirmed"}
or type(value["enabled"]) is not bool
or type(value["wakeup_confirmed"]) is not bool
or (value["enabled"] and not value["wakeup_confirmed"])
):
raise ValueError("Confirm camera Bluetooth Wakeup before enabling recovery")
return 0, "", 0.0
if action == "offer":
if (
not isinstance(value, dict)
+278
View File
@@ -0,0 +1,278 @@
"""Per-camera recovery intent. No SDK ownership, recording replay or BLE access."""
import copy
import hashlib
import json
import threading
import time
import uuid
from concurrent.futures import ThreadPoolExecutor
from datetime import UTC, datetime, timedelta
from pathlib import Path
from .http import request
from .operations import Operations, atomic
HELPER = Path("/run/mission-core-x4-recovery/driver.sock")
class Recovery:
def __init__(self, broker, root):
self.broker, self.root = broker, root
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
self.lock = threading.RLock()
self.states = {}
self.current = {}
self.absent = {}
self.stable = {}
self.jobs = {}
self.stopping = threading.Event()
self.pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="x4-recovery")
for path in sorted(root.glob("instax4_*.json"))[:500]:
state = json.loads(path.read_text())
self.broker.validate_identifier(path.stem)
self.states[path.stem] = state
def default(self):
now = datetime.now(UTC).isoformat()
return {
"enabled": False,
"preview_wanted": False,
"attempts": 0,
"phase": "disabled",
"session_id": "x4_recovery_" + uuid.uuid4().hex,
"opened_at": now,
"updated_at": now,
"revision": 1,
"resume_session": None,
"last_preview_request": "",
"last_preview_id": "",
}
def save(self, ident, state):
state["revision"] += 1
state["updated_at"] = datetime.now(UTC).isoformat()
atomic(self.root / (ident + ".json"), state)
def view(self, ident):
with self.lock:
state = self.states.get(ident, {})
return {
"supported": True,
"enabled": state.get("enabled", False),
"preview_wanted": state.get("preview_wanted", False),
"phase": state.get("phase", "disabled"),
}
def snapshot(self, ident):
with self.lock:
current = self.current.get(ident)
if current is not None:
return copy.deepcopy(current)
state = self.states.get(ident)
if state is None:
raise ValueError("Camera identity is unavailable")
return {
"id": ident,
"session_id": state["session_id"],
"opened_at": state["opened_at"],
"observed_at": state["updated_at"],
"revision": state["revision"],
"prepared": True,
"verified": False,
"online": False,
"preparation_safe": False,
"status": {},
"message": None,
}
def augment(self, snapshots):
values = {x["id"]: copy.deepcopy(x) for x in snapshots}
with self.lock:
for ident in self.states:
if ident not in values:
values[ident] = self.snapshot(ident)
# Never retain an online snapshot when its worker disappeared.
values[ident].update(online=False, status={}, preparation_safe=False)
for ident, value in values.items():
value["status"]["recovery"] = self.view(ident)
return list(values.values())
def configure(self, ident, enabled):
# Called under the camera capture lock, also used by preview restoration.
reply = request(HELPER, "/control", {"device_id": ident, "enabled": enabled}, timeout=8)
if reply.get("enabled") is not enabled:
raise RuntimeError("Recovery setting was not confirmed")
with self.lock:
state = self.states.setdefault(ident, self.default())
state.update(enabled=enabled, attempts=0, phase="ready" if enabled else "disabled")
if not enabled:
state["preview_wanted"] = False
current = self.current.get(ident)
if current:
state.update(session_id=current["session_id"], opened_at=current["opened_at"])
if enabled:
state["preview_wanted"] = current.get("status", {}).get("preview") == 1
self.save(ident, state)
self.absent.pop(ident, None)
return {"state": "complete", "result": self.view(ident)}
def operation(self, ident, command):
with self.broker.capture_lock(ident):
snapshot = self.snapshot(ident)
operations = Operations(
ident,
snapshot["session_id"],
self.root / ident / "operations",
RecoveryCalls(self, ident, command),
)
return operations.execute(command)
def preview_intent(self, ident, command, result):
start = command["action_id"] == "preview.start"
if start and result.get("state") != "complete":
return
with self.lock:
state = self.states.setdefault(ident, self.default())
requested = datetime.fromisoformat(command["requested_at"])
previous = state["last_preview_request"]
if command["operation_id"] == state["last_preview_id"] or (
previous and requested <= datetime.fromisoformat(previous)
):
return
state.update(
preview_wanted=start,
last_preview_request=command["requested_at"],
last_preview_id=command["operation_id"],
)
self.save(ident, state)
def observe(self, snapshots, now):
"""Pure scheduling decisions under one short lock; no device calls here."""
self.current = {x["id"]: copy.deepcopy(x) for x in snapshots}
decisions = []
for ident, state in self.states.items():
current = self.current.get(ident)
online = bool(current and current.get("online"))
if online:
self.absent.pop(ident, None)
self.stable.setdefault(ident, now)
if state["session_id"] != current["session_id"]:
state.update(session_id=current["session_id"], opened_at=current["opened_at"])
self.save(ident, state)
if now - self.stable[ident] >= 60 and state["attempts"]:
state["attempts"] = 0
self.save(ident, state)
if (
state["enabled"]
and state["preview_wanted"]
and state["resume_session"] != current["session_id"]
and current.get("status", {}).get("preview") == 0
and current.get("status", {}).get("recording") == 0
):
decisions.append((ident, "preview"))
else:
self.stable.pop(ident, None)
since = self.absent.setdefault(ident, now)
if (
state["enabled"]
and state["attempts"] < 3
and now - since >= (8, 40, 100)[state["attempts"]]
):
decisions.append((ident, "wake"))
return decisions
def work(self, ident, kind):
if kind == "preview":
# Serialize restore with user START/STOP/configure for this camera only.
with self.broker.capture_lock(ident):
with self.lock:
state = self.states[ident]
current = self.current.get(ident)
if not state["enabled"] or not state["preview_wanted"] or not current:
return
session = current["session_id"]
if state["resume_session"] == session:
return
state.update(resume_session=session, phase="restoring_preview")
self.save(ident, state)
now = datetime.now(UTC)
operation = (
"op_" + hashlib.sha256((ident + session + "restore").encode()).hexdigest()[:32]
)
command = {
"api_version": "missioncore.nodedc/plugin-sdk/v0alpha2",
"kind": "OperationRequest",
"operation_id": operation,
"idempotency_key": operation,
"session": {"device_id": ident, "session_id": session},
"action_id": "preview.start",
"parameters": {},
"requested_at": now.isoformat(),
"deadline_at": (now + timedelta(seconds=60)).isoformat(),
}
result = self.broker.capture_operation(ident, command, restoring=True)
with self.lock:
state["phase"] = (
"ready" if result.get("state") == "complete" else "preview_failed"
)
self.save(ident, state)
else:
with self.lock:
state = self.states[ident]
if not state["enabled"] or state["attempts"] >= 3:
return
state["attempts"] += 1
state["phase"] = "waking"
self.save(ident, state)
result = request(
HELPER, "/wake", {"device_id": ident, "attempt": uuid.uuid4().hex}, timeout=52
)
with self.lock:
state = self.states[ident]
if state["enabled"]:
if result.get("state") == "busy":
state["attempts"] -= 1
state["phase"] = (
"ready"
if result.get("state") == "connected"
else "exhausted"
if state["attempts"] >= 3
else "waiting"
)
self.save(ident, state)
def loop(self):
while not self.stopping.wait(2):
try:
snapshots = self.broker.snapshots()
with self.lock:
for ident, future in list(self.jobs.items()):
if future.done():
if future.exception():
state = self.states[ident]
state["phase"] = "unavailable" if state["enabled"] else "disabled"
self.save(ident, state)
del self.jobs[ident]
for ident, kind in self.observe(snapshots, time.monotonic()):
if ident not in self.jobs and len(self.jobs) < 2:
self.jobs[ident] = self.pool.submit(self.work, ident, kind)
except (OSError, ValueError, RuntimeError, KeyError):
# No wake is inferred from failed model-wide inventory sampling.
continue
def start(self):
threading.Thread(target=self.loop, name="x4-recovery", daemon=True).start()
class RecoveryCalls:
def __init__(self, recovery, ident, command):
self.recovery, self.ident, self.command = recovery, ident, command
def call(self, action, params):
if action == "recovery.configure":
return self.recovery.configure(self.ident, params["enabled"])
if action == "preview.stop":
self.recovery.preview_intent(self.ident, self.command, {"state": "complete"})
return {"state": "complete", "result": {"preview_wanted": False}}
raise ValueError("Unsupported recovery action")
+236
View File
@@ -0,0 +1,236 @@
"""Synthetic recovery/privilege-boundary tests. No USB, Bluetooth or service changes."""
import json
import sys
import tempfile
import unittest
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(ROOT), str(ROOT / "packaging")]
import layout # noqa: E402
import wake_service # noqa: E402
from check_runtime import command # noqa: E402
from runtime import broker, identity, recovery # noqa: E402
from runtime.native import parameters # noqa: E402
A = identity.device_id("SYNTHETICAAAAAA")
B = identity.device_id("SYNTHETICBBBBBB")
def snapshot(ident=A, session="x4_one", preview=0, recording=0):
now = datetime.now(UTC).isoformat()
return {
"id": ident,
"session_id": session,
"opened_at": now,
"observed_at": now,
"revision": 2,
"prepared": True,
"verified": True,
"preparation_safe": True,
"message": None,
"online": True,
"status": {"connected": True, "preview": preview, "recording": recording},
}
class RecoveryTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(dir=ROOT / "build")
self.root = Path(self.temporary.name)
self.broker = broker.Broker()
self.value = recovery.Recovery(self.broker, self.root / "recovery")
self.broker.recovery = self.value
self.addCleanup(self.value.pool.shutdown, wait=True)
self.addCleanup(self.temporary.cleanup)
def enable(self, ident=A):
self.value.observe([snapshot(ident)], 0)
with patch.object(recovery, "request", return_value={"enabled": True}):
self.value.configure(ident, True)
def test_default_has_no_wake_and_independent_instances_keep_distinct_intents(self):
self.assertEqual(self.value.observe([], 0), [])
self.enable()
self.value.states[B] = self.value.default()
self.assertEqual(self.value.observe([], 0), [])
self.assertEqual(self.value.observe([], 8), [(A, "wake")])
self.assertFalse(self.value.view(B)["enabled"])
def test_grace_three_attempts_and_restart_do_not_reset_budget(self):
self.enable()
self.value.observe([], 0)
with patch.object(recovery, "request", return_value={"state": "unavailable"}):
for number, threshold in enumerate((8, 40, 100)):
self.assertEqual(self.value.observe([], threshold - 1), [])
self.assertEqual(self.value.observe([], threshold), [(A, "wake")])
self.value.work(A, "wake")
self.assertEqual(self.value.states[A]["attempts"], number + 1)
self.assertEqual(self.value.observe([], 900), [])
restored = recovery.Recovery(self.broker, self.root / "recovery")
self.addCleanup(restored.pool.shutdown, wait=True)
self.assertEqual(restored.observe([], 1000), [])
def test_stable_return_resets_budget_only_after_one_minute(self):
self.enable()
self.value.states[A]["attempts"] = 3
self.value.observe([], 0)
self.value.observe([snapshot()], 10)
self.assertEqual(self.value.states[A]["attempts"], 3)
self.value.observe([snapshot()], 70)
self.assertEqual(self.value.states[A]["attempts"], 0)
def test_disable_cancels_helper_even_while_offline_and_clears_preview(self):
self.enable()
self.value.states[A]["preview_wanted"] = True
self.value.observe([], 1)
with patch.object(recovery, "request", return_value={"enabled": False}) as call:
self.value.configure(A, False)
call.assert_called_once()
self.assertFalse(self.value.view(A)["preview_wanted"])
self.assertEqual(self.value.observe([], 500), [])
def test_no_preview_restore_without_confirmed_intent_or_when_recording_unknown(self):
self.enable()
self.assertEqual(self.value.observe([snapshot(session="x4_two")], 1), [])
self.value.states[A]["preview_wanted"] = True
for recording in (1, -1, None):
self.assertEqual(self.value.observe([snapshot(recording=recording)], 2), [])
def test_restore_once_for_new_session_and_never_replay_sd_commands(self):
self.enable()
self.value.states[A]["preview_wanted"] = True
self.value.observe([snapshot(session="x4_two")], 2)
with patch.object(
self.broker, "capture_operation", return_value={"state": "unknown"}
) as call:
self.value.work(A, "preview")
self.value.work(A, "preview")
self.assertEqual(call.call_count, 1)
self.assertEqual(call.call_args.args[1]["action_id"], "preview.start")
self.assertEqual(self.value.observe([snapshot(session="x4_two")], 3), [])
def test_cancelled_intent_blocks_previously_scheduled_preview_job(self):
self.enable()
self.value.states[A]["preview_wanted"] = True
self.assertEqual(self.value.observe([snapshot(session="x4_two")], 1), [(A, "preview")])
self.value.states[A]["preview_wanted"] = False
with patch.object(self.broker, "capture_operation") as call:
self.value.work(A, "preview")
call.assert_not_called()
def test_old_start_receipt_cannot_reenable_preview_after_stop(self):
self.enable()
start = command(A, "x4_one", "preview.start")
stop = command(A, "x4_one", "preview.stop")
stop["requested_at"] = (datetime.now(UTC) + timedelta(seconds=1)).isoformat()
for value in (start, stop, start):
self.value.preview_intent(A, value, {"state": "complete"})
self.assertFalse(self.value.view(A)["preview_wanted"])
def test_offline_inventory_preserves_control_but_never_claims_online(self):
self.enable()
offline = self.value.augment([])[0]
self.assertFalse(offline["online"])
self.assertFalse(offline["preparation_safe"])
self.assertEqual(offline["status"], {"recovery": self.value.view(A)})
self.assertNotIn("serial", json.dumps(offline))
def test_recovery_parameters_require_explicit_camera_setting_confirmation(self):
parameters("recovery.configure", {"enabled": False, "wakeup_confirmed": False})
for value in (
{"enabled": True, "wakeup_confirmed": False},
{"enabled": 1, "wakeup_confirmed": True},
{"enabled": True},
):
with self.assertRaises(ValueError):
parameters("recovery.configure", value)
class WakeServiceTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(dir=ROOT / "build")
self.root = Path(self.temporary.name)
self.addCleanup(self.temporary.cleanup)
self.service = wake_service.WakeService(self.root)
self.trust = patch.object(wake_service, "trusted", side_effect=lambda path, *args: path)
self.trust.start()
self.addCleanup(self.trust.stop)
layout_trust = patch.object(layout, "trusted", side_effect=lambda path, *args: path)
layout_trust.start()
self.addCleanup(layout_trust.stop)
self.binding = identity.Binding(A, "1-2", 1, 2, "SYNTHETICAAAAAA")
def enable(self):
with patch.object(wake_service, "connected", return_value=[self.binding]):
self.service.control(A, True)
def test_enrollment_uses_os_serial_and_refuses_arbitrary_transport_input(self):
self.enable()
self.assertEqual(self.service.load(A)["serial"], self.binding.serial)
with self.assertRaises(ValueError):
self.service.dispatch(
"POST", "/control", {"device_id": A, "enabled": True, "serial": "ATTACKER"}, {}
)
with (
patch.object(wake_service, "connected", return_value=[]),
self.assertRaises(ValueError),
):
self.service.control(B, True)
def test_present_sdk_and_duplicate_serials_never_launch_advertiser(self):
self.enable()
with (
patch.object(wake_service, "connected", return_value=[self.binding]),
patch.object(wake_service.subprocess, "Popen") as spawn,
):
self.assertEqual(self.service.wake_locked(A, "1" * 32)["state"], "connected")
spawn.assert_not_called()
with (
patch.object(wake_service, "connected", return_value=[self.binding, self.binding]),
self.assertRaises(ValueError),
):
self.service.control(A, True)
def test_persisted_rate_limit_and_duplicate_attempt_prevent_commands(self):
self.enable()
value = self.service.load(A)
value["attempts"] = [{"id": str(i) * 32, "at": wake_service.time.time()} for i in range(3)]
self.service.save(A, value)
with (
patch.object(wake_service, "connected", return_value=[]),
patch.object(wake_service.subprocess, "Popen") as spawn,
):
self.assertEqual(self.service.wake_locked(A, "0" * 32)["state"], "already_attempted")
self.assertEqual(self.service.wake_locked(A, "4" * 32)["state"], "exhausted")
spawn.assert_not_called()
def test_known_radio_suffix_collision_blocks_wake(self):
self.enable()
self.service.save(B, {"serial": "DIFFERENTAAAAAA", "enabled": True, "attempts": []})
with patch.object(wake_service, "connected", return_value=[]):
self.assertEqual(self.service.wake_locked(A, "1" * 32)["state"], "identity_conflict")
def test_disable_persists_before_cancelling_only_selected_child(self):
self.enable()
left, right = Mock(), Mock()
self.service.running = {A: left, B: right}
self.service.control(A, False)
left.terminate.assert_called_once()
right.terminate.assert_not_called()
self.assertFalse(self.service.load(A)["enabled"])
def test_one_adapter_busy_does_not_consume_another_camera_attempt(self):
self.service.advertiser.acquire()
try:
self.assertEqual(self.service.wake(A, "1" * 32), {"state": "busy"})
finally:
self.service.advertiser.release()
self.assertEqual(list(self.root.iterdir()), [])
if __name__ == "__main__":
unittest.main()