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,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
|
||||
Reference in New Issue
Block a user