Add adaptive per-vehicle layouts, full-panel dragging, source controls and automatic preview recovery for RealSense, Insta360 X4 and XGRIDS K1 observation views. Integrate cached Cesium map layers through the private NAS gateway link, retain bounded sensor command receipts, and document validation and operational handoff.
169 lines
6.7 KiB
Python
169 lines
6.7 KiB
Python
"""Fleet projects Node-owned sensor state and forwards SDK operations, never hardware calls."""
|
|
|
|
import json
|
|
import time
|
|
from datetime import UTC, datetime
|
|
|
|
from missioncore_plugin_sdk.v0alpha2.operations import OperationRequest
|
|
from missioncore_plugin_sdk.v0alpha2.session import DeviceSessionSnapshot
|
|
|
|
from .trust import PairingError
|
|
|
|
ACTIONS = {
|
|
"prepare",
|
|
"details",
|
|
"rename",
|
|
"verify",
|
|
"start",
|
|
"replay",
|
|
"stop",
|
|
"option",
|
|
"offer",
|
|
"close-peer",
|
|
"preview.start",
|
|
"preview.stop",
|
|
"record.start",
|
|
"record.stop",
|
|
"photo.capture",
|
|
"settings.read",
|
|
"settings.apply",
|
|
"files.list",
|
|
"recovery.configure",
|
|
"power.wake",
|
|
}
|
|
MAX_INVENTORY_ITEMS = 500
|
|
MAX_SENSOR_STATE_BYTES = 3 * 1024 * 1024
|
|
MAX_PENDING_COMMANDS = 32
|
|
MAX_COMMAND_RECEIPTS = 128
|
|
|
|
|
|
def validate_inventory(value, node_id):
|
|
items = value.get("devices", [])
|
|
if not isinstance(items, list) or len(items) > MAX_INVENTORY_ITEMS:
|
|
raise ValueError("Invalid device inventory")
|
|
seen = set()
|
|
for item in items:
|
|
snapshot = DeviceSessionSnapshot.model_validate(item["snapshot"])
|
|
if (
|
|
snapshot.context.execution.node_id != node_id
|
|
or snapshot.context.device.device_id != item["id"]
|
|
or item["id"] in seen
|
|
):
|
|
raise ValueError("Invalid device execution binding")
|
|
seen.add(item["id"])
|
|
state = value.get("sensor_state", {"items": items, "operations": [], "preparation": None})
|
|
if (
|
|
not isinstance(state, dict)
|
|
or state.get("items") != items
|
|
or len(json.dumps(state, ensure_ascii=False).encode()) > MAX_SENSOR_STATE_BYTES
|
|
):
|
|
raise ValueError("Invalid sensor state")
|
|
return state
|
|
|
|
|
|
def submit(fleet, vehicle_id, value):
|
|
command = OperationRequest.model_validate(value)
|
|
if (
|
|
command.action_id not in ACTIONS
|
|
or command.idempotency_key != command.operation_id
|
|
or len(json.dumps(value)) > 65536
|
|
):
|
|
raise PairingError("Неподдерживаемая команда устройства.")
|
|
with fleet.lock:
|
|
row = fleet.find(vehicle_id)
|
|
if fleet.public(row)["connectivity"] != "online":
|
|
raise PairingError("БК недоступен. Дождитесь связи перед настройкой устройства.")
|
|
existing = row.setdefault("sensor_commands", {}).get(command.operation_id)
|
|
if existing:
|
|
if existing["command"] != value:
|
|
raise PairingError("Идентификатор операции уже использован.")
|
|
return existing
|
|
if (
|
|
not datetime.now(UTC) < command.deadline_at
|
|
or (command.deadline_at - command.requested_at).total_seconds() > 360
|
|
):
|
|
raise PairingError("Срок команды истёк. Обновите устройство.")
|
|
item = next(
|
|
(
|
|
item
|
|
for item in row.get("sensor_state", {}).get("items", [])
|
|
if item["id"] == command.session.device_id
|
|
),
|
|
None,
|
|
)
|
|
if item is None or item["snapshot"]["context"]["session_id"] != command.session.session_id:
|
|
raise PairingError("Сеанс устройства изменился. Обновите сведения.")
|
|
row["sensor_commands"] = {
|
|
k: v for k, v in row["sensor_commands"].items() if time.time() - v["updated_at"] < 600
|
|
}
|
|
now = datetime.now(UTC)
|
|
# Completed viewing operations are receipts, not occupied execution
|
|
# slots. Keep them for idempotency without blocking a later Stop.
|
|
active = sum(
|
|
entry["state"] in ("queued", "running")
|
|
and datetime.fromisoformat(entry["command"]["deadline_at"]) > now
|
|
for entry in row["sensor_commands"].values()
|
|
)
|
|
if active >= MAX_PENDING_COMMANDS:
|
|
raise PairingError("Слишком много запросов к устройству. Повторите позже.")
|
|
if len(row["sensor_commands"]) >= MAX_COMMAND_RECEIPTS:
|
|
expired = sorted(
|
|
(k for k, entry in row["sensor_commands"].items()
|
|
if entry["state"] not in ("queued", "running")
|
|
and datetime.fromisoformat(entry["command"]["deadline_at"]) < now),
|
|
key=lambda k: row["sensor_commands"][k]["updated_at"],
|
|
)
|
|
for key in expired:
|
|
del row["sensor_commands"][key]
|
|
if len(row["sensor_commands"]) < MAX_COMMAND_RECEIPTS:
|
|
break
|
|
if len(row["sensor_commands"]) >= MAX_COMMAND_RECEIPTS:
|
|
raise PairingError("Слишком много запросов к устройству. Повторите позже.")
|
|
operation = {"command": value, "state": "queued", "updated_at": time.time()}
|
|
row["sensor_commands"][command.operation_id] = operation
|
|
fleet.save(row)
|
|
return operation
|
|
|
|
|
|
def operation(fleet, vehicle_id, identifier):
|
|
with fleet.lock:
|
|
value = fleet.find(vehicle_id).get("sensor_commands", {}).get(identifier)
|
|
if value is None:
|
|
raise PairingError("Операция не найдена.")
|
|
if value["state"] in ("queued", "running") and datetime.fromisoformat(
|
|
value["command"]["deadline_at"]
|
|
) < datetime.now(UTC):
|
|
return {
|
|
**value,
|
|
"state": "unknown",
|
|
"error": "Подтверждение не получено. Обновите фактическое состояние устройства.",
|
|
}
|
|
return value
|
|
|
|
|
|
def heartbeat(row, value):
|
|
commands = row.setdefault("sensor_commands", {})
|
|
acknowledgements = []
|
|
results = value.get("sensor_results", [])
|
|
if not isinstance(results, list) or len(results) > 64:
|
|
raise ValueError("Invalid sensor operations")
|
|
for result in results:
|
|
command = result.get("command", {})
|
|
identifier = command.get("operation_id")
|
|
existing = commands.get(identifier)
|
|
if (
|
|
existing
|
|
and existing["command"] == command
|
|
and result.get("state") in ("running", "complete", "error", "unknown")
|
|
):
|
|
commands[identifier] = {**result, "updated_at": time.time()}
|
|
if result["state"] != "running":
|
|
acknowledgements.append(identifier)
|
|
pending = []
|
|
for entry in commands.values():
|
|
if entry["state"] == "queued" and datetime.fromisoformat(
|
|
entry["command"]["deadline_at"]
|
|
) > datetime.now(UTC):
|
|
pending.append(entry["command"])
|
|
return {"sensor_commands": pending[:4], "sensor_acknowledgements": acknowledgements}
|