Add packaged per-camera X4 wake and preview recovery
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user