194 lines
7.1 KiB
Python
194 lines
7.1 KiB
Python
"""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 == "power.wake":
|
|
if not isinstance(value, dict) or value:
|
|
raise ValueError("Wake uses the enrolled camera identity only")
|
|
return 0, "", 0.0
|
|
if action == "recovery.configure":
|
|
if (
|
|
not isinstance(value, dict)
|
|
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)
|
|
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
|