Add packaged Insta360 X4 integration and recover paired Node channels
Discover independent camera instances and prepare their versioned runtime from Node or remote Core. Add isolated SDK workers, camera controls, raw dual-fisheye WebRTC preview, and shared action/region loading states. Recover existing Node bindings over known Tailscale addresses after a Core LAN address change. Preserve identities and trust, pin both peers, migrate endpoints with revision checks, and require real heartbeats for online status. Fix the Python client certificate profile for Go X509 verification. Pin Design Guideline 8c53f73 and retain installer/build/acceptance history. Node 0.8.19 is installed; X4 0.1.3-3 is bundled but hardware activation is pending. Validation: qualified DG/Node builds and Go race tests; 31 fleet tests; Python-to-Go certificate interoperability and live tailnet recovery with five fresh heartbeats; prior 38 X4 tests and bounded remote WebRTC acceptance. Clean-OS, replug/power autonomy, local X4 video and long-run stability remain open.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Mission Core's private Insta360 X4 worker implementation."""
|
||||
@@ -0,0 +1,270 @@
|
||||
"""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 = {}
|
||||
|
||||
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())
|
||||
|
||||
def capture_operation(self, identifier, value):
|
||||
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 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)
|
||||
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)
|
||||
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":
|
||||
return {"items": [self.item(item, node) for item in self.snapshots()]}
|
||||
if method == "POST" and route == "/operation":
|
||||
identifier = value["session"]["device_id"]
|
||||
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}
|
||||
with Server(path, Broker().dispatch, allowed) as server:
|
||||
path.chmod(0o660)
|
||||
server.serve_forever(poll_interval=0.5)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Public messages are allowlisted; vendor error text never crosses the boundary."""
|
||||
|
||||
MESSAGES = {
|
||||
"unsupported_camera_parameter": "Параметр или действие недоступны в текущем режиме камеры.",
|
||||
"verification_recording_active": "Для проверки изображения остановите запись на камере.",
|
||||
"preview_no_decodable_image": "Камера не передала декодируемое изображение.",
|
||||
"preview_restart_required": "Не поступают свежие кадры. Остановите и снова начните просмотр.",
|
||||
}
|
||||
|
||||
|
||||
def failure(code):
|
||||
return {"state": "error", "error": code}
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Bounded per-camera encoded fanout. Readers never consume each other's data."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
MAX_PACKET = 4 * 1024 * 1024
|
||||
MAX_BYTES = 8 * 1024 * 1024
|
||||
MAX_ENTRIES = 64
|
||||
MAX_WIRE = MAX_PACKET + 1028
|
||||
MAX_PARAMETERS = 65536
|
||||
START_CODE = re.compile(b"\x00\x00(?:\x00)?\x01")
|
||||
|
||||
|
||||
class Parameters:
|
||||
"""Retain only codec setup, so a late viewer can decode the next keyframe.
|
||||
|
||||
Parameter sets describe this preview generation, never a previous session.
|
||||
Media history and camera commands are not retained or replayed.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.streams = {}
|
||||
|
||||
def packet(self, header, data):
|
||||
codec = header["codec"]
|
||||
required = (7, 8) if codec == 0 else (32, 33, 34)
|
||||
keyframes = (5,) if codec == 0 else (16, 17, 18, 19, 20, 21)
|
||||
saved = self.streams.setdefault((header["stream_index"], codec), {})
|
||||
markers = START_CODE.finditer(data)
|
||||
marker = next(markers, None)
|
||||
present, keyframe = set(), False
|
||||
while marker is not None:
|
||||
following = next(markers, None)
|
||||
if marker.end() >= len(data):
|
||||
break
|
||||
kind = data[marker.end()] & 31 if codec == 0 else (data[marker.end()] >> 1) & 63
|
||||
keyframe |= kind in keyframes
|
||||
if kind in required:
|
||||
end = following.start() if following is not None else len(data)
|
||||
value = data[marker.start() : end]
|
||||
if len(value) > MAX_PARAMETERS:
|
||||
saved.clear()
|
||||
return data
|
||||
saved[kind] = value
|
||||
present.add(kind)
|
||||
marker = following
|
||||
if keyframe and all(kind in saved for kind in required):
|
||||
prefix = b"".join(saved[kind] for kind in required if kind not in present)
|
||||
if len(prefix) + len(data) <= MAX_PACKET:
|
||||
return prefix + data
|
||||
return data
|
||||
|
||||
|
||||
def encode(value):
|
||||
if value is None:
|
||||
return b""
|
||||
header, data = value
|
||||
raw = json.dumps(header, allow_nan=False, separators=(",", ":")).encode()
|
||||
if len(raw) > 1024 or not 0 < len(data) <= MAX_PACKET:
|
||||
raise ValueError("Invalid video envelope")
|
||||
return struct.pack("!I", len(raw)) + raw + data
|
||||
|
||||
|
||||
def decode(raw):
|
||||
if not raw:
|
||||
return None
|
||||
if len(raw) < 5 or len(raw) > MAX_WIRE:
|
||||
raise ValueError("Invalid video envelope")
|
||||
size = struct.unpack("!I", raw[:4])[0]
|
||||
if not 0 < size <= 1024 or len(raw) <= 4 + size:
|
||||
raise ValueError("Invalid video envelope")
|
||||
header, data = json.loads(raw[4 : 4 + size]), raw[4 + size :]
|
||||
if (
|
||||
not isinstance(header, dict)
|
||||
or any(
|
||||
type(header.get(key)) is not int or header[key] < 0
|
||||
for key in ("cursor", "generation", "stream_index", "codec")
|
||||
)
|
||||
or header["stream_index"] not in (0, 1)
|
||||
or header["codec"] not in (0, 1)
|
||||
or type(header.get("gap")) is not bool
|
||||
or not 0 < len(data) <= MAX_PACKET
|
||||
):
|
||||
raise ValueError("Invalid video metadata")
|
||||
return header, data
|
||||
|
||||
|
||||
class Feed:
|
||||
def __init__(self, camera):
|
||||
self.camera = camera
|
||||
self.lock = threading.Condition()
|
||||
self.source_lock = threading.Lock()
|
||||
self.queue = deque()
|
||||
self.bytes = self.cursor = self.generation = 0
|
||||
self.source_generation = None
|
||||
self.parameters = Parameters()
|
||||
self.closed = threading.Event()
|
||||
self.thread = threading.Thread(target=self.pump, daemon=True)
|
||||
|
||||
def clear(self):
|
||||
with self.lock:
|
||||
self.queue.clear()
|
||||
self.bytes = 0
|
||||
self.parameters = Parameters()
|
||||
self.generation += 1
|
||||
self.lock.notify_all()
|
||||
|
||||
def append(self, value):
|
||||
source, data = value
|
||||
if not 0 < len(data) <= MAX_PACKET:
|
||||
self.clear()
|
||||
return
|
||||
with self.lock:
|
||||
if self.source_generation != source["generation"]:
|
||||
self.queue.clear()
|
||||
self.bytes = 0
|
||||
self.source_generation = source["generation"]
|
||||
self.generation += 1
|
||||
self.parameters = Parameters()
|
||||
data = self.parameters.packet(source, data)
|
||||
while self.queue and (
|
||||
self.bytes + len(data) > MAX_BYTES or len(self.queue) >= MAX_ENTRIES
|
||||
):
|
||||
_, removed, _ = self.queue.popleft()
|
||||
self.bytes -= len(removed)
|
||||
self.cursor += 1
|
||||
header = dict(source, cursor=self.cursor, generation=self.generation)
|
||||
self.queue.append((header, data, time.monotonic()))
|
||||
self.bytes += len(data)
|
||||
self.lock.notify_all()
|
||||
|
||||
def read(self, cursor, wait=0.1):
|
||||
if type(cursor) is not int or not 0 <= cursor < 2**64:
|
||||
raise ValueError("Invalid video cursor")
|
||||
deadline = time.monotonic() + min(max(wait, 0), 0.2)
|
||||
with self.lock:
|
||||
while True:
|
||||
for header, data, observed in self.queue:
|
||||
if header["cursor"] > cursor and time.monotonic() - observed < 2:
|
||||
return dict(
|
||||
header, gap=bool(cursor and header["cursor"] != cursor + 1)
|
||||
), data
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0 or self.closed.is_set():
|
||||
return None
|
||||
self.lock.wait(remaining)
|
||||
|
||||
def reader(self):
|
||||
with self.lock:
|
||||
cursor = self.cursor
|
||||
|
||||
def read():
|
||||
nonlocal cursor
|
||||
value = self.read(cursor, wait=0)
|
||||
if value is not None:
|
||||
cursor = value[0]["cursor"]
|
||||
return value
|
||||
|
||||
return read
|
||||
|
||||
def change(self, action):
|
||||
# No packet read from the previous preview may be appended after a
|
||||
# START/STOP boundary and mistaken for evidence of the new preview.
|
||||
with self.source_lock:
|
||||
self.clear()
|
||||
try:
|
||||
return action()
|
||||
finally:
|
||||
self.clear()
|
||||
|
||||
def pump(self):
|
||||
while not self.closed.is_set():
|
||||
try:
|
||||
with self.source_lock:
|
||||
value = self.camera.video()
|
||||
if value is not None:
|
||||
self.append(value)
|
||||
continue
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
self.clear()
|
||||
self.closed.wait(0.01)
|
||||
|
||||
def close(self):
|
||||
self.closed.set()
|
||||
self.clear()
|
||||
if self.thread.is_alive():
|
||||
self.thread.join(timeout=1)
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Bounded private HTTP over Unix sockets, never a public device endpoint."""
|
||||
|
||||
import http.client
|
||||
import json
|
||||
import socket
|
||||
import socketserver
|
||||
import struct
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from http.server import BaseHTTPRequestHandler
|
||||
|
||||
LIMIT = 65536
|
||||
INVENTORY_LIMIT = 2 * 1024 * 1024
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Binary:
|
||||
data: bytes
|
||||
|
||||
|
||||
def request(path, route, value=None, timeout=25, binary=False):
|
||||
client = http.client.HTTPConnection("driver", timeout=timeout)
|
||||
client.sock = socket.socket(socket.AF_UNIX)
|
||||
client.sock.settimeout(timeout)
|
||||
try:
|
||||
client.sock.connect(str(path))
|
||||
body = None if value is None else json.dumps(value, allow_nan=False).encode()
|
||||
client.request(
|
||||
"GET" if value is None else "POST", route, body, {"Content-Type": "application/json"}
|
||||
)
|
||||
reply = client.getresponse()
|
||||
limit = (
|
||||
4 * 1024 * 1024 + 1028
|
||||
if binary
|
||||
else INVENTORY_LIMIT
|
||||
if route == "/inventory"
|
||||
else LIMIT
|
||||
)
|
||||
raw = reply.read(limit + 1)
|
||||
if len(raw) > limit or reply.status != 200:
|
||||
raise RuntimeError("Camera service request failed")
|
||||
if binary:
|
||||
if reply.getheader("Content-Type") != "application/octet-stream":
|
||||
raise RuntimeError("Invalid video response")
|
||||
return raw
|
||||
return json.loads(raw)
|
||||
except http.client.HTTPException as error:
|
||||
raise RuntimeError("Camera service response is incomplete") from error
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
|
||||
class Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
|
||||
daemon_threads = True
|
||||
block_on_close = False
|
||||
|
||||
def __init__(self, path, dispatch, allowed_uids):
|
||||
self.dispatch = dispatch
|
||||
self.allowed_uids = frozenset(allowed_uids)
|
||||
self.slots = threading.BoundedSemaphore(12)
|
||||
super().__init__(str(path), Handler)
|
||||
|
||||
def verify_request(self, request, address):
|
||||
_, uid, _ = struct.unpack(
|
||||
"3i", request.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12)
|
||||
)
|
||||
return uid in self.allowed_uids
|
||||
|
||||
def process_request(self, request, address):
|
||||
if not self.slots.acquire(blocking=False):
|
||||
self.shutdown_request(request)
|
||||
return
|
||||
try:
|
||||
super().process_request(request, address)
|
||||
except BaseException:
|
||||
self.slots.release()
|
||||
raise
|
||||
|
||||
def process_request_thread(self, request, address):
|
||||
try:
|
||||
super().process_request_thread(request, address)
|
||||
finally:
|
||||
self.slots.release()
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def setup(self):
|
||||
self.request.settimeout(10)
|
||||
super().setup()
|
||||
|
||||
def log_message(self, *_):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
self.handle_request()
|
||||
|
||||
def do_POST(self):
|
||||
self.handle_request()
|
||||
|
||||
def handle_request(self):
|
||||
try:
|
||||
if self.headers.get("Transfer-Encoding"):
|
||||
raise ValueError("Chunked commands are not supported")
|
||||
value = None
|
||||
if self.command == "POST":
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if (
|
||||
not 0 < length <= LIMIT
|
||||
or self.headers.get("Content-Type") != "application/json"
|
||||
):
|
||||
raise ValueError("Invalid command size")
|
||||
raw = self.rfile.read(length)
|
||||
if len(raw) != length:
|
||||
raise ValueError("Truncated command")
|
||||
value = json.loads(raw)
|
||||
result = self.server.dispatch(self.command, self.path, value, self.headers)
|
||||
status = 200
|
||||
except (ValueError, KeyError, TypeError):
|
||||
status, result = 400, {"error": "Некорректная команда камеры."}
|
||||
except (RuntimeError, OSError, TimeoutError):
|
||||
status, result = 409, {"error": "Камера не ответила. Проверьте подключение."}
|
||||
binary = isinstance(result, Binary)
|
||||
data = (
|
||||
result.data
|
||||
if binary
|
||||
else json.dumps(result, ensure_ascii=False, allow_nan=False).encode()
|
||||
)
|
||||
limit = (
|
||||
4 * 1024 * 1024 + 1028
|
||||
if binary
|
||||
else INVENTORY_LIMIT
|
||||
if self.path == "/inventory"
|
||||
else LIMIT
|
||||
)
|
||||
if len(data) > limit:
|
||||
binary = False
|
||||
status, data = 500, b'{"error":"Camera response exceeds its limit"}'
|
||||
self.send_response(status)
|
||||
self.send_header(
|
||||
"Content-Type", "application/octet-stream" if binary else "application/json"
|
||||
)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Read-only USB admission, before vendor enumeration or library loading."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def device_id(serial):
|
||||
return "instax4_" + hashlib.sha256(serial.encode()).hexdigest()[:32]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Binding:
|
||||
device_id: str
|
||||
port: str
|
||||
bus: int
|
||||
address: int
|
||||
serial: str
|
||||
|
||||
@property
|
||||
def device_path(self):
|
||||
return f"/dev/bus/usb/{self.bus:03d}/{self.address:03d}"
|
||||
|
||||
|
||||
def read_binding(port, root=Path("/sys/bus/usb/devices")):
|
||||
if not isinstance(port, str) or not re.fullmatch(
|
||||
r"[1-9][0-9]*-[1-9][0-9]*(?:\.[1-9][0-9]*)*", port
|
||||
):
|
||||
raise ValueError("Invalid camera transport binding")
|
||||
path = root / port
|
||||
|
||||
def read(name):
|
||||
return (path / name).read_text().strip()
|
||||
|
||||
if (read("idVendor"), read("idProduct"), read("product")) != ("2e1a", "0002", "Insta360 X4"):
|
||||
raise ValueError("USB device is not the admitted X4 model")
|
||||
serial = read("serial")
|
||||
if not serial or len(serial) > 256 or any(ord(c) < 32 for c in serial):
|
||||
raise ValueError("Camera identity is unavailable")
|
||||
bus, address = int(read("busnum")), int(read("devnum"))
|
||||
if not 1 <= bus <= 999 or not 1 <= address <= 127:
|
||||
raise ValueError("Invalid USB bus address")
|
||||
return Binding(device_id(serial), port, bus, address, serial)
|
||||
|
||||
|
||||
def verify_isolation(binding):
|
||||
"""No override switch. An ordinary host process cannot load the SDK here.
|
||||
|
||||
The root-owned instance service must have PrivateDevices+one BindPaths,
|
||||
DevicePolicy=closed, NoNewPrivileges, no capabilities, and PrivateNetwork.
|
||||
Service declarations additionally enforce cgroup access to this USB node.
|
||||
"""
|
||||
if os.uname().sysname != "Linux" or os.geteuid() == 0:
|
||||
raise RuntimeError("X4 requires an unprivileged isolated Linux worker")
|
||||
current = read_binding(binding.port)
|
||||
if current != binding:
|
||||
raise RuntimeError("Camera changed before SDK initialization")
|
||||
status = dict(
|
||||
line.split(":", 1)
|
||||
for line in Path("/proc/self/status").read_text().splitlines()
|
||||
if ":" in line
|
||||
)
|
||||
if int(status["CapEff"].strip(), 16) or status["NoNewPrivs"].strip() != "1":
|
||||
raise RuntimeError("Camera process has unexpected privileges")
|
||||
proof = Path("/run/mission-core-x4-control/host-net-inode")
|
||||
for path in (proof.parent, proof):
|
||||
info = path.lstat()
|
||||
if path.is_symlink() or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise RuntimeError("Untrusted host namespace evidence")
|
||||
host_network = int(proof.read_text())
|
||||
if host_network <= 0 or Path("/proc/self/ns/net").stat().st_ino == host_network:
|
||||
raise RuntimeError("Camera process has access to the host network")
|
||||
devices = list(Path("/dev/bus/usb").glob("*/*"))
|
||||
if devices != [Path(binding.device_path)]:
|
||||
raise RuntimeError("Camera process can see unrelated USB devices")
|
||||
info = devices[0].lstat()
|
||||
expected_minor = (binding.bus - 1) * 128 + binding.address - 1
|
||||
if not stat.S_ISCHR(info.st_mode) or (os.major(info.st_rdev), os.minor(info.st_rdev)) != (
|
||||
189,
|
||||
expected_minor,
|
||||
):
|
||||
raise RuntimeError("Camera device node does not match the transport")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""OS lock shared by every SDK effect and exclusive profile activation."""
|
||||
|
||||
import fcntl
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path("/var/lib/mission-core-insta360")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def acquisition(wait=False):
|
||||
path = ROOT / "lifecycle.lock"
|
||||
for item in (ROOT, path):
|
||||
info = item.lstat()
|
||||
if item.is_symlink() or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise RuntimeError("Untrusted camera lifecycle lock")
|
||||
with path.open("rb") as handle:
|
||||
try:
|
||||
fcntl.flock(handle, fcntl.LOCK_SH | (0 if wait else fcntl.LOCK_NB))
|
||||
except BlockingIOError:
|
||||
raise RuntimeError("Подготовка драйвера ещё выполняется.") from None
|
||||
if (ROOT / "maintenance").exists():
|
||||
raise RuntimeError("Драйвер X4 обновляется. Повторите после подготовки.")
|
||||
yield
|
||||
@@ -0,0 +1,394 @@
|
||||
"""Private WebRTC operator view outside the vendor SDK network namespace.
|
||||
|
||||
Opening/closing a peer never sends a camera command. Each source has its own
|
||||
worker socket, session, decoder and bounded latest-frame slot. No stitching.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import TimeoutError as FutureTimeout
|
||||
from fractions import Fraction
|
||||
|
||||
import aioice.ice
|
||||
import av
|
||||
from aiortc import (
|
||||
RTCConfiguration,
|
||||
RTCPeerConnection,
|
||||
RTCRtpSender,
|
||||
RTCSessionDescription,
|
||||
VideoStreamTrack,
|
||||
)
|
||||
from aiortc.mediastreams import MediaStreamError
|
||||
|
||||
from .errors import failure
|
||||
from .frames import decode
|
||||
from .http import request
|
||||
|
||||
NETWORKS = tuple(
|
||||
ipaddress.ip_network(value)
|
||||
for value in (
|
||||
"127.0.0.0/8",
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"100.64.0.0/10",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def private(value):
|
||||
try:
|
||||
address = ipaddress.ip_address(value)
|
||||
return address.version == 4 and any(address in network for network in NETWORKS)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
_host_addresses = aioice.ice.get_host_addresses
|
||||
aioice.ice.get_host_addresses = lambda use_ipv4, use_ipv6: [
|
||||
value for value in _host_addresses(use_ipv4=True, use_ipv6=False) if private(value)
|
||||
]
|
||||
|
||||
|
||||
def admit_sdp(sdp):
|
||||
if not isinstance(sdp, str) or not 0 < len(sdp) <= 32768:
|
||||
raise ValueError("Invalid camera offer")
|
||||
for line in sdp.splitlines():
|
||||
if line.startswith("a=candidate:"):
|
||||
fields = line.split()
|
||||
if len(fields) < 8 or not (private(fields[4]) or fields[4].endswith(".local")):
|
||||
raise ValueError("Camera preview requires a private network")
|
||||
|
||||
|
||||
class Source:
|
||||
def __init__(self, path, session):
|
||||
self.path, self.session = path, session
|
||||
self.lock = threading.Lock()
|
||||
self.frame = None
|
||||
self.sequence = 0
|
||||
self.observed = 0.0
|
||||
self.started = time.monotonic()
|
||||
self.closed = threading.Event()
|
||||
self.thread = threading.Thread(target=self.pump, daemon=True)
|
||||
|
||||
def current(self):
|
||||
with self.lock:
|
||||
return self.sequence, self.frame, self.observed
|
||||
|
||||
def pump(self):
|
||||
cursor, key, decoder, resized_at = 0, None, None, 0.0
|
||||
try:
|
||||
while not self.closed.is_set():
|
||||
raw = request(
|
||||
self.path,
|
||||
"/video",
|
||||
{"session_id": self.session, "cursor": cursor},
|
||||
timeout=2,
|
||||
binary=True,
|
||||
)
|
||||
value = decode(raw)
|
||||
if value is None:
|
||||
continue
|
||||
header, data = value
|
||||
cursor = header["cursor"]
|
||||
if header["stream_index"] != 0:
|
||||
continue
|
||||
current = (header["generation"], header["codec"])
|
||||
if decoder is None or key != current or header["gap"]:
|
||||
decoder = av.CodecContext.create(
|
||||
"h264" if header["codec"] == 0 else "hevc", "r"
|
||||
)
|
||||
decoder.thread_count = 1
|
||||
key = current
|
||||
try:
|
||||
for packet in decoder.parse(data):
|
||||
for frame in decoder.decode(packet):
|
||||
if not (0 < frame.width <= 4096 and 0 < frame.height <= 2160):
|
||||
raise RuntimeError("Camera frame exceeds the admitted view profile")
|
||||
now = time.monotonic()
|
||||
if now - resized_at < 1 / 15:
|
||||
continue
|
||||
width = min(1280, frame.width) // 2 * 2
|
||||
height = max(2, round(frame.height * width / frame.width) // 2 * 2)
|
||||
output = frame.reformat(width=width, height=height, format="yuv420p")
|
||||
output.pts = round((now - self.started) * 90000)
|
||||
output.time_base = Fraction(1, 90000)
|
||||
with self.lock:
|
||||
self.frame, self.observed = output, now
|
||||
self.sequence += 1
|
||||
resized_at = now
|
||||
except av.error.FFmpegError:
|
||||
decoder = None
|
||||
except (OSError, ValueError, RuntimeError) as error:
|
||||
logging.getLogger(__name__).warning(
|
||||
"X4 preview source stopped (%s)", type(error).__name__
|
||||
)
|
||||
finally:
|
||||
self.closed.set()
|
||||
with self.lock:
|
||||
self.frame = None
|
||||
|
||||
def close(self):
|
||||
self.closed.set()
|
||||
|
||||
|
||||
class Track(VideoStreamTrack):
|
||||
def __init__(self, source):
|
||||
super().__init__()
|
||||
self.source, self.sequence, self.next_at = source, 0, 0.0
|
||||
|
||||
async def recv(self):
|
||||
while self.readyState == "live" and not self.source.closed.is_set():
|
||||
await asyncio.sleep(max(0, self.next_at - time.monotonic()))
|
||||
sequence, frame, observed = self.source.current()
|
||||
if frame is not None and sequence > self.sequence and time.monotonic() - observed < 2:
|
||||
self.sequence = sequence
|
||||
self.next_at = time.monotonic() + 1 / 15
|
||||
# Distinct AV frames per encoder: force-keyframe decisions in
|
||||
# one browser must not mutate another browser's frame object.
|
||||
copy = av.VideoFrame(frame.width, frame.height, "yuv420p")
|
||||
for target, source in zip(copy.planes, frame.planes, strict=True):
|
||||
if target.line_size == source.line_size:
|
||||
target.update(source)
|
||||
else:
|
||||
raw = bytes(source)
|
||||
padded = bytearray(target.buffer_size)
|
||||
for row in range(target.height):
|
||||
padded[
|
||||
row * target.line_size : row * target.line_size + target.width
|
||||
] = raw[row * source.line_size : row * source.line_size + target.width]
|
||||
target.update(padded)
|
||||
copy.pts, copy.time_base = frame.pts, frame.time_base
|
||||
return copy
|
||||
await asyncio.sleep(0.01)
|
||||
raise MediaStreamError
|
||||
|
||||
|
||||
class Peers:
|
||||
def __init__(self, source_factory=Source):
|
||||
self.items, self.sources = {}, {}
|
||||
self.primed = {}
|
||||
self.source_factory = source_factory
|
||||
|
||||
def source(self, key, path):
|
||||
source = self.sources.get(key)
|
||||
if source is not None and source.closed.is_set():
|
||||
raise RuntimeError("Camera video session ended")
|
||||
if source is None:
|
||||
if len(self.sources) >= 4:
|
||||
raise ValueError("Остановите лишний просмотр камеры.")
|
||||
source = self.source_factory(path, key[1])
|
||||
self.sources[key] = source
|
||||
source.thread.start()
|
||||
return source
|
||||
|
||||
async def prime(self, key, path):
|
||||
# Start reading before the explicit camera START. Some previews expose
|
||||
# an independently decodable beginning only once per capture session.
|
||||
source = self.source(key, path)
|
||||
if key in self.primed:
|
||||
return {"ok": True}
|
||||
|
||||
async def watch():
|
||||
started, active, fresh = time.monotonic(), False, time.monotonic()
|
||||
while key in self.primed:
|
||||
try:
|
||||
status = await asyncio.to_thread(request, path, "/snapshot", timeout=2)
|
||||
preview = status.get("status", {}).get("preview")
|
||||
ended = status.get("session_id") != key[1] or source.closed.is_set()
|
||||
if status.get("online"):
|
||||
fresh = time.monotonic()
|
||||
ended |= active and preview == 0
|
||||
active |= preview == 1
|
||||
ended |= active and time.monotonic() - fresh > 15
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
ended = True
|
||||
if ended or (not active and time.monotonic() - started > 45):
|
||||
await self.release(key)
|
||||
return
|
||||
await asyncio.sleep(1)
|
||||
|
||||
self.primed[key] = asyncio.create_task(watch())
|
||||
return {"ok": True}
|
||||
|
||||
async def release(self, key):
|
||||
task = self.primed.pop(key, None)
|
||||
if task is not None and task is not asyncio.current_task():
|
||||
task.cancel()
|
||||
for identifier, entry in tuple(self.items.items()):
|
||||
if entry["key"] == key:
|
||||
await self.close(key, identifier)
|
||||
source = self.sources.pop(key, None)
|
||||
if source is not None:
|
||||
source.close()
|
||||
await asyncio.to_thread(source.thread.join, 3)
|
||||
return {"ok": True}
|
||||
|
||||
async def verify(self, key, timeout=8):
|
||||
# An active preview may have emitted its only keyframe minutes ago.
|
||||
# Verify two NEW decoded frames from its existing source, never open a
|
||||
# competing decoder or accept a retained frame as fresh evidence.
|
||||
source = self.sources.get(key)
|
||||
if source is None or source.closed.is_set():
|
||||
return failure("preview_restart_required")
|
||||
initial = source.current()[0]
|
||||
started = time.monotonic()
|
||||
while time.monotonic() - started < timeout:
|
||||
if self.sources.get(key) is not source or source.closed.is_set():
|
||||
break
|
||||
sequence, frame, observed = source.current()
|
||||
if frame is not None and sequence >= initial + 2 and time.monotonic() - observed < 2:
|
||||
return {
|
||||
"state": "complete",
|
||||
"result": {
|
||||
"verified": True,
|
||||
"streams": [
|
||||
{
|
||||
"stream_index": 0,
|
||||
"width": frame.width,
|
||||
"height": frame.height,
|
||||
"frames": sequence - initial,
|
||||
}
|
||||
],
|
||||
"duration_ms": round((time.monotonic() - started) * 1000),
|
||||
},
|
||||
}
|
||||
await asyncio.sleep(0.02)
|
||||
return failure("preview_restart_required")
|
||||
|
||||
async def offer(self, key, path, params):
|
||||
admit_sdp(params["sdp"])
|
||||
# Inventory remains independent; these limits bound active media only.
|
||||
if len(self.items) >= 4 or sum(item["key"] == key for item in self.items.values()) >= 2:
|
||||
raise ValueError("Закройте лишний просмотр камеры.")
|
||||
source = self.source(key, path)
|
||||
pc = RTCPeerConnection(RTCConfiguration(iceServers=[]))
|
||||
identifier = "peer_" + uuid.uuid4().hex
|
||||
entry = {"key": key, "pc": pc, "source": source, "seen": time.monotonic(), "tasks": set()}
|
||||
self.items[identifier] = entry
|
||||
|
||||
async def telemetry(channel):
|
||||
while identifier in self.items:
|
||||
sequence, frame, observed = source.current()
|
||||
if channel.readyState == "open" and channel.bufferedAmount < 65536:
|
||||
channel.send(
|
||||
json.dumps(
|
||||
{
|
||||
"frame_sequence": sequence,
|
||||
"frame_age_ms": round((time.monotonic() - observed) * 1000)
|
||||
if frame
|
||||
else None,
|
||||
"fresh": frame is not None and time.monotonic() - observed < 2,
|
||||
}
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
def task(coroutine):
|
||||
value = asyncio.create_task(coroutine)
|
||||
entry["tasks"].add(value)
|
||||
value.add_done_callback(entry["tasks"].discard)
|
||||
|
||||
@pc.on("datachannel")
|
||||
def datachannel(channel):
|
||||
if channel.label != "sensor":
|
||||
channel.close()
|
||||
return
|
||||
|
||||
@channel.on("message")
|
||||
def message(value):
|
||||
if value == "keepalive":
|
||||
entry["seen"] = time.monotonic()
|
||||
|
||||
task(telemetry(channel))
|
||||
|
||||
@pc.on("connectionstatechange")
|
||||
async def changed():
|
||||
if pc.connectionState in ("failed", "closed"):
|
||||
await self.close(key, identifier)
|
||||
|
||||
async def watchdog():
|
||||
while identifier in self.items:
|
||||
if source.closed.is_set() or time.monotonic() - entry["seen"] > 30:
|
||||
await self.close(key, identifier)
|
||||
return
|
||||
await asyncio.sleep(1)
|
||||
|
||||
try:
|
||||
sender = pc.addTrack(Track(source))
|
||||
transceiver = next(item for item in pc.getTransceivers() if item.sender == sender)
|
||||
transceiver.setCodecPreferences(
|
||||
[
|
||||
codec
|
||||
for codec in RTCRtpSender.getCapabilities("video").codecs
|
||||
if codec.mimeType.lower() == "video/h264"
|
||||
]
|
||||
)
|
||||
await pc.setRemoteDescription(RTCSessionDescription(sdp=params["sdp"], type="offer"))
|
||||
await pc.setLocalDescription(await pc.createAnswer())
|
||||
task(watchdog())
|
||||
return {"peer_id": identifier, "sdp": pc.localDescription.sdp, "type": "answer"}
|
||||
except BaseException:
|
||||
await self.close(key, identifier)
|
||||
raise
|
||||
|
||||
async def close(self, key, identifier):
|
||||
entry = self.items.get(identifier)
|
||||
if entry is None:
|
||||
return {"ok": True}
|
||||
if entry["key"] != key:
|
||||
raise ValueError("Preview belongs to a different camera session")
|
||||
self.items.pop(identifier)
|
||||
current = asyncio.current_task()
|
||||
for task in tuple(entry["tasks"]):
|
||||
if task is not current:
|
||||
task.cancel()
|
||||
await entry["pc"].close()
|
||||
if key not in self.primed and not any(item["key"] == key for item in self.items.values()):
|
||||
source = self.sources.pop(key)
|
||||
source.close()
|
||||
await asyncio.to_thread(source.thread.join, 3)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class Engine:
|
||||
def __init__(self):
|
||||
self.loop = asyncio.new_event_loop()
|
||||
self.peers = Peers()
|
||||
self.thread = threading.Thread(target=self.loop.run_forever, daemon=True)
|
||||
self.thread.start()
|
||||
|
||||
def capture(self, key, path, start):
|
||||
work = self.peers.prime(key, path) if start else self.peers.release(key)
|
||||
future = asyncio.run_coroutine_threadsafe(work, self.loop)
|
||||
try:
|
||||
return future.result(timeout=10)
|
||||
except FutureTimeout as error:
|
||||
future.cancel()
|
||||
raise RuntimeError("Camera decoder setup timed out") from error
|
||||
|
||||
def call(self, key, path, action, params):
|
||||
work = (
|
||||
self.peers.offer(key, path, params)
|
||||
if action == "offer"
|
||||
else self.peers.close(key, params["peer_id"])
|
||||
)
|
||||
future = asyncio.run_coroutine_threadsafe(work, self.loop)
|
||||
try:
|
||||
return {"state": "complete", "result": future.result(timeout=20)}
|
||||
except FutureTimeout as error:
|
||||
future.cancel()
|
||||
raise RuntimeError("Camera preview timed out") from error
|
||||
|
||||
def verify(self, key):
|
||||
future = asyncio.run_coroutine_threadsafe(self.peers.verify(key), self.loop)
|
||||
try:
|
||||
return future.result(timeout=10)
|
||||
except FutureTimeout as error:
|
||||
future.cancel()
|
||||
raise RuntimeError("Camera image verification timed out") from error
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Typed private C ABI. Imported safely; vendor code loads only after admission."""
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import threading
|
||||
|
||||
from .identity import verify_isolation
|
||||
|
||||
ACTIONS = {
|
||||
"details": 1,
|
||||
"settings.read": 2,
|
||||
"settings.apply": 3,
|
||||
"preview.start": 4,
|
||||
"preview.stop": 5,
|
||||
"record.start": 6,
|
||||
"record.stop": 7,
|
||||
"photo.capture": 8,
|
||||
"files.list": 9,
|
||||
}
|
||||
MAX_PACKET = 4 * 1024 * 1024
|
||||
|
||||
|
||||
class VideoHeader(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("sequence", ctypes.c_uint64),
|
||||
("generation", ctypes.c_uint64),
|
||||
("timestamp", ctypes.c_int64),
|
||||
("stream_index", ctypes.c_int32),
|
||||
("codec", ctypes.c_int32),
|
||||
("bytes", ctypes.c_uint32),
|
||||
]
|
||||
|
||||
|
||||
def parameters(action, value):
|
||||
"""Validate before any SDK call; parameters never select a file or library."""
|
||||
if action == "offer":
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or set(value) != {"sdp", "layer"}
|
||||
or value["layer"] != "preview"
|
||||
or not isinstance(value["sdp"], str)
|
||||
or not 0 < len(value["sdp"]) <= 32768
|
||||
):
|
||||
raise ValueError("Invalid camera preview offer")
|
||||
return 0, "", 0.0
|
||||
if action == "close-peer":
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or set(value) != {"peer_id"}
|
||||
or not isinstance(value["peer_id"], str)
|
||||
or not re.fullmatch(r"peer_[0-9a-f]{32}", value["peer_id"])
|
||||
):
|
||||
raise ValueError("Invalid camera preview identity")
|
||||
return 0, "", 0.0
|
||||
if action == "verify":
|
||||
if not isinstance(value, dict) or value:
|
||||
raise ValueError("Image verification uses the installed fixed profile")
|
||||
return 0, "", 0.0
|
||||
if action not in ACTIONS or not isinstance(value, dict):
|
||||
raise ValueError("Unsupported camera command")
|
||||
if action in ("settings.read", "settings.apply"):
|
||||
required = {"mode"} if action == "settings.read" else {"mode", "key", "value"}
|
||||
if (
|
||||
set(value) != required
|
||||
or type(value["mode"]) is not int
|
||||
or not 0 <= value["mode"] <= 255
|
||||
):
|
||||
raise ValueError("Invalid camera mode")
|
||||
if action == "settings.read":
|
||||
return value["mode"], "", 0.0
|
||||
if value["key"] not in (
|
||||
"function_mode",
|
||||
"video_resolution",
|
||||
"photo_size",
|
||||
"white_balance",
|
||||
"iso",
|
||||
"exposure_mode",
|
||||
):
|
||||
raise ValueError("Unsupported camera setting")
|
||||
number = value["value"]
|
||||
if (
|
||||
type(number) not in (int, float)
|
||||
or not math.isfinite(number)
|
||||
or not 0 <= number <= 65535
|
||||
):
|
||||
raise ValueError("Invalid camera setting")
|
||||
return value["mode"], value["key"], float(number)
|
||||
if action == "files.list":
|
||||
if (
|
||||
set(value) - {"offset"}
|
||||
or type(value.get("offset", 0)) is not int
|
||||
or not 0 <= value.get("offset", 0) <= 100000
|
||||
):
|
||||
raise ValueError("Invalid camera file page")
|
||||
return 0, "", float(value.get("offset", 0))
|
||||
if value:
|
||||
raise ValueError("Unexpected camera parameters")
|
||||
return 0, "", 0.0
|
||||
|
||||
|
||||
class NativeCamera:
|
||||
def __init__(self, binding, library_path, log_directory):
|
||||
# No ctypes.CDLL anywhere above this check: loading a .so can execute
|
||||
# constructors even before the first explicit SDK function call.
|
||||
verify_isolation(binding)
|
||||
self.lock = threading.RLock()
|
||||
self.video_lock = threading.Lock()
|
||||
self.api = ctypes.CDLL(str(library_path))
|
||||
self.api.mc_x4_abi.restype = ctypes.c_int
|
||||
if self.api.mc_x4_abi() != 1:
|
||||
raise RuntimeError("Incompatible camera adapter")
|
||||
self.api.mc_x4_open.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||
self.api.mc_x4_open.restype = ctypes.c_void_p
|
||||
self.api.mc_x4_call.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_double,
|
||||
]
|
||||
self.api.mc_x4_call.restype = ctypes.c_char_p
|
||||
self.api.mc_x4_read_video.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.POINTER(VideoHeader),
|
||||
ctypes.POINTER(ctypes.c_uint8),
|
||||
ctypes.c_size_t,
|
||||
]
|
||||
self.api.mc_x4_read_video.restype = ctypes.c_int
|
||||
self.api.mc_x4_close.argtypes = [ctypes.c_void_p]
|
||||
self.api.mc_x4_close.restype = None
|
||||
self.handle = self.api.mc_x4_open(binding.serial.encode(), str(log_directory).encode())
|
||||
if not self.handle:
|
||||
raise RuntimeError("SDK could not open the selected X4")
|
||||
self.buffer = (ctypes.c_uint8 * MAX_PACKET)()
|
||||
|
||||
def call(self, action, values):
|
||||
mode, key, number = parameters(action, values)
|
||||
with self.lock:
|
||||
if not self.handle:
|
||||
raise RuntimeError("Camera session is closed")
|
||||
raw = self.api.mc_x4_call(self.handle, ACTIONS[action], mode, key.encode(), number)
|
||||
if not raw or len(raw) > 65536:
|
||||
raise RuntimeError("Camera returned an invalid response")
|
||||
result = json.loads(raw)
|
||||
if not isinstance(result, dict) or result.get("state") not in (
|
||||
"complete",
|
||||
"error",
|
||||
"unknown",
|
||||
):
|
||||
raise RuntimeError("Camera returned an invalid response")
|
||||
return result
|
||||
|
||||
def video(self):
|
||||
# SDK control calls may block for seconds. The callback queue has its
|
||||
# own native mutex and can be drained without taking the command lock.
|
||||
with self.video_lock:
|
||||
if not self.handle:
|
||||
return None
|
||||
header = VideoHeader()
|
||||
state = self.api.mc_x4_read_video(
|
||||
self.handle, ctypes.byref(header), self.buffer, MAX_PACKET
|
||||
)
|
||||
if state < 0 or header.bytes > MAX_PACKET:
|
||||
raise RuntimeError("Invalid video packet")
|
||||
if state == 0:
|
||||
return None
|
||||
if header.stream_index not in (0, 1) or header.codec not in (0, 1) or not header.bytes:
|
||||
raise RuntimeError("Unsupported video packet")
|
||||
return {name: getattr(header, name) for name, _ in VideoHeader._fields_}, bytes(
|
||||
self.buffer[: header.bytes]
|
||||
)
|
||||
|
||||
def close(self):
|
||||
with self.lock, self.video_lock:
|
||||
if self.handle:
|
||||
self.api.mc_x4_close(self.handle)
|
||||
self.handle = None
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Per-camera durable effects. A retry retrieves a receipt, never replays START."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from missioncore_plugin_sdk.v0alpha2.operations import OperationRequest
|
||||
|
||||
from .errors import MESSAGES
|
||||
from .native import parameters
|
||||
|
||||
UNKNOWN = {
|
||||
"state": "unknown",
|
||||
"error": "Результат команды не подтверждён. Обновите состояние камеры.",
|
||||
}
|
||||
INVALID = {"state": "error", "error": "Параметр или действие недоступны в текущем режиме камеры."}
|
||||
|
||||
|
||||
def atomic(path, value):
|
||||
fd, temporary = tempfile.mkstemp(prefix=".receipt-", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as stream:
|
||||
os.fchmod(stream.fileno(), 0o600)
|
||||
json.dump(value, stream, ensure_ascii=False, allow_nan=False)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
directory = os.open(path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(directory)
|
||||
finally:
|
||||
os.close(directory)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
class Operations:
|
||||
def __init__(self, device_id, session_id, root, camera):
|
||||
self.device_id, self.session_id = device_id, session_id
|
||||
self.root, self.camera = Path(root), camera
|
||||
self.lock = threading.Lock()
|
||||
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
|
||||
def execute(self, value):
|
||||
command = OperationRequest.model_validate(value)
|
||||
if not re.fullmatch(r"op_[0-9a-f]{32}", command.operation_id):
|
||||
raise ValueError("Invalid operation identity")
|
||||
if command.session.device_id != self.device_id:
|
||||
raise ValueError("Command belongs to another camera")
|
||||
digest = hashlib.sha256(
|
||||
json.dumps(value, sort_keys=True, allow_nan=False).encode()
|
||||
).hexdigest()
|
||||
path = self.root / (command.operation_id + ".json")
|
||||
with self.lock:
|
||||
if path.exists():
|
||||
previous = json.loads(path.read_text())
|
||||
if previous["digest"] != digest:
|
||||
raise ValueError("Operation identity is already in use")
|
||||
return previous["result"]
|
||||
if command.session.session_id != self.session_id:
|
||||
raise ValueError("Camera session changed")
|
||||
if command.deadline_at <= datetime.now(UTC):
|
||||
raise ValueError("Command deadline expired")
|
||||
params = dict(command.parameters)
|
||||
parameters(command.action_id, params)
|
||||
receipt = {"digest": digest, "result": dict(UNKNOWN)}
|
||||
# This fsync must complete before the SDK receives the command.
|
||||
# A crash between dispatch and acknowledgement remains UNKNOWN.
|
||||
atomic(path, receipt)
|
||||
try:
|
||||
result = self.camera.call(command.action_id, params)
|
||||
if result["state"] == "error":
|
||||
error = result.get("error")
|
||||
if not isinstance(error, str):
|
||||
error = ""
|
||||
result = {
|
||||
"state": "error",
|
||||
"error": error
|
||||
if error in MESSAGES.values()
|
||||
else MESSAGES.get(error, "Не удалось выполнить команду камеры."),
|
||||
}
|
||||
elif result["state"] == "unknown":
|
||||
result = dict(UNKNOWN)
|
||||
# settings.apply must verify the readback, not merely accept
|
||||
# the SDK's SetXXX acknowledgement or its old cached value.
|
||||
if command.action_id == "settings.apply" and result["state"] == "complete":
|
||||
observed = result.get("result", {})
|
||||
actual = (
|
||||
observed.get("mode")
|
||||
if params["key"] == "function_mode"
|
||||
else observed.get("values", {}).get(params["key"])
|
||||
)
|
||||
if actual != params["value"]:
|
||||
result = dict(UNKNOWN)
|
||||
receipt["result"] = result
|
||||
except (RuntimeError, ValueError, OSError, KeyError, TypeError):
|
||||
receipt["result"] = dict(UNKNOWN)
|
||||
atomic(path, receipt)
|
||||
return receipt["result"]
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Bounded image verification; packets alone never count as a working camera."""
|
||||
|
||||
import time
|
||||
|
||||
from .errors import failure
|
||||
from .operations import UNKNOWN
|
||||
|
||||
|
||||
class Control:
|
||||
def __init__(self, camera, feed=None):
|
||||
self.camera = camera
|
||||
self.feed = feed
|
||||
self.verified = False
|
||||
|
||||
def call(self, action, values):
|
||||
if action == "verify":
|
||||
return self.verify()
|
||||
if self.feed and action in ("preview.start", "preview.stop"):
|
||||
# The native adapter treats an already reached state as a no-op.
|
||||
# Preserve the same feed generation: clearing it would discard the
|
||||
# only initial keyframe even though the camera sends no new START.
|
||||
current = self.camera.call("details", {})
|
||||
target = 1 if action == "preview.start" else 0
|
||||
if (
|
||||
current.get("state") == "complete"
|
||||
and current.get("result", {}).get("connected") is True
|
||||
and current["result"].get("preview") == target
|
||||
):
|
||||
return self.camera.call(action, values)
|
||||
return self.feed.change(lambda: self.camera.call(action, values))
|
||||
return self.camera.call(action, values)
|
||||
|
||||
def verify(self, timeout=8):
|
||||
import av
|
||||
|
||||
self.verified = False
|
||||
result = self.camera.call("details", {})
|
||||
status = result.get("result", {})
|
||||
if (
|
||||
result.get("state") != "complete"
|
||||
or status.get("connected") is not True
|
||||
or status.get("recording") != 0
|
||||
or status.get("preview") not in (0, 1)
|
||||
):
|
||||
return failure("verification_recording_active")
|
||||
owned = status["preview"] == 0
|
||||
decoders, observed = {}, {}
|
||||
outcome = failure("preview_no_decodable_image")
|
||||
start = time.monotonic()
|
||||
read_video = self.feed.reader() if self.feed else self.camera.video
|
||||
try:
|
||||
if owned and self.call("preview.start", {}).get("state") != "complete":
|
||||
return dict(UNKNOWN)
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
value = read_video()
|
||||
if value is None:
|
||||
time.sleep(0.01)
|
||||
continue
|
||||
header, data = value
|
||||
index, codec, generation = (
|
||||
header["stream_index"],
|
||||
header["codec"],
|
||||
header["generation"],
|
||||
)
|
||||
name = "h264" if codec == 0 else "hevc" if codec == 1 else None
|
||||
if index not in (0, 1) or name is None:
|
||||
return dict(UNKNOWN)
|
||||
key = (index, codec, generation)
|
||||
if header.get("gap") or index not in decoders or decoders[index][0] != key:
|
||||
decoder = av.CodecContext.create(name, "r")
|
||||
decoder.thread_count = 1
|
||||
decoders[index] = (key, decoder)
|
||||
observed.pop(index, None)
|
||||
decoder = decoders[index][1]
|
||||
try:
|
||||
for packet in decoder.parse(data):
|
||||
for frame in decoder.decode(packet):
|
||||
if not (0 < frame.width <= 4096 and 0 < frame.height <= 2160):
|
||||
return dict(UNKNOWN)
|
||||
current = observed.setdefault(
|
||||
index,
|
||||
{
|
||||
"stream_index": index,
|
||||
"codec": name,
|
||||
"width": frame.width,
|
||||
"height": frame.height,
|
||||
"frames": 0,
|
||||
},
|
||||
)
|
||||
current["frames"] += 1
|
||||
except av.error.FFmpegError:
|
||||
decoders.pop(index, None)
|
||||
observed.pop(index, None)
|
||||
continue
|
||||
if any(item["frames"] >= 2 for item in observed.values()):
|
||||
outcome = {
|
||||
"state": "complete",
|
||||
"result": {
|
||||
"verified": True,
|
||||
"streams": list(observed.values()),
|
||||
"duration_ms": round((time.monotonic() - start) * 1000),
|
||||
},
|
||||
}
|
||||
break
|
||||
finally:
|
||||
if owned:
|
||||
try:
|
||||
if self.call("preview.stop", {}).get("state") != "complete":
|
||||
outcome = dict(UNKNOWN)
|
||||
except (RuntimeError, ValueError, OSError):
|
||||
outcome = dict(UNKNOWN)
|
||||
self.verified = outcome["state"] == "complete"
|
||||
return outcome
|
||||
@@ -0,0 +1,154 @@
|
||||
"""One SDK session per OS-isolated USB instance. No public network listener."""
|
||||
|
||||
import os
|
||||
import pwd
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from .frames import Feed, encode
|
||||
from .http import Binary, Server
|
||||
from .identity import read_binding
|
||||
from .lifecycle import acquisition
|
||||
from .native import NativeCamera
|
||||
from .operations import Operations
|
||||
from .verification import Control
|
||||
|
||||
|
||||
class Worker:
|
||||
def __init__(self, binding, runtime, state):
|
||||
self.binding = binding
|
||||
self.session = "x4_" + uuid.uuid4().hex
|
||||
self.opened_at = datetime.now(UTC).isoformat()
|
||||
self.status_lock = threading.Lock()
|
||||
self.status = {}
|
||||
self.observed = 0.0
|
||||
self.observed_at = self.opened_at
|
||||
self.revision = 0
|
||||
self.snapshot_signature = None
|
||||
self.failure = None
|
||||
self.ready = False
|
||||
self.done = threading.Event()
|
||||
self.camera = None
|
||||
self.control = None
|
||||
self.feed = None
|
||||
self.operations = None
|
||||
self.runtime, self.state = runtime, state
|
||||
|
||||
def connect(self):
|
||||
# Bounded startup even if a vendor call never returns. No restart loop:
|
||||
# a new attempt requires explicit preparation or physical reconnection.
|
||||
timer = threading.Timer(40, lambda: os._exit(70))
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
try:
|
||||
logs = self.state / "sdk-logs"
|
||||
logs.mkdir(mode=0o700, exist_ok=True)
|
||||
# The prepare process starts services before releasing its lock.
|
||||
# Wait for that bounded transaction without starting the SDK early.
|
||||
with acquisition(wait=True):
|
||||
self.camera = NativeCamera(
|
||||
self.binding, self.runtime / "lib/libmissioncore_x4.so", logs
|
||||
)
|
||||
self.feed = Feed(self.camera)
|
||||
self.feed.thread.start()
|
||||
self.control = Control(self.camera, self.feed)
|
||||
self.operations = Operations(
|
||||
self.binding.device_id, self.session, self.state / "operations", self.control
|
||||
)
|
||||
self.refresh()
|
||||
self.ready = True
|
||||
except (RuntimeError, OSError, ValueError):
|
||||
self.failure = "Не удалось открыть X4. Проверьте питание и режим USB на камере."
|
||||
finally:
|
||||
timer.cancel()
|
||||
|
||||
def refresh(self):
|
||||
if read_binding(self.binding.port) != self.binding:
|
||||
raise RuntimeError("Camera transport changed")
|
||||
response = self.camera.call("details", {})
|
||||
if response["state"] != "complete":
|
||||
raise RuntimeError("Camera status is unconfirmed")
|
||||
with self.status_lock:
|
||||
self.status = response["result"]
|
||||
self.observed = time.monotonic()
|
||||
self.observed_at = datetime.now(UTC).isoformat()
|
||||
self.revision += 1
|
||||
|
||||
def snapshot(self):
|
||||
with self.status_lock:
|
||||
state = dict(self.status)
|
||||
fresh = time.monotonic() - self.observed < 12
|
||||
connected = self.ready and fresh and state.get("connected") is True
|
||||
verified = bool(self.control and self.control.verified)
|
||||
signature = (self.ready, connected, fresh, verified, self.failure)
|
||||
if signature != self.snapshot_signature:
|
||||
self.revision += 1
|
||||
self.snapshot_signature = signature
|
||||
self.observed_at = datetime.now(UTC).isoformat()
|
||||
revision, observed_at = self.revision, self.observed_at
|
||||
return {
|
||||
"id": self.binding.device_id,
|
||||
"session_id": self.session,
|
||||
"opened_at": self.opened_at,
|
||||
"prepared": self.ready,
|
||||
"online": connected,
|
||||
"verified": verified,
|
||||
"revision": revision,
|
||||
"observed_at": observed_at,
|
||||
"preparation_safe": connected
|
||||
and state.get("preview") == 0
|
||||
and state.get("recording") == 0,
|
||||
"status": state if fresh else {},
|
||||
"message": self.failure,
|
||||
}
|
||||
|
||||
def dispatch(self, method, route, value, headers):
|
||||
if method == "GET" and route == "/snapshot":
|
||||
return self.snapshot()
|
||||
if method == "POST" and route == "/video":
|
||||
if (
|
||||
not self.ready
|
||||
or value.get("session_id") != self.session
|
||||
or set(value) != {"session_id", "cursor"}
|
||||
):
|
||||
raise ValueError("Camera video session changed")
|
||||
return Binary(encode(self.feed.read(value["cursor"])))
|
||||
if method == "POST" and route == "/operation":
|
||||
if not self.ready:
|
||||
raise RuntimeError("Camera is unavailable")
|
||||
with acquisition():
|
||||
result = self.operations.execute(value)
|
||||
# Poll asynchronously; a lost state read must not change a durable
|
||||
# acknowledged command result into a different receipt.
|
||||
if value.get("action_id") not in ("details", "settings.read", "files.list"):
|
||||
with self.status_lock:
|
||||
self.observed = 0
|
||||
return result
|
||||
raise ValueError("Unsupported worker route")
|
||||
|
||||
def poll(self):
|
||||
self.connect()
|
||||
while self.ready and not self.done.wait(3):
|
||||
try:
|
||||
self.refresh()
|
||||
except (RuntimeError, OSError, ValueError):
|
||||
with self.status_lock:
|
||||
self.observed = 0
|
||||
|
||||
|
||||
def main(port, runtime):
|
||||
binding = read_binding(port)
|
||||
state = Path(os.environ["STATE_DIRECTORY"])
|
||||
socket = Path(os.environ["RUNTIME_DIRECTORY"]) / "driver.sock"
|
||||
worker = Worker(binding, runtime, state)
|
||||
thread = threading.Thread(target=worker.poll, daemon=True)
|
||||
thread.start()
|
||||
if socket.exists():
|
||||
socket.unlink()
|
||||
allowed = {0, pwd.getpwnam("mission-core-insta360").pw_uid}
|
||||
with Server(socket, worker.dispatch, allowed) as server:
|
||||
socket.chmod(0o660)
|
||||
server.serve_forever(poll_interval=0.5)
|
||||
Reference in New Issue
Block a user