Files
NODEDC_MISSION_CORE/plugins/insta360-x4/runtime/broker.py
T

301 lines
12 KiB
Python

"""Unprivileged model broker; public authority stays in Node/Core."""
import pwd
import re
import threading
import uuid
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from .errors import failure
from .http import Server, request
from .operations import UNKNOWN, Operations
ROOT = Path("/run/mission-core-insta360")
INSTANCES = Path("/run/mission-core-x4-instances")
IDENTIFIER = re.compile(r"instax4_[0-9a-f]{32}")
STATE = Path("/var/lib/mission-core-insta360-broker")
def worker_socket(identifier):
if not isinstance(identifier, str) or not IDENTIFIER.fullmatch(identifier):
raise ValueError("Invalid camera identity")
return INSTANCES / identifier / "driver.sock"
class Broker:
def __init__(self):
self.instance = "x4broker_" + uuid.uuid4().hex
self.media_lock = threading.Lock()
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.RLock())
def capture_operation(self, identifier, value, restoring=False):
path = worker_socket(identifier)
lock = self.capture_lock(identifier)
with self.media_lock:
if self.media is None:
from .media import Engine
self.media = Engine()
# Only one camera's START/STOP is serialized here; other devices keep
# independent locks and SDK operation journals.
with lock:
current = request(path, "/snapshot", timeout=2)
session = current["session_id"]
if current.get("id") != identifier or value.get("session") != {
"device_id": identifier,
"session_id": session,
}:
raise ValueError("Camera session changed")
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")
self.media.capture(key, path, True)
try:
result = request(path, "/operation", value, timeout=55)
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):
path = worker_socket(identifier)
# Serialize with preview transitions, preserving one receipt namespace
# whether verification uses the live decoder or an idle camera check.
with self.capture_lock(identifier):
current = request(path, "/snapshot", timeout=2)
if current.get("id") != identifier:
raise ValueError("Camera identity changed")
operation = Operations(
identifier,
current["session_id"],
STATE / identifier / "verification",
VerificationCalls(self, path, value),
)
return operation.execute(value)
def media_operation(self, identifier, value):
path = worker_socket(identifier)
current = request(path, "/snapshot", timeout=2)
if current.get("id") != identifier:
raise ValueError("Camera identity changed")
session = current["session_id"]
with self.media_lock:
if self.media is None:
from .media import Engine
self.media = Engine()
previous = self.media_operations.get(identifier)
if previous is None or previous.session_id != session:
previous = Operations(
identifier,
session,
STATE / identifier,
MediaCalls(self.media, (identifier, session), path),
)
self.media_operations[identifier] = previous
return previous.execute(value)
def snapshots(self):
paths = sorted(INSTANCES.glob("instax4_*/driver.sock"))
if len(paths) > 500:
raise RuntimeError("Camera inventory exceeds the protocol limit")
def read(path):
if not IDENTIFIER.fullmatch(path.parent.name):
return None
try:
value = request(path, "/snapshot", timeout=0.75)
return value if value.get("id") == path.parent.name else None
except (OSError, RuntimeError, ValueError):
return None
pool = ThreadPoolExecutor(max_workers=8)
pending = [pool.submit(read, path) for path in paths]
values = []
try:
for future in as_completed(pending, timeout=2):
value = future.result()
if value is not None:
values.append(value)
except TimeoutError:
pass
finally:
pool.shutdown(wait=False, cancel_futures=True)
return sorted(values, key=lambda value: value["id"])
def item(self, value, node):
status = value["status"]
preview = status.get("preview")
return {
"id": value["id"],
"name": "Insta360 X4",
"model": "Insta360 X4",
"kind": "insta360.x4",
"initializable": True,
"configured": False,
"prepared": value["prepared"],
"verified": value["verified"],
"online": value["online"],
"preparation_safe": value["preparation_safe"],
"usb": "USB",
"firmware": status.get("firmware"),
"layers": [],
"camera_status": status,
"snapshot": {
"revision": value["revision"],
"observed_at": value["observed_at"],
"context": {
"session_id": value["session_id"],
"opened_at": value["opened_at"],
"device": {
"device_id": value["id"],
"stability": "stable",
"basis": "hardware-identifier",
"model": {
"plugin_id": "missioncore.insta360",
"plugin_version": "0.1.3",
"model_id": "insta360.x4",
},
},
"execution": {
"node_id": node,
"agent_instance_id": self.instance,
"platform": "linux",
},
},
"acquisition": "streaming"
if preview == 1
else "idle"
if preview == 0
else "failed",
"connectivity": "connected" if value["online"] else "offline",
"enrollment": "enrolled" if value["prepared"] else "empty",
"message": value["message"],
},
}
def dispatch(self, method, route, value, headers):
if method == "GET" and route == "/prepare-safe":
snapshots = self.snapshots()
expected = list(INSTANCES.glob("instax4_*"))
return {
"safe": len(snapshots) == len(expected)
and all(item["preparation_safe"] for item in snapshots)
}
node = headers.get("X-Node-Id", "")
if not re.fullmatch(r"node_[0-9a-f]{64}", node):
raise ValueError("Node identity is required")
if method == "GET" and route == "/inventory":
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") 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:
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"):
return self.media_operation(identifier, value)
if value.get("action_id") == "verify":
return self.verify_operation(identifier, value)
return request(worker_socket(identifier), "/operation", value, timeout=55)
raise ValueError("Unsupported model route")
class MediaCalls:
def __init__(self, engine, key, path):
self.engine, self.key, self.path = engine, key, path
def call(self, action, params):
if action == "offer":
current = request(self.path, "/snapshot", timeout=2)
if (
current.get("session_id") != self.key[1]
or not current.get("online")
or current.get("status", {}).get("preview") != 1
):
raise RuntimeError("Camera preview is not active")
return self.engine.call(self.key, self.path, action, params)
class VerificationCalls:
def __init__(self, broker, path, command):
self.broker, self.path, self.command = broker, path, command
def call(self, action, params):
current = request(self.path, "/snapshot", timeout=2)
session = self.command["session"]
if (
current.get("id") != session["device_id"]
or current.get("session_id") != session["session_id"]
or not current.get("online")
):
return dict(UNKNOWN)
status = current.get("status", {})
if status.get("recording") != 0:
return failure("verification_recording_active")
if status.get("preview") == 1:
engine = self.broker.media
if engine is None:
return failure("preview_restart_required")
return engine.verify((session["device_id"], session["session_id"]))
if status.get("preview") == 0:
# Preserve worker ownership of the bounded temporary idle preview.
return request(self.path, "/operation", self.command, timeout=55)
return dict(UNKNOWN)
def main():
path = ROOT / "driver.sock"
if path.exists():
path.unlink()
allowed = {0, pwd.getpwnam("mission-core-node").pw_uid}
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)