feat(x4): add per-camera manual wake and hide power action when connected
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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": "Камера не передала декодируемое изображение.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user