Files
NODEDC_MISSION_CORE/src/k1link/fleet/sensors.py
T
DCCONSTRUCTIONS be58d589e2 feat(fleet): add board observation center with live sources and maps
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.
2026-09-10 22:12:05 +03:00

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}