feat(x4): add per-camera manual wake and hide power action when connected

This commit is contained in:
DCCONSTRUCTIONS
2026-09-10 16:27:48 +03:00
parent db14ee5435
commit 99f29218d7
27 changed files with 992 additions and 126 deletions
@@ -1,7 +1,16 @@
import type {Sensor} from '../../../../packages/sensor-ui/src/contracts';
import type {SensorRowAction} from '../../../../packages/sensor-ui/src/extensions';
export interface RecoveryStatus {
supported:boolean; enabled:boolean; preview_wanted:boolean; phase:string;
wake_available?:boolean;
}
export function cameraRowActions(device:Sensor):readonly SensorRowAction[] {
if(device.online||!cameraStatus(device).recovery?.supported)return [];
return [{actionId:'power.wake',label:'Включить',
description:'На X4 должно быть включено «Пробуждение по Bluetooth». USB оставьте подключённым.',
disabled:!device.prepared||!(device.configured??device.snapshot.enrollment==='enrolled')||cameraStatus(device).recovery?.wake_available!==true}];
}
export interface CameraStatus {
+2 -1
View File
@@ -1,8 +1,9 @@
import type {SensorUiContribution} from '../../../../packages/sensor-ui/src/extensions';
import {X4Detail} from './X4Detail';
import {cameraLabel} from './model';
import {cameraLabel,cameraRowActions} from './model';
export const insta360X4SensorUi:SensorUiContribution={
kind:'insta360.x4',Detail:X4Detail,icon:'camera',retainOffline:true,
supportsPreparation:true,supportsRenaming:true,status:cameraLabel,
rowActions:cameraRowActions,
};
+1 -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-7"
VERSION = "0.1.3-9"
WHEELS = {
"aiohappyeyeballs",
"aiohttp",
@@ -34,6 +34,7 @@ def build():
paths += list((REPOSITORY / "packages/plugin-sdk/python/missioncore_plugin_sdk").rglob("*.py"))
paths += [REPOSITORY / "scripts/packaging/debian.py"]
paths += [REPOSITORY / "apps/node-agent/packaging/insta360_profile.py"]
paths += [REPOSITORY / "apps/node-agent/packaging/install_owner_release.py"]
paths += [
REPOSITORY / name
for name in (
+31 -7
View File
@@ -47,6 +47,28 @@ class WakeService:
def save(self, ident, value):
write(self.root / (ident + ".json"), json.dumps(value).encode(), 0o600)
def registry(self):
"""Remember exact USB identities without enabling any recovery policy."""
with self.lock:
observed = connected()
known = {
p.stem for p in self.root.glob("instax4_*.json") if IDENTIFIER.fullmatch(p.stem)
}
for binding in observed:
ident = binding.device_id
if (
sum(x.device_id == ident for x in observed) != 1
or not re.fullmatch(r"[A-Za-z0-9]{7,64}", binding.serial)
or (ident not in known and len(known) >= 500)
):
continue
value = self.load(ident)
if "serial" not in value:
value["serial"] = binding.serial
self.save(ident, value)
known.add(ident)
return {"items": sorted(ident for ident in known if self.load(ident).get("serial"))}
def control(self, ident, enabled):
with self.lock:
value = self.load(ident)
@@ -65,20 +87,20 @@ class WakeService:
self.running[ident].terminate()
return {"enabled": enabled}
def wake(self, ident, attempt):
def wake(self, ident, attempt, manual=False):
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)
return self.wake_locked(ident, attempt, manual)
finally:
self.advertiser.release()
def wake_locked(self, ident, attempt):
def wake_locked(self, ident, attempt, manual=False):
with self.lock:
value = self.load(ident)
if not value["enabled"] or "serial" not in value:
if (not manual and not value["enabled"]) or "serial" not in value:
return {"state": "disabled"}
serial = value["serial"]
if any(x.device_id == ident for x in connected()):
@@ -135,13 +157,15 @@ class WakeService:
state = "connected"
except (ValueError, OSError):
pass
return {"state": state if self.load(ident)["enabled"] else "disabled"}
return {"state": state if manual or 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")
if route == "/registry" and not value:
return self.registry()
ident = value.get("device_id")
if not isinstance(ident, str) or not IDENTIFIER.fullmatch(ident):
raise ValueError("Invalid recovery identity")
@@ -149,12 +173,12 @@ class WakeService:
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 route in ("/wake", "/wake-once") 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"])
return self.wake(ident, value["attempt"], manual=route == "/wake-once")
raise ValueError("Unsupported recovery request")
+1 -1
View File
@@ -223,7 +223,7 @@ class Broker:
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:
if value.get("action_id") in ("recovery.configure", "power.wake") and self.recovery:
return self.recovery.operation(identifier, value)
if value.get("action_id") == "preview.stop" and self.recovery:
try:
+8
View File
@@ -1,6 +1,14 @@
"""Public messages are allowlisted; vendor error text never crosses the boundary."""
MESSAGES = {
"wake_adapter_busy": "Сейчас включается другая камера. Повторите действие позже.",
"wake_identity_missing": "Сначала подключите эту камеру по USB и подготовьте её на борту.",
"wake_identity_conflict": "Не удалось однозначно выбрать камеру для включения.",
"wake_rate_limited": "Лимит попыток включения исчерпан. Повторите через 10 минут.",
"wake_not_confirmed": (
"Камера не подключилась. Проверьте питание, USB "
"и настройку «Пробуждение по Bluetooth» на X4."
),
"unsupported_camera_parameter": "Параметр или действие недоступны в текущем режиме камеры.",
"verification_recording_active": "Для проверки изображения остановите запись на камере.",
"preview_no_decodable_image": "Камера не передала декодируемое изображение.",
+4
View File
@@ -35,6 +35,10 @@ class VideoHeader(ctypes.Structure):
def parameters(action, value):
"""Validate before any SDK call; parameters never select a file or library."""
if action == "power.wake":
if not isinstance(value, dict) or value:
raise ValueError("Wake uses the enrolled camera identity only")
return 0, "", 0.0
if action == "recovery.configure":
if (
not isinstance(value, dict)
+50
View File
@@ -26,6 +26,7 @@ class Recovery:
self.absent = {}
self.stable = {}
self.jobs = {}
self.wakeable = set()
self.stopping = threading.Event()
self.pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="x4-recovery")
for path in sorted(root.glob("instax4_*.json"))[:500]:
@@ -62,8 +63,50 @@ class Recovery:
"enabled": state.get("enabled", False),
"preview_wanted": state.get("preview_wanted", False),
"phase": state.get("phase", "disabled"),
"wake_available": ident in self.wakeable,
}
def sync_registry(self):
values = request(HELPER, "/registry", {}, timeout=4)["items"]
if not isinstance(values, list) or len(values) > 500:
raise ValueError("Invalid wake registry")
for ident in values:
self.broker.validate_identifier(ident)
with self.lock:
self.wakeable = set(values)
for ident in values:
if ident not in self.states:
self.states[ident] = self.default()
self.save(ident, self.states[ident])
def wake_once(self, ident, command):
# Explicit operator action. Keep the automatic policy and preview intent intact.
deadline = time.monotonic() + 52
result = request(
HELPER,
"/wake-once",
{"device_id": ident, "attempt": command["operation_id"][3:]},
timeout=52,
)
state = result.get("state")
# A cold X4 can enumerate after the 20-second beacon has already ended.
# Observe the installed supervisor only; never send a second wake beacon.
if state == "unavailable":
while time.monotonic() < deadline and not self.stopping.is_set():
if any(x["id"] == ident and x.get("online") for x in self.broker.snapshots()):
state = "connected"
break
self.stopping.wait(1)
if state == "connected":
return {"state": "complete", "result": {"usb_connected": True}}
errors = {
"busy": "wake_adapter_busy",
"disabled": "wake_identity_missing",
"identity_conflict": "wake_identity_conflict",
"exhausted": "wake_rate_limited",
}
return {"state": "error", "error": errors.get(state, "wake_not_confirmed")}
def snapshot(self, ident):
with self.lock:
current = self.current.get(ident)
@@ -246,6 +289,11 @@ class Recovery:
while not self.stopping.wait(2):
try:
snapshots = self.broker.snapshots()
try:
self.sync_registry()
except (OSError, ValueError, RuntimeError, KeyError, TypeError):
with self.lock:
self.wakeable = set()
with self.lock:
for ident, future in list(self.jobs.items()):
if future.done():
@@ -270,6 +318,8 @@ class RecoveryCalls:
self.recovery, self.ident, self.command = recovery, ident, command
def call(self, action, params):
if action == "power.wake":
return self.recovery.wake_once(self.ident, self.command)
if action == "recovery.configure":
return self.recovery.configure(self.ident, params["enabled"])
if action == "preview.stop":
@@ -0,0 +1,88 @@
"""Synthetic owner installer checks: no actual APT, systemd or camera calls."""
import json
import sys
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "apps/node-agent/packaging"))
import install_owner_release as owner # noqa: E402
PROFILE = {"version": "0.1.3-8", "revision": "a" * 24}
class OwnerModelUpdateTests(unittest.TestCase):
def test_clean_host_does_not_install_optional_model_implicitly(self):
run = Mock()
with patch.object(owner.subprocess, "run", return_value=Mock(returncode=1, stdout="")):
self.assertEqual(owner.update_installed_x4(run), {"state": "not-installed"})
run.assert_not_called()
def test_current_installed_model_is_not_restarted(self):
run = Mock(return_value=PROFILE["version"])
with (
patch.object(
owner.subprocess,
"run",
return_value=Mock(returncode=0, stdout="install ok installed"),
),
patch.object(owner.Path, "read_text", return_value=json.dumps(PROFILE)),
):
self.assertEqual(owner.update_installed_x4(run)["state"], "current")
self.assertEqual(run.call_count, 1)
def test_offline_model_update_uses_only_fixed_profile_and_checks_result(self):
run = Mock(side_effect=["0.1.3-7", "", PROFILE["version"]])
with (
patch.object(
owner.subprocess,
"run",
return_value=Mock(returncode=0, stdout="install ok installed"),
),
patch.object(
owner.Path,
"read_text",
side_effect=[
json.dumps(PROFILE),
json.dumps({"state": "complete", "revision": PROFILE["revision"]}),
],
),
):
result = owner.update_installed_x4(run)
self.assertEqual(
result,
{
"state": "updated",
"version": PROFILE["version"],
"camera_verification": "not-performed",
},
)
self.assertEqual(
run.call_args_list[1].args[1],
["/usr/bin/systemctl", "start", "mission-core-node-insta360-x4-profile.service"],
)
self.assertIsNone(run.call_args_list[1].kwargs["timeout"])
def test_stale_profile_completion_cannot_qualify_updated_model(self):
run = Mock(side_effect=["0.1.3-7", "", PROFILE["version"]])
with (
patch.object(
owner.subprocess,
"run",
return_value=Mock(returncode=0, stdout="install ok installed"),
),
patch.object(
owner.Path,
"read_text",
side_effect=[
json.dumps(PROFILE),
json.dumps({"state": "complete", "revision": "b" * 24}),
],
),self.assertRaises(RuntimeError)
):
owner.update_installed_x4(run)
if __name__ == "__main__":
unittest.main()
+110
View File
@@ -1,5 +1,6 @@
"""Synthetic recovery/privilege-boundary tests. No USB, Bluetooth or service changes."""
import io
import json
import sys
import tempfile
@@ -149,6 +150,72 @@ class RecoveryTests(unittest.TestCase):
with self.assertRaises(ValueError):
parameters("recovery.configure", value)
def test_registry_retains_offline_wake_identity_without_automatic_intent(self):
with patch.object(recovery, "request", return_value={"items": [A, B]}):
self.value.sync_registry()
self.assertTrue(self.value.view(A)["wake_available"])
self.assertFalse(self.value.view(A)["enabled"])
self.assertEqual(self.value.observe([], 900), [])
self.assertEqual(len(self.value.augment([])), 2)
def test_manual_wake_is_durable_once_and_does_not_enable_policy_or_preview(self):
self.value.states[A] = self.value.default()
session = self.value.states[A]["session_id"]
value = command(A, session, "power.wake")
with patch.object(recovery, "request", return_value={"state": "connected"}) as call:
self.assertEqual(self.value.operation(A, value)["state"], "complete")
self.assertEqual(self.value.operation(A, value)["state"], "complete")
call.assert_called_once_with(
recovery.HELPER,
"/wake-once",
{
"device_id": A,
"attempt": value["operation_id"][3:],
},
timeout=52,
)
self.assertFalse(self.value.view(A)["enabled"])
self.assertFalse(self.value.view(A)["preview_wanted"])
with self.assertRaises(ValueError):
parameters("power.wake", {"serial": "ARBITRARY"})
def test_manual_wake_failure_does_not_schedule_automatic_retry(self):
self.value.states[A] = self.value.default()
value = command(A, self.value.states[A]["session_id"], "power.wake")
with (
patch.object(recovery, "request", return_value={"state": "unavailable"}) as call,
patch.object(recovery.time, "monotonic", side_effect=[0, 53]),
):
result = self.value.operation(A, value)
self.assertEqual(self.value.operation(A, value), result)
self.assertEqual(result["state"], "error")
call.assert_called_once()
self.assertEqual(self.value.observe([], 1000), [])
def test_manual_wake_waits_for_late_sdk_return_without_another_radio_command(self):
with (
patch.object(recovery, "request", return_value={"state": "unavailable"}) as call,
patch.object(recovery.time, "monotonic", side_effect=[0, 22, 37]),
patch.object(self.value.stopping, "wait") as wait,
patch.object(self.broker, "snapshots", side_effect=[[snapshot(B)], [snapshot(A)]]),
):
result = self.value.wake_once(A, command(A, "x4_one", "power.wake"))
self.assertEqual(result["state"], "complete")
call.assert_called_once()
wait.assert_called_once_with(1)
self.assertFalse(self.value.view(A)["enabled"])
def test_manual_wake_does_not_wait_for_other_camera_or_retry_after_deadline(self):
with (
patch.object(recovery, "request", return_value={"state": "unavailable"}) as call,
patch.object(recovery.time, "monotonic", side_effect=[0, 22, 53]),
patch.object(self.value.stopping, "wait"),
patch.object(self.broker, "snapshots", return_value=[snapshot(B)]),
):
result = self.value.wake_once(A, command(A, "x4_one", "power.wake"))
self.assertEqual(result["state"], "error")
call.assert_called_once()
class WakeServiceTests(unittest.TestCase):
def setUp(self):
@@ -231,6 +298,49 @@ class WakeServiceTests(unittest.TestCase):
self.service.advertiser.release()
self.assertEqual(list(self.root.iterdir()), [])
def test_registry_enrolls_usb_without_enabling_recovery_and_keeps_it_when_unplugged(self):
with patch.object(wake_service, "connected", return_value=[self.binding]):
self.assertEqual(self.service.registry(), {"items": [A]})
self.assertFalse(self.service.load(A)["enabled"])
self.assertEqual(self.service.load(A)["attempts"], [])
with patch.object(wake_service, "connected", return_value=[]):
self.assertEqual(self.service.registry(), {"items": [A]})
def test_registry_never_enrolls_ambiguous_usb_identity(self):
with patch.object(wake_service, "connected", return_value=[self.binding, self.binding]):
self.assertEqual(self.service.registry(), {"items": []})
def test_manual_wake_works_while_disabled_and_shares_the_persisted_attempt_budget(self):
self.enable()
self.service.control(A, False)
def start(*args, **kwargs):
kwargs["stdout"].write(b'{"sdk_usb_returned": true}')
kwargs["stdout"].flush()
return Mock(stdin=io.BytesIO(), returncode=0)
with (
patch.object(wake_service, "connected", return_value=[]),
patch.object(
wake_service.subprocess,
"Popen",
side_effect=start,
) as spawn,
):
self.assertEqual(self.service.wake_locked(A, "1" * 32), {"state": "disabled"})
self.assertEqual(
self.service.wake_locked(A, "1" * 32, manual=True), {"state": "connected"}
)
self.assertEqual(
self.service.wake_locked(A, "1" * 32, manual=True), {"state": "already_attempted"}
)
self.assertEqual(
self.service.wake_locked(B, "2" * 32, manual=True), {"state": "disabled"}
)
spawn.assert_called_once()
self.assertFalse(self.service.load(A)["enabled"])
self.assertEqual(len(self.service.load(A)["attempts"]), 1)
if __name__ == "__main__":
unittest.main()