194 lines
8.3 KiB
Python
194 lines
8.3 KiB
Python
"""Private Unix plugin host; the Go broker is the only UI/Core authority."""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
|
|
import pyrealsense2 as rs
|
|
from aiohttp import web
|
|
from device import Device, atomic, device_id
|
|
from media import Peers
|
|
from missioncore_plugin_sdk.v0alpha2.operations import OperationRequest
|
|
|
|
ROOT = Path("/var/lib/mission-core-sensors")
|
|
SOCKET = Path("/run/mission-core-sensors/driver.sock")
|
|
|
|
|
|
class Host:
|
|
def __init__(self, root=ROOT):
|
|
self.root = root
|
|
self.context = rs.context()
|
|
self.devices = {}
|
|
self.execution = None
|
|
self.peers = Peers()
|
|
self.scan_lock = asyncio.Lock()
|
|
self.operation_locks = {}
|
|
|
|
async def scan(self):
|
|
if self.execution is None:
|
|
return
|
|
async with self.scan_lock:
|
|
|
|
def work():
|
|
found = set()
|
|
for dev in self.context.query_devices():
|
|
if dev.is_playback():
|
|
continue
|
|
if dev.get_info(rs.camera_info.product_id).lower() != "0b5c":
|
|
continue
|
|
serial = dev.get_info(rs.camera_info.serial_number)
|
|
# SDK module serial and USB serial are distinct on D455.
|
|
# Resolve the actual transport ancestor, not list order or model count.
|
|
physical = Path(dev.get_info(rs.camera_info.physical_port)).resolve()
|
|
usb_serial = None
|
|
for parent in (physical, *physical.parents):
|
|
if (
|
|
(parent / "idVendor").exists()
|
|
and (parent / "idProduct").exists()
|
|
and (parent / "idVendor").read_text().strip() == "8086"
|
|
and (parent / "idProduct").read_text().strip() == "0b5c"
|
|
):
|
|
usb_serial = (parent / "serial").read_text().strip()
|
|
break
|
|
if not usb_serial:
|
|
continue
|
|
ident = device_id(usb_serial)
|
|
found.add(ident)
|
|
if ident not in self.devices:
|
|
self.devices[ident] = Device(serial, self.root, self.execution, usb_serial)
|
|
self.devices[ident].refresh(dev)
|
|
for ident, device in self.devices.items():
|
|
if ident not in found:
|
|
device.disconnect()
|
|
|
|
await asyncio.to_thread(work)
|
|
|
|
async def inventory(self, request):
|
|
node_id = request.headers.get("X-Node-Id", "")
|
|
if not re.fullmatch(r"node_[0-9a-f]{64}", node_id):
|
|
raise web.HTTPForbidden()
|
|
if self.execution is None:
|
|
self.execution = {
|
|
"node_id": node_id,
|
|
"agent_instance_id": "driver_" + uuid.uuid4().hex,
|
|
"platform": "linux",
|
|
}
|
|
if self.execution["node_id"] != node_id:
|
|
raise web.HTTPForbidden()
|
|
await self.scan()
|
|
return web.json_response(
|
|
{"items": [device.snapshot(False) for device in self.devices.values()]}
|
|
)
|
|
|
|
async def operation(self, request):
|
|
value = await request.json()
|
|
command = OperationRequest.model_validate(value)
|
|
identifier = command.operation_id
|
|
if not re.fullmatch(r"op_[0-9a-f]{32}", identifier):
|
|
raise ValueError("Некорректный идентификатор операции.")
|
|
path = self.root / (identifier + ".json")
|
|
digest = hashlib.sha256(json.dumps(value, sort_keys=True).encode()).hexdigest()
|
|
lock = self.operation_locks.setdefault(identifier, asyncio.Lock())
|
|
async with lock:
|
|
if path.exists():
|
|
previous = json.loads(path.read_text())
|
|
if previous["digest"] != digest:
|
|
raise ValueError("Идентификатор операции уже использован.")
|
|
return web.json_response(previous["result"])
|
|
if command.deadline_at <= datetime.now(UTC):
|
|
raise ValueError("Срок команды истёк. Состояние камеры не изменено.")
|
|
device = self.devices.get(command.session.device_id)
|
|
if device is None or command.session.session_id != device.session:
|
|
raise ValueError("Сеанс камеры изменился. Обновите сведения.")
|
|
# Persist uncertainty before any side effect. A crash must not replay START.
|
|
receipt = {
|
|
"digest": digest,
|
|
"result": {
|
|
"state": "unknown",
|
|
"error": "Результат операции пока неизвестен. Обновите состояние устройства.",
|
|
},
|
|
}
|
|
atomic(path, receipt)
|
|
try:
|
|
action, params = command.action_id, dict(command.parameters)
|
|
if action == "details":
|
|
result = device.snapshot()
|
|
elif action == "rename":
|
|
result = device.rename(params.get("name"))
|
|
elif action == "verify":
|
|
result = await asyncio.to_thread(device.verify)
|
|
elif action == "start":
|
|
if type(params.get("record", False)) is not bool:
|
|
raise ValueError("Некорректный режим записи.")
|
|
await asyncio.to_thread(
|
|
device.start, params.get("profiles"), params.get("record", False)
|
|
)
|
|
result = {"ok": True}
|
|
elif action == "replay":
|
|
result = await asyncio.to_thread(device.replay, params.get("recording_id"))
|
|
elif action == "stop":
|
|
result = await asyncio.to_thread(device.stop)
|
|
elif action == "option":
|
|
result = await asyncio.to_thread(
|
|
device.set_option, params.get("id"), params.get("value")
|
|
)
|
|
elif action == "offer":
|
|
result = await self.peers.offer(device, params)
|
|
elif action == "close-peer":
|
|
result = await self.peers.close(params.get("peer_id"))
|
|
else:
|
|
raise ValueError("Неподдерживаемая операция устройства.")
|
|
receipt["result"] = {"state": "complete", "result": result}
|
|
except (ValueError, RuntimeError) as error:
|
|
receipt["result"] = {"state": "error", "error": str(error)[:400]}
|
|
atomic(path, receipt)
|
|
self.operation_locks.pop(identifier, None)
|
|
return web.json_response(receipt["result"])
|
|
|
|
async def prepare_safe(self, request):
|
|
return web.json_response(
|
|
{
|
|
"safe": all(
|
|
d.pipeline is None
|
|
and d.acquisition not in ("preparing", "starting", "stopping")
|
|
for d in self.devices.values()
|
|
)
|
|
}
|
|
)
|
|
|
|
async def cleanup(self, app):
|
|
for peer in list(self.peers.items):
|
|
await self.peers.close(peer)
|
|
for device in self.devices.values():
|
|
await asyncio.to_thread(device.stop, True)
|
|
|
|
|
|
@web.middleware
|
|
async def errors(request, handler):
|
|
try:
|
|
return await handler(request)
|
|
except (ValueError, KeyError, TypeError):
|
|
return web.json_response({"error": "Некорректная команда устройства."}, status=400)
|
|
except RuntimeError:
|
|
return web.json_response(
|
|
{"error": "Драйвер не смог выполнить запрос. Проверьте подключение камеры."}, status=409
|
|
)
|
|
|
|
|
|
def main():
|
|
os.umask(0o007)
|
|
host = Host()
|
|
app = web.Application(client_max_size=65536, middlewares=[errors])
|
|
app.router.add_get("/inventory", host.inventory)
|
|
app.router.add_get("/prepare-safe", host.prepare_safe)
|
|
app.router.add_post("/operation", host.operation)
|
|
app.on_cleanup.append(host.cleanup)
|
|
if SOCKET.exists():
|
|
SOCKET.unlink()
|
|
web.run_app(app, path=str(SOCKET), print=None, access_log=None, shutdown_timeout=20)
|