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.
106 lines
4.4 KiB
Python
106 lines
4.4 KiB
Python
"""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"]
|