"""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}