feat(x4): add per-camera manual wake and hide power action when connected

This commit is contained in:
DCCONSTRUCTIONS
2026-09-10 16:27:48 +03:00
parent db14ee5435
commit 99f29218d7
27 changed files with 992 additions and 126 deletions
+1 -1
View File
@@ -223,7 +223,7 @@ class Broker:
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:
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:
+8
View File
@@ -1,6 +1,14 @@
"""Public messages are allowlisted; vendor error text never crosses the boundary."""
MESSAGES = {
"wake_adapter_busy": "Сейчас включается другая камера. Повторите действие позже.",
"wake_identity_missing": "Сначала подключите эту камеру по USB и подготовьте её на борту.",
"wake_identity_conflict": "Не удалось однозначно выбрать камеру для включения.",
"wake_rate_limited": "Лимит попыток включения исчерпан. Повторите через 10 минут.",
"wake_not_confirmed": (
"Камера не подключилась. Проверьте питание, USB "
"и настройку «Пробуждение по Bluetooth» на X4."
),
"unsupported_camera_parameter": "Параметр или действие недоступны в текущем режиме камеры.",
"verification_recording_active": "Для проверки изображения остановите запись на камере.",
"preview_no_decodable_image": "Камера не передала декодируемое изображение.",
+4
View File
@@ -35,6 +35,10 @@ class VideoHeader(ctypes.Structure):
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)
+50
View File
@@ -26,6 +26,7 @@ class Recovery:
self.absent = {}
self.stable = {}
self.jobs = {}
self.wakeable = set()
self.stopping = threading.Event()
self.pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="x4-recovery")
for path in sorted(root.glob("instax4_*.json"))[:500]:
@@ -62,8 +63,50 @@ class Recovery:
"enabled": state.get("enabled", False),
"preview_wanted": state.get("preview_wanted", False),
"phase": state.get("phase", "disabled"),
"wake_available": ident in self.wakeable,
}
def sync_registry(self):
values = request(HELPER, "/registry", {}, timeout=4)["items"]
if not isinstance(values, list) or len(values) > 500:
raise ValueError("Invalid wake registry")
for ident in values:
self.broker.validate_identifier(ident)
with self.lock:
self.wakeable = set(values)
for ident in values:
if ident not in self.states:
self.states[ident] = self.default()
self.save(ident, self.states[ident])
def wake_once(self, ident, command):
# Explicit operator action. Keep the automatic policy and preview intent intact.
deadline = time.monotonic() + 52
result = request(
HELPER,
"/wake-once",
{"device_id": ident, "attempt": command["operation_id"][3:]},
timeout=52,
)
state = result.get("state")
# A cold X4 can enumerate after the 20-second beacon has already ended.
# Observe the installed supervisor only; never send a second wake beacon.
if state == "unavailable":
while time.monotonic() < deadline and not self.stopping.is_set():
if any(x["id"] == ident and x.get("online") for x in self.broker.snapshots()):
state = "connected"
break
self.stopping.wait(1)
if state == "connected":
return {"state": "complete", "result": {"usb_connected": True}}
errors = {
"busy": "wake_adapter_busy",
"disabled": "wake_identity_missing",
"identity_conflict": "wake_identity_conflict",
"exhausted": "wake_rate_limited",
}
return {"state": "error", "error": errors.get(state, "wake_not_confirmed")}
def snapshot(self, ident):
with self.lock:
current = self.current.get(ident)
@@ -246,6 +289,11 @@ class Recovery:
while not self.stopping.wait(2):
try:
snapshots = self.broker.snapshots()
try:
self.sync_registry()
except (OSError, ValueError, RuntimeError, KeyError, TypeError):
with self.lock:
self.wakeable = set()
with self.lock:
for ident, future in list(self.jobs.items()):
if future.done():
@@ -270,6 +318,8 @@ class RecoveryCalls:
self.recovery, self.ident, self.command = recovery, ident, command
def call(self, action, params):
if action == "power.wake":
return self.recovery.wake_once(self.ident, self.command)
if action == "recovery.configure":
return self.recovery.configure(self.ident, params["enabled"])
if action == "preview.stop":