From b1aaa405080bf7c11454ab50589d3b223aca81d7 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Sat, 5 Sep 2026 22:27:07 +0300 Subject: [PATCH] Add D455 sensor host and shared Node/Core preparation surface --- .../src/workspaces/fleet/VehicleSensors.tsx | 12 + .../workspaces/fleet/VehiclesWorkspace.tsx | 3 +- apps/node-agent/cmd/node-agent/main.go | 6 + apps/node-agent/internal/node/pairing.go | 1 + .../internal/node/pairing_transport.go | 20 +- apps/node-agent/internal/node/sensors.go | 422 +++++++++++++++ apps/node-agent/internal/node/sensors_test.go | 10 + apps/node-agent/internal/node/server.go | 4 + .../50-mission-core-device-prepare.rules | 6 + .../packaging/70-mission-core-realsense.rules | 4 + apps/node-agent/packaging/build_deb.py | 22 +- .../packaging/fetch_driver_bundle.py | 30 ++ ...ission-core-node-realsense-prepare.service | 8 + .../packaging/mission-core-realsense.service | 31 ++ apps/node-agent/packaging/postinst | 3 + apps/node-agent/packaging/preinst | 5 + apps/node-agent/packaging/prerm | 7 + .../packaging/realsense-bundle.json | 154 ++++++ .../node-agent/packaging/realsense_prepare.py | 194 +++++++ .../packaging/test_realsense_prepare.py | 25 + apps/node-agent/sensors/bootstrap.py | 13 + apps/node-agent/sensors/device.py | 503 ++++++++++++++++++ apps/node-agent/sensors/media.py | 126 +++++ apps/node-agent/sensors/server.py | 161 ++++++ apps/node-agent/ui/src/NodeSensors.tsx | 5 + apps/node-agent/ui/src/main.tsx | 2 + apps/node-agent/ui/src/nodeModel.ts | 5 +- apps/node-agent/ui/tsconfig.json | 31 +- apps/node-agent/ui/vite.config.js | 2 +- .../node/05_SENSOR_HOST_AND_SHARED_CONTROL.md | 79 +++ packages/sensor-ui/src/LiveViewport.tsx | 37 ++ packages/sensor-ui/src/PointViewport.tsx | 23 + packages/sensor-ui/src/SensorDetail.tsx | 26 + packages/sensor-ui/src/SensorWorkspace.tsx | 24 + packages/sensor-ui/src/contracts.ts | 40 ++ packages/sensor-ui/src/sensors.css | 10 + src/k1link/fleet/registry.py | 17 +- src/k1link/fleet/sensors.py | 128 +++++ src/k1link/fleet/transport.py | 2 +- src/k1link/web/fleet_api.py | 24 + tests/fleet/test_pairing.py | 87 +++ 41 files changed, 2293 insertions(+), 19 deletions(-) create mode 100644 apps/control-station/src/workspaces/fleet/VehicleSensors.tsx create mode 100644 apps/node-agent/internal/node/sensors.go create mode 100644 apps/node-agent/internal/node/sensors_test.go create mode 100644 apps/node-agent/packaging/50-mission-core-device-prepare.rules create mode 100644 apps/node-agent/packaging/70-mission-core-realsense.rules create mode 100644 apps/node-agent/packaging/fetch_driver_bundle.py create mode 100644 apps/node-agent/packaging/mission-core-node-realsense-prepare.service create mode 100644 apps/node-agent/packaging/mission-core-realsense.service create mode 100644 apps/node-agent/packaging/realsense-bundle.json create mode 100644 apps/node-agent/packaging/realsense_prepare.py create mode 100644 apps/node-agent/packaging/test_realsense_prepare.py create mode 100644 apps/node-agent/sensors/bootstrap.py create mode 100644 apps/node-agent/sensors/device.py create mode 100644 apps/node-agent/sensors/media.py create mode 100644 apps/node-agent/sensors/server.py create mode 100644 apps/node-agent/ui/src/NodeSensors.tsx create mode 100644 docs/node/05_SENSOR_HOST_AND_SHARED_CONTROL.md create mode 100644 packages/sensor-ui/src/LiveViewport.tsx create mode 100644 packages/sensor-ui/src/PointViewport.tsx create mode 100644 packages/sensor-ui/src/SensorDetail.tsx create mode 100644 packages/sensor-ui/src/SensorWorkspace.tsx create mode 100644 packages/sensor-ui/src/contracts.ts create mode 100644 packages/sensor-ui/src/sensors.css create mode 100644 src/k1link/fleet/sensors.py diff --git a/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx b/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx new file mode 100644 index 0000000..a04020d --- /dev/null +++ b/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx @@ -0,0 +1,12 @@ +import {useMemo} from 'react'; +import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace'; +import type {SensorInventory,SensorTransport} from '../../../../../packages/sensor-ui/src/contracts'; +import {fleetRequest} from '../../core/fleet/useFleet'; +export function VehicleSensors({vehicleID,enabled}:{vehicleID:string;enabled:boolean}){ + const transport=useMemo(()=>({ + inventory:async()=>{const fleet=await fleetRequest<{items:{id:string;sensor_state:SensorInventory}[]}>();const value=fleet.items.find(v=>v.id===vehicleID);if(!value)throw new Error('Аппарат не найден.');return value.sensor_state;}, + submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value), + operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`), + }),[vehicleID]); + return ; +} diff --git a/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx b/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx index 2b028ac..d37b605 100644 --- a/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx +++ b/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { ActivityIndicator, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react"; import { fleetRequest, useFleet, type FleetPreview, type Vehicle } from "../../core/fleet/useFleet"; import "./fleet.css"; +import { VehicleSensors } from "./VehicleSensors"; const platforms = [{ value: "ugv", label: "Наземный (UGV)" }, { value: "uav", label: "Воздушный (UAV)" }, { value: "stationary", label: "Стационарный" }, { value: "other", label: "Другой" }]; const platformLabel = (value: string) => platforms.find(item => item.value === value)?.label ?? value; @@ -51,7 +52,7 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe {detail.enrollment !== "revoked" && } -

Устройства к аппарату ещё не подключены.

+ : !fleet.items ? : fleet.items.length === 0 ? : {fleet.items.map(item =>
  • } title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={{fleet.error ? "Нет свежих данных" : statusLabel(item)}} actions={ setSelected(item.id)}>} />
  • )}
    } {preview ? : }}>
    diff --git a/apps/node-agent/cmd/node-agent/main.go b/apps/node-agent/cmd/node-agent/main.go index a078276..14baa38 100644 --- a/apps/node-agent/cmd/node-agent/main.go +++ b/apps/node-agent/cmd/node-agent/main.go @@ -74,6 +74,12 @@ func run() error { return err } app.Pairing = pairing + nodeID, _ := store.Public() + app.Sensors, err = node.OpenSensors(*dir, nodeID) + if err != nil { + return err + } + pairing.Sensors = app.Sensors app.Access = &node.AccessStore{Path: filepath.Join(*dir, "ssh-keys.json"), Users: func() []string { return node.LocalAdmins("/") }} if err := os.MkdirAll(filepath.Dir(*socket), 0700); err != nil { return err diff --git a/apps/node-agent/internal/node/pairing.go b/apps/node-agent/internal/node/pairing.go index 8918412..d188b74 100644 --- a/apps/node-agent/internal/node/pairing.go +++ b/apps/node-agent/internal/node/pairing.go @@ -39,6 +39,7 @@ type PairState struct { Revocations []CoreBinding `json:"revocations,omitempty"` } type Pairing struct { + Sensors *Sensors mu sync.Mutex path string store *Store diff --git a/apps/node-agent/internal/node/pairing_transport.go b/apps/node-agent/internal/node/pairing_transport.go index 717abaf..4f0fffd 100644 --- a/apps/node-agent/internal/node/pairing_transport.go +++ b/apps/node-agent/internal/node/pairing_transport.go @@ -231,7 +231,7 @@ func (p *Pairing) send(ctx context.Context, b CoreBinding, path string, payload } defer response.Body.Close() var out map[string]json.RawMessage - if json.NewDecoder(io.LimitReader(response.Body, 16384)).Decode(&out) != nil { + if json.NewDecoder(io.LimitReader(response.Body, 1048576)).Decode(&out) != nil { return nil, response.StatusCode, errors.New("invalid Core response") } return out, response.StatusCode, nil @@ -270,12 +270,30 @@ func (p *Pairing) channel(ctx context.Context) { if binding != nil { id, name := p.store.Public() payload := map[string]any{"schema": PairSchema, "binding_id": binding.BindingID, "node_id": id, "name": name, "version": p.version, "execution_binding": map[string]string{"node_id": id, "agent_instance_id": instance, "platform": "linux"}, "host": p.inventory(), "devices": []any{}} + if p.Sensors != nil { + inv := p.Sensors.Inventory() + payload["devices"] = inv["items"] + payload["sensor_state"] = inv + payload["sensor_results"] = p.Sensors.RemoteResults() + } result, status, e := p.send(ctx, *binding, "/v1/node/heartbeat", payload) p.mu.Lock() if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID { if e == nil && status == 200 { p.connection = "online" p.lastSeen = p.now().Unix() + if p.Sensors != nil { + var ack []string + if json.Unmarshal(result["sensor_acknowledgements"], &ack) == nil { + p.Sensors.Acknowledge(ack) + } + var commands []SensorCommand + if json.Unmarshal(result["sensor_commands"], &commands) == nil { + for _, c := range commands { + _, _ = p.Sensors.Submit(c, true) + } + } + } var cert string if json.Unmarshal(result["client_pem"], &cert) == nil && cert != "" && cert != binding.ClientPEM { next := *binding diff --git a/apps/node-agent/internal/node/sensors.go b/apps/node-agent/internal/node/sensors.go new file mode 100644 index 0000000..ef1c6e2 --- /dev/null +++ b/apps/node-agent/internal/node/sensors.go @@ -0,0 +1,422 @@ +package node + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "time" +) + +const SensorSchema = "missioncore.nodedc/plugin-sdk/v0alpha2" + +type SensorSession struct { + SessionID string `json:"session_id"` + DeviceID string `json:"device_id"` +} +type SensorCommand struct { + APIVersion string `json:"api_version"` + Kind string `json:"kind"` + ID string `json:"operation_id"` + Session SensorSession `json:"session"` + Action string `json:"action_id"` + Requested string `json:"requested_at"` + Deadline string `json:"deadline_at"` + Idempotency string `json:"idempotency_key"` + Parameters map[string]any `json:"parameters"` +} +type SensorOperation struct { + Command SensorCommand `json:"command"` + State string `json:"state"` + Error string `json:"error,omitempty"` + Result any `json:"result,omitempty"` + Remote bool `json:"remote,omitempty"` + Updated int64 `json:"updated_at"` +} +type Sensors struct { + mu sync.Mutex + prepareMu sync.Mutex + root string + nodeID string + instance string + client *http.Client + operations map[string]*SensorOperation + names map[string]string +} + +var sensorID = regexp.MustCompile(`^rsd455_[0-9a-f]{32}$`) +var operationID = regexp.MustCompile(`^op_[0-9a-f]{32}$`) + +func OpenSensors(root, nodeID string) (*Sensors, error) { + dir := filepath.Join(root, "sensors") + if e := os.MkdirAll(dir, 0700); e != nil { + return nil, e + } + s := &Sensors{root: dir, nodeID: nodeID, instance: "discovery_" + digest(token())[:24], operations: map[string]*SensorOperation{}, names: map[string]string{}} + s.client = &http.Client{Timeout: 25 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", "/run/mission-core-sensors/driver.sock") + }}} + files, _ := filepath.Glob(filepath.Join(dir, "op_*.json")) + for _, p := range files { + data, e := os.ReadFile(p) + if e != nil { + return nil, e + } + var v SensorOperation + if json.Unmarshal(data, &v) != nil { + return nil, errors.New("invalid sensor operation journal") + } + if v.State == "running" { + v.State = "unknown" + v.Error = "Результат операции неизвестен после перезапуска. Проверьте состояние устройства." + } + s.operations[v.Command.ID] = &v + } + data, _ := os.ReadFile(filepath.Join(dir, "names.json")) + _ = json.Unmarshal(data, &s.names) + return s, nil +} +func (s *Sensors) write(name string, value any) error { + data, e := json.Marshal(value) + if e != nil { + return e + } + f, e := os.CreateTemp(s.root, ".sensor-") + if e != nil { + return e + } + defer os.Remove(f.Name()) + if _, e = f.Write(data); e != nil { + f.Close() + return e + } + if e = f.Sync(); e != nil { + f.Close() + return e + } + f.Close() + if e = os.Rename(f.Name(), filepath.Join(s.root, name)); e != nil { + return e + } + d, e := os.Open(s.root) + if e != nil { + return e + } + defer d.Close() + return d.Sync() +} +func (s *Sensors) driver(path string, body any) (map[string]any, error) { + method := "GET" + var reader io.Reader + if body != nil { + method = "POST" + data, e := json.Marshal(body) + if e != nil { + return nil, e + } + reader = bytes.NewReader(data) + } + req, e := http.NewRequest(method, "http://driver"+path, reader) + if e != nil { + return nil, e + } + req.Header.Set("X-Node-Id", s.nodeID) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + response, e := s.client.Do(req) + if e != nil { + return nil, errors.New("Служба камеры недоступна. Подготовьте устройство.") + } + defer response.Body.Close() + var result map[string]any + if json.NewDecoder(io.LimitReader(response.Body, 1024*1024)).Decode(&result) != nil { + return nil, errors.New("Не удалось прочитать результат драйвера.") + } + if response.StatusCode != 200 { + message, _ := result["error"].(string) + return nil, errors.New(message) + } + return result, nil +} +func (s *Sensors) Inventory() map[string]any { + items := []any{} + seen := map[string]bool{} + if result, e := s.driver("/inventory", nil); e == nil { + if found, ok := result["items"].([]any); ok { + for _, v := range found { + item, ok := v.(map[string]any) + if !ok { + continue + } + id, _ := item["id"].(string) + seen[id] = true + s.mu.Lock() + if n := s.names[id]; n != "" { + item["name"] = n + } + s.mu.Unlock() + items = append(items, item) + } + } + } + paths, _ := filepath.Glob("/sys/bus/usb/devices/*") + for _, path := range paths { + read := func(n string) string { + b, _ := os.ReadFile(filepath.Join(path, n)) + return strings.TrimSpace(string(b)) + } + if read("idVendor") != "8086" || read("idProduct") != "0b5c" { + continue + } + serial := read("serial") + if serial == "" { + continue + } + h := sha256.Sum256([]byte(serial)) + id := "rsd455_" + hex.EncodeToString(h[:])[:32] + if seen[id] { + continue + } + now := time.Now().UTC().Format(time.RFC3339Nano) + name := "RealSense D455" + s.mu.Lock() + if n := s.names[id]; n != "" { + name = n + } + s.mu.Unlock() + items = append(items, map[string]any{"id": id, "name": name, "model": "RealSense D455", "prepared": false, "verified": false, "online": true, "usb": read("speed") + " Мбит/с", "layers": []any{}, "snapshot": map[string]any{"context": map[string]any{"session_id": s.instance + "_" + id, "device": map[string]any{"device_id": id, "model": map[string]string{"plugin_id": "missioncore.realsense", "plugin_version": "0.6.0", "model_id": "realsense.d455"}, "stability": "stable", "basis": "hardware-identifier"}, "execution": map[string]string{"node_id": s.nodeID, "agent_instance_id": s.instance, "platform": "linux"}, "opened_at": now}, "revision": 0, "enrollment": "empty", "connectivity": "connected", "acquisition": "idle", "observed_at": now}}) + } + var preparation any + if data, e := os.ReadFile("/var/lib/mission-core-node-drivers/preparation.json"); e == nil && len(data) < 32768 { + _ = json.Unmarshal(data, &preparation) + } + s.mu.Lock() + operations := []any{} + for _, v := range s.operations { + if time.Now().Unix()-v.Updated < 600 { + operations = append(operations, map[string]any{"operation_id": v.Command.ID, "device_id": v.Command.Session.DeviceID, "action_id": v.Command.Action, "state": v.State, "error": v.Error}) + } + } + s.mu.Unlock() + return map[string]any{"schema": "missioncore.node.devices/v1", "items": items, "preparation": preparation, "operations": operations} +} +func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) { + if c.APIVersion != SensorSchema || c.Kind != "OperationRequest" || !operationID.MatchString(c.ID) || c.Idempotency != c.ID || !sensorID.MatchString(c.Session.DeviceID) || len(c.Session.SessionID) > 192 { + return nil, errors.New("Некорректная команда устройства.") + } + if !map[string]bool{"prepare": true, "details": true, "rename": true, "verify": true, "start": true, "stop": true, "option": true, "offer": true, "close-peer": true}[c.Action] { + return nil, errors.New("Операция не поддерживается.") + } + deadline, e := time.Parse(time.RFC3339Nano, c.Deadline) + requested, e2 := time.Parse(time.RFC3339Nano, c.Requested) + if e != nil || e2 != nil || !deadline.After(requested) || deadline.Sub(requested) > 6*time.Minute { + return nil, errors.New("Некорректный срок команды.") + } + s.mu.Lock() + defer s.mu.Unlock() + if old := s.operations[c.ID]; old != nil { + a, _ := json.Marshal(old.Command) + b, _ := json.Marshal(c) + if !bytes.Equal(a, b) { + return nil, errors.New("Идентификатор операции уже использован.") + } + copy := *old + return ©, nil + } + if !deadline.After(time.Now()) { + return nil, errors.New("Срок команды истёк. Устройство не изменено.") + } + for _, v := range s.operations { + if v.State == "running" && v.Command.Session.DeviceID == c.Session.DeviceID { + return nil, errors.New("Другая операция устройства ещё выполняется.") + } + } + if len(s.operations) > 2000 { + for id, v := range s.operations { + if v.State != "running" && time.Now().Unix()-v.Updated > 86400 { + delete(s.operations, id) + os.Remove(filepath.Join(s.root, id+".json")) + } + } + } + if len(s.operations) > 2000 { + return nil, errors.New("Журнал операций заполнен. Повторите позже.") + } + value := &SensorOperation{Command: c, State: "running", Remote: remote, Updated: time.Now().Unix()} + if e = s.write(c.ID+".json", value); e != nil { + return nil, e + } + s.operations[c.ID] = value + copy := *value + go s.execute(c) + return ©, nil +} +func (s *Sensors) execute(c SensorCommand) { + var result any + var err error + inv := s.Inventory() + var item map[string]any + for _, v := range inv["items"].([]any) { + i := v.(map[string]any) + if i["id"] == c.Session.DeviceID { + item = i + break + } + } + if item == nil { + err = errors.New("Камера не обнаружена. Проверьте подключение.") + } else if c.Action == "prepare" { + result, err = s.prepare(c) + } else if c.Action == "rename" { + name, ok := c.Parameters["name"].(string) + if !ok || strings.TrimSpace(name) == "" || len([]rune(name)) > 80 || strings.ContainsAny(name, "\n\r\t") { + err = errors.New("Введите название до 80 символов.") + } else { + s.mu.Lock() + s.names[c.Session.DeviceID] = strings.TrimSpace(name) + err = s.write("names.json", s.names) + s.mu.Unlock() + result = map[string]bool{"ok": err == nil} + } + } else { + var v map[string]any + v, err = s.driver("/operation", c) + if err == nil { + if v["state"] == "complete" { + result = v["result"] + } else { + message, _ := v["error"].(string) + err = errors.New(message) + } + } + } + s.mu.Lock() + defer s.mu.Unlock() + v := s.operations[c.ID] + v.Updated = time.Now().Unix() + if err != nil { + v.State = "error" + v.Error = err.Error() + } else { + v.State = "complete" + v.Result = result + } + if s.write(c.ID+".json", v) != nil { + v.State = "unknown" + v.Error = "Не удалось сохранить результат операции. Обновите состояние устройства." + } +} +func (s *Sensors) prepare(c SensorCommand) (any, error) { + s.prepareMu.Lock() + defer s.prepareMu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), 310*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "/usr/bin/systemctl", "start", "mission-core-node-realsense-prepare.service") + cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C", "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket"} + if cmd.Run() != nil { + return nil, errors.New("Подготовка драйвера не завершена. Проверьте этапы и повторите действие.") + } + for i := 0; i < 12; i++ { + inv := s.Inventory() + for _, v := range inv["items"].([]any) { + item := v.(map[string]any) + if item["id"] == c.Session.DeviceID && item["prepared"] == true { + snap := item["snapshot"].(map[string]any) + sc := snap["context"].(map[string]any) + verify := c + verify.Action = "verify" + verify.Session.SessionID = sc["session_id"].(string) + result, e := s.driver("/operation", verify) + if e != nil { + return nil, e + } + if result["state"] != "complete" { + message, _ := result["error"].(string) + return nil, errors.New(message) + } + return result["result"], nil + } + } + time.Sleep(time.Second) + } + return nil, errors.New("Драйвер установлен, но камера не открылась. Проверьте USB-подключение.") +} +func (s *Sensors) Get(id string) *SensorOperation { + s.mu.Lock() + defer s.mu.Unlock() + if v := s.operations[id]; v != nil { + copy := *v + return © + } + return nil +} +func (s *Sensors) RemoteResults() []any { + s.mu.Lock() + defer s.mu.Unlock() + out := []any{} + for _, v := range s.operations { + if v.Remote && time.Now().Unix()-v.Updated < 600 { + copy := *v + out = append(out, copy) + } + } + return out +} +func (s *Sensors) Routes(mux *http.ServeMux, server *Server) { + mux.HandleFunc("GET /api/devices", func(w http.ResponseWriter, r *http.Request) { + if server.authorized(w, r) { + reply(w, 200, s.Inventory()) + } + }) + mux.HandleFunc("POST /api/devices/operations", func(w http.ResponseWriter, r *http.Request) { + if !server.authorized(w, r) { + return + } + var c SensorCommand + r.Body = http.MaxBytesReader(w, r.Body, 65536) + if r.Header.Get("Content-Type") != "application/json" || json.NewDecoder(r.Body).Decode(&c) != nil { + reply(w, 400, map[string]string{"error": "Некорректная команда"}) + return + } + v, e := s.Submit(c, false) + if e != nil { + reply(w, 409, map[string]string{"error": e.Error()}) + return + } + reply(w, 202, v) + }) + mux.HandleFunc("GET /api/devices/operations/{id}", func(w http.ResponseWriter, r *http.Request) { + if !server.authorized(w, r) { + return + } + v := s.Get(r.PathValue("id")) + if v == nil { + reply(w, 404, map[string]string{"error": "Операция не найдена"}) + return + } + reply(w, 200, v) + }) +} + +func (s *Sensors) Acknowledge(ids []string) { + s.mu.Lock() + defer s.mu.Unlock() + for _, id := range ids { + if v := s.operations[id]; v != nil && v.State != "running" { + v.Remote = false + _ = s.write(id+".json", v) + } + } +} diff --git a/apps/node-agent/internal/node/sensors_test.go b/apps/node-agent/internal/node/sensors_test.go new file mode 100644 index 0000000..ea6833b --- /dev/null +++ b/apps/node-agent/internal/node/sensors_test.go @@ -0,0 +1,10 @@ +package node + +import ( + "testing" + "time" +) + +func sensorTestCommand() SensorCommand {now:=time.Now();id:="op_01234567890123456789012345678901";return SensorCommand{APIVersion:SensorSchema,Kind:"OperationRequest",ID:id,Idempotency:id,Session:SensorSession{SessionID:"session_test",DeviceID:"rsd455_01234567890123456789012345678901"},Action:"start",Requested:now.UTC().Format(time.RFC3339Nano),Deadline:now.Add(time.Minute).UTC().Format(time.RFC3339Nano),Parameters:map[string]any{}}} +func TestSensorRejectsAuthorityAndExpiredRequests(t *testing.T){s,e:=OpenSensors(t.TempDir(),"node_test");if e!=nil{t.Fatal(e)};c:=sensorTestCommand();c.Action="shell";if _,e=s.Submit(c,false);e==nil{t.Fatal("arbitrary action admitted")};c=sensorTestCommand();c.ID="../../owned";if _,e=s.Submit(c,false);e==nil{t.Fatal("path admitted")};c=sensorTestCommand();c.Requested=time.Now().Add(-2*time.Minute).Format(time.RFC3339Nano);c.Deadline=time.Now().Add(-time.Minute).Format(time.RFC3339Nano);if _,e=s.Submit(c,false);e==nil{t.Fatal("expired command admitted")}} +func TestSensorUncertainCrashDoesNotReplay(t *testing.T){root:=t.TempDir();s,_:=OpenSensors(root,"node_test");c:=sensorTestCommand();old:=&SensorOperation{Command:c,State:"running",Updated:time.Now().Unix()};if e:=s.write(c.ID+".json",old);e!=nil{t.Fatal(e)};s,e:=OpenSensors(root,"node_test");if e!=nil{t.Fatal(e)};v,e:=s.Submit(c,false);if e!=nil||v.State!="unknown"{t.Fatalf("replay: %+v %v",v,e)};c.Parameters=map[string]any{"record":true};if _,e=s.Submit(c,false);e==nil{t.Fatal("id collision did not reject different command")}} diff --git a/apps/node-agent/internal/node/server.go b/apps/node-agent/internal/node/server.go index bb7e9c1..c56e04f 100644 --- a/apps/node-agent/internal/node/server.go +++ b/apps/node-agent/internal/node/server.go @@ -15,6 +15,7 @@ import ( type Server struct { Store *Store Pairing *Pairing + Sensors *Sensors Assets fs.FS Origin string Version string @@ -78,6 +79,9 @@ func reply(w http.ResponseWriter, status int, v any) { func (s *Server) Handler() http.Handler { mux := http.NewServeMux() + if s.Sensors != nil { + s.Sensors.Routes(mux, s) + } if s.Pairing != nil { s.Pairing.localRoutes(mux, s) } diff --git a/apps/node-agent/packaging/50-mission-core-device-prepare.rules b/apps/node-agent/packaging/50-mission-core-device-prepare.rules new file mode 100644 index 0000000..d7a12a3 --- /dev/null +++ b/apps/node-agent/packaging/50-mission-core-device-prepare.rules @@ -0,0 +1,6 @@ +// An authenticated Node action may start only this fixed model job. +polkit.addRule(function(action, subject) { + if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && action.lookup("unit") === "mission-core-node-realsense-prepare.service" && action.lookup("verb") === "start") { + return polkit.Result.YES; + } +}); diff --git a/apps/node-agent/packaging/70-mission-core-realsense.rules b/apps/node-agent/packaging/70-mission-core-realsense.rules new file mode 100644 index 0000000..b0281d2 --- /dev/null +++ b/apps/node-agent/packaging/70-mission-core-realsense.rules @@ -0,0 +1,4 @@ +# Reviewed D455 only. No firmware/DFU IDs, unrelated cameras or world-writable devices. +SUBSYSTEM=="usb", ATTR{idVendor}=="8086", ATTR{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors" +SUBSYSTEM=="video4linux", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors" +SUBSYSTEM=="hidraw", ATTRS{idVendor}=="8086", ATTRS{idProduct}=="0b5c", MODE="0660", GROUP="mission-core-sensors" diff --git a/apps/node-agent/packaging/build_deb.py b/apps/node-agent/packaging/build_deb.py index c99cb93..bc6a9f5 100644 --- a/apps/node-agent/packaging/build_deb.py +++ b/apps/node-agent/packaging/build_deb.py @@ -13,7 +13,7 @@ import tarfile ROOT = Path(__file__).resolve().parents[1] -VERSION = "0.5.1" +VERSION = "0.6.0" BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af" @@ -33,7 +33,7 @@ def desktop_icon(brand): def tarball(files): stream = io.BytesIO() - with tarfile.open(fileobj=stream, mode="w", format=tarfile.USTAR_FORMAT) as archive: + with tarfile.open(fileobj=stream, mode="w", format=tarfile.GNU_FORMAT) as archive: directories = {str(parent) for name, _, _ in files for parent in Path(name).parents if str(parent) != "."} for name in sorted(directories): item = tarfile.TarInfo(name + "/") @@ -92,6 +92,24 @@ Description: Mission Core onboard computer configuration ]: files.append((path, (p / source).read_bytes(), mode)) files.append(("usr/share/mission-core-node/environment-profile.json", (ROOT / "internal/node/environment-profile.json").read_bytes(), 0o644)) + for name in ("realsense_prepare.py",): + files.append(("usr/lib/mission-core-node/" + name, (p / name).read_bytes(), 0o644)) + for name in ("mission-core-realsense.service", "mission-core-node-realsense-prepare.service"): + files.append(("usr/lib/systemd/system/" + name, (p / name).read_bytes(), 0o644)) + files.append(("usr/share/polkit-1/rules.d/50-mission-core-device-prepare.rules", (p / "50-mission-core-device-prepare.rules").read_bytes(), 0o644)) + files.append(("usr/share/mission-core-node/realsense/70-mission-core-realsense.rules", (p / "70-mission-core-realsense.rules").read_bytes(), 0o644)) + bundle = json.loads((p / "realsense-bundle.json").read_text()) + files.append(("usr/share/mission-core-node/realsense/bundle.json", (p / "realsense-bundle.json").read_bytes(), 0o644)) + for item in bundle["wheels"]: + data = (ROOT / "build/realsense-wheels" / item["name"]).read_bytes() + if hashlib.sha256(data).hexdigest() != item["sha256"]: + raise ValueError("Driver bundle hash mismatch") + files.append(("usr/share/mission-core-node/realsense/" + item["name"], data, 0o644)) + for path in (ROOT / "sensors").glob("*.py"): + files.append(("usr/lib/mission-core-node/sensors/" + path.name, path.read_bytes(), 0o644)) + sdk = ROOT.parents[1] / "packages/plugin-sdk/python/missioncore_plugin_sdk" + for path in sdk.rglob("*.py"): + files.append(("usr/lib/mission-core-node/sdk/missioncore_plugin_sdk/" + str(path.relative_to(sdk)), path.read_bytes(), 0o644)) if (ROOT / "build/provenance.json").exists(): files.append(("usr/share/doc/mission-core-node/provenance.json", (ROOT / "build/provenance.json").read_bytes(), 0o644)) archive = b"!\n" + ar_member("debian-binary", b"2.0\n") + ar_member("control.tar.gz", tarball(controls)) + ar_member("data.tar.gz", tarball(files)) diff --git a/apps/node-agent/packaging/fetch_driver_bundle.py b/apps/node-agent/packaging/fetch_driver_bundle.py new file mode 100644 index 0000000..a55fa23 --- /dev/null +++ b/apps/node-agent/packaging/fetch_driver_bundle.py @@ -0,0 +1,30 @@ +"""Engineering build input, never run on an operator board. Exact PyPI hashes only.""" + +import hashlib +import json +from pathlib import Path +from urllib.request import urlopen + +root = Path(__file__).resolve().parents[1] +manifest = json.loads((root / "packaging/realsense-bundle.json").read_text()) +output = root / "build/realsense-wheels" +output.mkdir(parents=True, exist_ok=True) +for item in manifest["wheels"]: + target = output / item["name"] + if target.exists() and hashlib.sha256(target.read_bytes()).hexdigest() == item["sha256"]: + continue + package, version = item["name"].split("-")[:2] + with urlopen(f"https://pypi.org/pypi/{package}/{version}/json", timeout=30) as response: + metadata = json.load(response) + source = next( + v + for v in metadata["urls"] + if v["filename"] == item["name"] and v["digests"]["sha256"] == item["sha256"] + ) + if not source["url"].startswith("https://files.pythonhosted.org/"): + raise ValueError("Unexpected package origin") + with urlopen(source["url"], timeout=120) as response: + data = response.read(item["bytes"] + 1) + if len(data) != item["bytes"] or hashlib.sha256(data).hexdigest() != item["sha256"]: + raise ValueError("Driver checksum mismatch") + target.write_bytes(data) diff --git a/apps/node-agent/packaging/mission-core-node-realsense-prepare.service b/apps/node-agent/packaging/mission-core-node-realsense-prepare.service new file mode 100644 index 0000000..8306d8c --- /dev/null +++ b/apps/node-agent/packaging/mission-core-node-realsense-prepare.service @@ -0,0 +1,8 @@ +[Unit] +Description=Mission Core fixed RealSense model preparation +After=systemd-udevd.service +[Service] +Type=oneshot +ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/realsense_prepare.py +TimeoutStartSec=300 +UMask=0022 diff --git a/apps/node-agent/packaging/mission-core-realsense.service b/apps/node-agent/packaging/mission-core-realsense.service new file mode 100644 index 0000000..0133ac3 --- /dev/null +++ b/apps/node-agent/packaging/mission-core-realsense.service @@ -0,0 +1,31 @@ +[Unit] +Description=Mission Core isolated RealSense driver +After=network.target +ConditionPathExists=/var/lib/mission-core-node-drivers/active.path +[Service] +User=mission-core-sensors +Group=mission-core-node +SupplementaryGroups=mission-core-sensors +ExecStart=/usr/bin/python3 -I /usr/lib/mission-core-node/sensors/bootstrap.py +StateDirectory=mission-core-sensors +StateDirectoryMode=0700 +RuntimeDirectory=mission-core-sensors +RuntimeDirectoryMode=0750 +UMask=0077 +Restart=on-failure +RestartSec=3 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +RestrictSUIDSGID=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_NETLINK +CapabilityBoundingSet= +LockPersonality=yes +TasksMax=128 +MemoryMax=900M +[Install] +WantedBy=multi-user.target diff --git a/apps/node-agent/packaging/postinst b/apps/node-agent/packaging/postinst index c0a05a6..65e5b57 100644 --- a/apps/node-agent/packaging/postinst +++ b/apps/node-agent/packaging/postinst @@ -5,6 +5,9 @@ case "$1" in if ! getent passwd mission-core-node >/dev/null; then adduser --system --group --home /var/lib/mission-core-node --no-create-home --disabled-login mission-core-node fi + if ! getent passwd mission-core-sensors >/dev/null; then + adduser --system --group --home /var/lib/mission-core-sensors --no-create-home --disabled-login mission-core-sensors + fi # Only bootstrap required to open the GUI. Operational configuration is a # versioned job started by «Настройка окружения → Сконфигурировать». if [ -d /run/systemd/system ]; then diff --git a/apps/node-agent/packaging/preinst b/apps/node-agent/packaging/preinst index 481729f..579fb70 100644 --- a/apps/node-agent/packaging/preinst +++ b/apps/node-agent/packaging/preinst @@ -2,6 +2,11 @@ set -eu if [ "$1" = install ] || [ "$1" = upgrade ]; then if [ -d /run/systemd/system ]; then + mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true) + case "$mc_node_device_job" in + active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;; + esac + mc_node_environment_state=$(systemctl show --property=ActiveState --value mission-core-node-environment.service 2>/dev/null || true) case "$mc_node_environment_state" in active|activating) diff --git a/apps/node-agent/packaging/prerm b/apps/node-agent/packaging/prerm index 95ea73d..7c86ed7 100644 --- a/apps/node-agent/packaging/prerm +++ b/apps/node-agent/packaging/prerm @@ -1,6 +1,11 @@ #!/bin/sh set -eu if [ -d /run/systemd/system ]; then + mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true) + case "$mc_node_device_job" in + active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;; + esac + mc_node_environment_state=$(systemctl show --property=ActiveState --value mission-core-node-environment.service 2>/dev/null || true) case "$mc_node_environment_state" in active|activating) @@ -29,6 +34,8 @@ case "$1" in /usr/sbin/sshd -t systemctl try-reload-or-restart ssh.service fi + systemctl stop mission-core-realsense.service + systemctl disable mission-core-realsense.service || true systemctl stop mission-core-node.service systemctl disable mission-core-node.service fi diff --git a/apps/node-agent/packaging/realsense-bundle.json b/apps/node-agent/packaging/realsense-bundle.json new file mode 100644 index 0000000..3f116fe --- /dev/null +++ b/apps/node-agent/packaging/realsense-bundle.json @@ -0,0 +1,154 @@ +{ + "schema": "missioncore.node.driver-bundle/v1", + "model_id": "realsense.d455", + "revision": "c70509ac57b917dbdd5707a9", + "python": "3.12", + "platform": "linux-amd64", + "wheels": [ + { + "name": "aiohappyeyeballs-2.7.1-py3-none-any.whl", + "sha256": "9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", + "bytes": 15038 + }, + { + "name": "aiohttp-3.12.15-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "sha256": "fd736ed420f4db2b8148b52b46b88ed038d0354255f9a73196b7bbce3ea97545", + "bytes": 1719929 + }, + { + "name": "aioice-0.10.2-py3-none-any.whl", + "sha256": "14911c15ab12d096dd14d372ebb4aecbb7420b52c9b76fdfcf54375dec17fcbf", + "bytes": 24875 + }, + { + "name": "aiortc-1.14.0-py3-none-any.whl", + "sha256": "4b244d7e482f4e1f67e685b3468269628eca1ec91fa5b329ab517738cfca086e", + "bytes": 93183 + }, + { + "name": "aiosignal-1.4.0-py3-none-any.whl", + "sha256": "053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", + "bytes": 7490 + }, + { + "name": "annotated_types-0.8.0-py3-none-any.whl", + "sha256": "f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", + "bytes": 13427 + }, + { + "name": "attrs-26.1.0-py3-none-any.whl", + "sha256": "c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", + "bytes": 67548 + }, + { + "name": "av-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", + "sha256": "7ae547f6d5fa31763f73900d43901e8c5fa6367bb9a9840978d57b5a7ae14ed2", + "bytes": 41174337 + }, + { + "name": "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", + "sha256": "c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", + "bytes": 221822 + }, + { + "name": "cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", + "sha256": "ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", + "bytes": 4712478 + }, + { + "name": "dnspython-2.8.0-py3-none-any.whl", + "sha256": "01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", + "bytes": 331094 + }, + { + "name": "frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", + "sha256": "494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", + "bytes": 242411 + }, + { + "name": "google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", + "sha256": "14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", + "bytes": 33364 + }, + { + "name": "idna-3.19-py3-none-any.whl", + "sha256": "815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", + "bytes": 68550 + }, + { + "name": "ifaddr-0.2.0-py3-none-any.whl", + "sha256": "085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", + "bytes": 12314 + }, + { + "name": "multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", + "bytes": 256322 + }, + { + "name": "numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "sha256": "fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", + "bytes": 16527618 + }, + { + "name": "pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", + "sha256": "67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", + "bytes": 7644652 + }, + { + "name": "propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", + "bytes": 61639 + }, + { + "name": "pycparser-3.0-py3-none-any.whl", + "sha256": "b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", + "bytes": 48172 + }, + { + "name": "pydantic-2.11.7-py3-none-any.whl", + "sha256": "dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", + "bytes": 444782 + }, + { + "name": "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", + "sha256": "8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", + "bytes": 2002028 + }, + { + "name": "pyee-14.0.0-py3-none-any.whl", + "sha256": "3ac2d3229a9677f7de2c33d7f52fe25b638a46b19c413fea2edc8c6d0a644e4d", + "bytes": 15553 + }, + { + "name": "pylibsrtp-1.0.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "293c9f2ac21a2bd689c477603a1aa235d85cf252160e6715f0101e42a43cbedc", + "bytes": 2434534 + }, + { + "name": "pyopenssl-26.4.0-py3-none-any.whl", + "sha256": "f0eb0cb2d581d3ad2b9c489468485e7f2ab6727d08401bcf9d824c3caddf3c1c", + "bytes": 56026 + }, + { + "name": "pyrealsense2-2.58.4.10922-cp312-cp312-manylinux1_x86_64.whl", + "sha256": "1e83454cbaf9de50962d78ce3addb0b6a6d8d02259c1f0c64d4a0d527a02bf73", + "bytes": 13094959 + }, + { + "name": "typing_extensions-4.16.0-py3-none-any.whl", + "sha256": "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", + "bytes": 45571 + }, + { + "name": "typing_inspection-0.4.4-py3-none-any.whl", + "sha256": "65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", + "bytes": 14750 + }, + { + "name": "yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", + "sha256": "f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", + "bytes": 109835 + } + ] +} diff --git a/apps/node-agent/packaging/realsense_prepare.py b/apps/node-agent/packaging/realsense_prepare.py new file mode 100644 index 0000000..fbeb2f6 --- /dev/null +++ b/apps/node-agent/packaging/realsense_prepare.py @@ -0,0 +1,194 @@ +"""Fixed model job. No paths, packages, URLs or commands are accepted from clients.""" + +import hashlib +import json +import os +import shutil +import subprocess +import sys +import time +import uuid +import zipfile +from pathlib import Path, PurePosixPath + +SHARE = Path("/usr/share/mission-core-node/realsense") +ROOT = Path("/var/lib/mission-core-node-drivers") +REPORT = ROOT / "preparation.json" +STEPS = [ + ("platform", "Проверка совместимости системы"), + ("payload", "Проверка встроенного драйвера"), + ("runtime", "Развёртывание драйвера"), + ("access", "Настройка доступа к камере"), + ("service", "Запуск службы камеры"), +] + + +def publish(value): + ROOT.mkdir(mode=0o755, exist_ok=True) + if ROOT.is_symlink() or ROOT.stat().st_uid != 0: + raise RuntimeError("Небезопасный каталог драйверов") + ROOT.chmod(0o755) + tmp = ROOT / ".preparation.tmp" + with tmp.open("w") as f: + os.fchmod(f.fileno(), 0o644) + json.dump(value, f, ensure_ascii=False) + f.flush() + os.fsync(f.fileno()) + tmp.replace(REPORT) + + +def run(*args): + result = subprocess.run(args, capture_output=True, timeout=90) + if result.returncode: + raise RuntimeError("Системный этап не завершён. Повторите подготовку устройства.") + + +def safe_members(archive): + for info in archive.infolist(): + path = PurePosixPath(info.filename) + if ( + path.is_absolute() + or ".." in path.parts + or (info.external_attr >> 16) & 0o170000 == 0o120000 + ): + raise RuntimeError("Недопустимое содержимое драйверного пакета") + if ".data" in info.filename or info.filename.endswith(".pth"): + raise RuntimeError("Пакет требует неподдерживаемый способ установки") + yield info + + +def prepare(): + manifest = json.loads((SHARE / "bundle.json").read_text()) + revision = manifest["revision"] + if not revision.isalnum(): + raise RuntimeError("Некорректная версия драйвера") + target = ROOT / revision + state = { + "schema": "missioncore.node.device-preparation/v1", + "model_id": "realsense.d455", + "revision": revision, + "run_id": str(uuid.uuid4()), + "started_at": time.time(), + "state": "running", + "steps": [{"id": k, "label": v, "state": "pending"} for k, v in STEPS], + } + publish(state) + try: + for step in state["steps"]: + step["state"] = "running" + state["updated_at"] = time.time() + publish(state) + if step["id"] == "platform": + release = dict( + line.split("=", 1) + for line in Path("/etc/os-release").read_text().splitlines() + if "=" in line + ) + if ( + (release.get("ID", "").strip('"'), release.get("VERSION_ID", "").strip('"')) + != ("ubuntu", "24.04") + or os.uname().machine != "x86_64" + or sys.version_info[:2] != (3, 12) + ): + raise RuntimeError("Встроенный драйвер несовместим с этой системой") + elif step["id"] == "payload": + for entry in manifest["wheels"]: + path = SHARE / entry["name"] + if ( + path.name != entry["name"] + or path.is_symlink() + or hashlib.sha256(path.read_bytes()).hexdigest() != entry["sha256"] + ): + raise RuntimeError( + "Контрольная сумма драйвера не совпала. Переустановите пакет Node." + ) + elif step["id"] == "runtime": + if not target.exists(): + stage = ROOT / (revision + ".partial") + if stage.exists(): + shutil.rmtree(stage) + stage.mkdir(mode=0o755) + for entry in manifest["wheels"]: + with zipfile.ZipFile(SHARE / entry["name"]) as archive: + archive.extractall(stage, members=safe_members(archive)) + for path in stage.rglob("*"): + path.chmod(0o755 if path.is_dir() else 0o644) + stage.rename(target) + # Wheel RECORD content is checked again against the bundled wheel; + # a previous successful report never substitutes for integrity. + for entry in manifest["wheels"]: + with zipfile.ZipFile(SHARE / entry["name"]) as archive: + for info in safe_members(archive): + if not info.is_dir() and ( + target / info.filename + ).read_bytes() != archive.read(info): + raise RuntimeError( + "Установленный драйвер изменён. " + "Нужна переустановка пакета драйвера." + ) + (ROOT / "active.path").write_text(str(target)) + (ROOT / "active.path").chmod(0o644) + elif step["id"] == "access": + source = SHARE / "70-mission-core-realsense.rules" + dest = Path("/etc/udev/rules.d/70-mission-core-realsense.rules") + if dest.is_symlink() or ( + dest.exists() and dest.read_bytes() != source.read_bytes() + ): + raise RuntimeError( + "Правила доступа к камере изменены в системе. Чужая конфигурация сохранена." + ) + dest.write_bytes(source.read_bytes()) + dest.chmod(0o644) + run("/usr/bin/udevadm", "control", "--reload-rules") + # Restrict trigger to the admitted product; no unrelated USB reset. + run( + "/usr/bin/udevadm", + "trigger", + "--action=change", + "--subsystem-match=usb", + "--attr-match=idVendor=8086", + "--attr-match=idProduct=0b5c", + ) + run( + "/usr/bin/udevadm", + "trigger", + "--action=change", + "--subsystem-match=video4linux", + ) + run("/usr/bin/udevadm", "trigger", "--action=change", "--subsystem-match=hidraw") + run("/usr/bin/udevadm", "settle", "--timeout=10") + elif step["id"] == "service": + run("/usr/bin/systemctl", "enable", "mission-core-realsense.service") + # Never restart a running acquisition on repeated preparation. + run("/usr/bin/systemctl", "start", "mission-core-realsense.service") + run("/usr/bin/systemctl", "is-active", "--quiet", "mission-core-realsense.service") + step["state"] = "complete" + publish(state) + state["state"] = "complete" + except ( + OSError, + ValueError, + RuntimeError, + subprocess.SubprocessError, + zipfile.BadZipFile, + ) as error: + step["state"] = "error" + step["message"] = ( + str(error)[:300] + if isinstance(error, RuntimeError) + else "Не удалось подготовить драйвер. Повторите действие." + ) + state["state"] = "error" + for item in state["steps"]: + if item["state"] == "pending": + item["state"] = "blocked" + state["updated_at"] = time.time() + publish(state) + return state["state"] == "complete" + + +if __name__ == "__main__": + os.umask(0o022) + if os.geteuid() != 0 or len(sys.argv) != 1: + sys.exit(1) + sys.exit(0 if prepare() else 1) diff --git a/apps/node-agent/packaging/test_realsense_prepare.py b/apps/node-agent/packaging/test_realsense_prepare.py new file mode 100644 index 0000000..623233a --- /dev/null +++ b/apps/node-agent/packaging/test_realsense_prepare.py @@ -0,0 +1,25 @@ +import io +import unittest +import zipfile +from realsense_prepare import safe_members + + +class BundleBoundaryTests(unittest.TestCase): + def test_archives_cannot_escape_or_execute_import_hooks(self): + for name in ('../../etc/shadow', '/root/escape', 'something.pth', 'sdk.data/purelib/code.py'): + value = io.BytesIO() + with zipfile.ZipFile(value, 'w') as archive: + archive.writestr(name, b'payload') + with zipfile.ZipFile(io.BytesIO(value.getvalue())) as archive: + with self.assertRaises(RuntimeError): + list(safe_members(archive)) + + def test_archive_symlink_is_rejected(self): + value = io.BytesIO() + with zipfile.ZipFile(value, 'w') as archive: + info = zipfile.ZipInfo('device.py') + info.external_attr = 0o120777 << 16 + archive.writestr(info, '/etc/shadow') + with zipfile.ZipFile(io.BytesIO(value.getvalue())) as archive: + with self.assertRaises(RuntimeError): + list(safe_members(archive)) diff --git a/apps/node-agent/sensors/bootstrap.py b/apps/node-agent/sensors/bootstrap.py new file mode 100644 index 0000000..ef7e22c --- /dev/null +++ b/apps/node-agent/sensors/bootstrap.py @@ -0,0 +1,13 @@ +"""Isolated interpreter; only root-owned pinned runtime and product code on sys.path.""" + +import sys +from pathlib import Path + +root = Path("/var/lib/mission-core-node-drivers") +path = Path((root / "active.path").read_text()) +if path.parent != root or not path.name.isalnum() or path.is_symlink() or path.stat().st_uid != 0: + raise RuntimeError("Invalid driver installation") +sys.path[:0] = [str(path), "/usr/lib/mission-core-node/sensors", "/usr/lib/mission-core-node/sdk"] +from server import main # noqa: E402 — use only the verified isolated runtime above + +main() diff --git a/apps/node-agent/sensors/device.py b/apps/node-agent/sensors/device.py new file mode 100644 index 0000000..36cc8ed --- /dev/null +++ b/apps/node-agent/sensors/device.py @@ -0,0 +1,503 @@ +"""D455 adapter. Hardware ownership and raw acquisition remain on the board.""" + +import hashlib +import json +import math +import os +import queue +import shutil +import threading +import time +import uuid +from contextlib import suppress +from datetime import UTC, datetime + +import numpy as np +import pyrealsense2 as rs +from missioncore_plugin_sdk.v0alpha2.session import DeviceSessionSnapshot + +MODEL = { + "plugin_id": "missioncore.realsense", + "plugin_version": "0.6.0", + "model_id": "realsense.d455", +} + + +def utc(): + return datetime.now(UTC).isoformat() + + +def device_id(serial): + return "rsd455_" + hashlib.sha256(serial.encode()).hexdigest()[:32] + + +def atomic(path, value): + tmp = path.with_suffix(".tmp") + with tmp.open("w") as f: + json.dump(value, f, ensure_ascii=False, allow_nan=False) + f.flush() + os.fsync(f.fileno()) + tmp.replace(path) + + +def kind(profile): + return str(profile.stream_type()).split(".")[-1] + + +class Device: + def __init__(self, serial, root, execution): + self.serial = serial + self.id = device_id(serial) + self.root = root / self.id + self.root.mkdir(exist_ok=True, mode=0o700) + self.lock = threading.RLock() + self.execution = execution + self.session = "sensor_" + uuid.uuid4().hex + self.opened = utc() + self.revision = 0 + self.online = True + self.acquisition = "idle" + self.message = "" + self.pipeline = None + self.thread = None + self.queue = queue.Queue(maxsize=2) + self.stop_event = threading.Event() + self.images = {} + self.motion = {} + self.depth = None + self.intrinsics = None + self.frames = {} + self.last_frame = None + self.record = None + self.profiles = [] + self.options = [] + self.sdk_device = None + self.firmware = "" + self.transport = "" + self.config = {"name": "RealSense D455", "verified": False} + if (self.root / "config.json").exists(): + self.config.update(json.loads((self.root / "config.json").read_text())) + # A previous verification is evidence, not a live readiness assertion. + self.verified_this_process = False + for path in self.root.glob("recordings/*/manifest.json"): + value = json.loads(path.read_text()) + if value.get("state") == "recording": + value.update(state="interrupted", recovered_at=utc()) + atomic(path, value) + + def save(self): + atomic(self.root / "config.json", self.config) + + def refresh(self, dev): + with self.lock: + self.sdk_device = dev + self.online = True + self.firmware = dev.get_info(rs.camera_info.firmware_version) + self.transport = dev.get_info(rs.camera_info.usb_type_descriptor) + if self.pipeline is not None: + return + profiles, options = [], [] + for index, sensor in enumerate(dev.query_sensors()): + for profile in sensor.get_stream_profiles(): + stream = kind(profile) + if stream not in ("color", "depth", "infrared", "accel", "gyro"): + continue + data = { + "sensor": index, + "stream": stream, + "index": profile.stream_index(), + "fps": profile.fps(), + "format": str(profile.format()).split(".")[-1], + } + if profile.is_video_stream_profile(): + video = profile.as_video_stream_profile() + data.update(width=video.width(), height=video.height()) + data["id"] = hashlib.sha256( + json.dumps(data, sort_keys=True).encode() + ).hexdigest()[:16] + profiles.append(data) + for option in sensor.get_supported_options(): + try: + limits = sensor.get_option_range(option) + value = sensor.get_option(option) + if not all( + math.isfinite(x) for x in (limits.min, limits.max, limits.step, value) + ): + continue + options.append( + { + "id": f"{index}:{int(option)}", + "sensor": sensor.get_info(rs.camera_info.name), + "label": str(option).split(".")[-1], + "value": value, + "min": limits.min, + "max": limits.max, + "step": limits.step, + "read_only": sensor.is_option_read_only(option), + } + ) + except RuntimeError: + continue + self.profiles, self.options = profiles, options + + def defaults(self): + result = [] + for stream, index, fmt, fps in [ + ("depth", 0, "z16", 15), + ("color", 0, "rgb8", 15), + ("infrared", 1, "y8", 15), + ("infrared", 2, "y8", 15), + ("accel", 0, "motion_xyz32f", 63), + ("gyro", 0, "motion_xyz32f", 200), + ]: + candidates = [ + p + for p in self.profiles + if p["stream"] == stream and p["index"] == index and p["format"] == fmt + ] + if candidates: + p = min( + candidates, + key=lambda p: ( + abs(p.get("width", 640) - 640) + + abs(p.get("height", 480) - 480) + + abs(p["fps"] - fps) * 20 + ), + ) + result.append(p["id"]) + return result + + def snapshot(self, detailed=True): + with self.lock: + now = utc() + snap = DeviceSessionSnapshot.model_validate( + { + "context": { + "session_id": self.session, + "device": { + "device_id": self.id, + "model": MODEL, + "stability": "stable", + "basis": "hardware-identifier", + }, + "execution": self.execution, + "opened_at": self.opened, + }, + "revision": self.revision, + "enrollment": "enrolled" if self.config["verified"] else "empty", + "connectivity": "connected" if self.online else "offline", + "acquisition": self.acquisition, + "observed_at": now, + "message": self.message or None, + } + ) + value = { + "id": self.id, + "name": self.config["name"], + "model": "RealSense D455", + "prepared": True, + "verified": self.verified_this_process, + "online": self.online, + "snapshot": snap.model_dump(mode="json"), + "firmware": self.firmware, + "usb": self.transport, + "frames": dict(self.frames), + "last_frame": self.last_frame, + "recording": self.record, + "layers": list(self.images) + + (["points"] if self.depth is not None else []) + + (["motion"] if self.motion else []), + } + if detailed: + value.update( + profiles=self.profiles, + defaults=self.defaults(), + options=self.options, + recordings=self.recordings(), + motion=dict(self.motion), + ) + return value + + def callback(self, frame): + if frame.is_motion_frame(): + v = frame.as_motion_frame().get_motion_data() + key = kind(frame.profile) + self.motion[key] = { + "x": v.x, + "y": v.y, + "z": v.z, + "timestamp_ms": frame.get_timestamp(), + "clock": str(frame.get_frame_timestamp_domain()), + "observed_at": utc(), + } + self.frames[key] = self.frames.get(key, 0) + 1 + elif frame.is_frameset(): + with suppress(queue.Full): + self.queue.put_nowait(frame.as_frameset()) + + def start(self, selected=None, record=False): + with self.lock: + if self.pipeline is not None: + raise ValueError("Захват уже запущен. Сначала остановите его.") + if not self.online or self.sdk_device is None: + raise ValueError("Камера не подключена.") + ids = selected if selected is not None else self.defaults() + if not isinstance(ids, list) or not 1 <= len(ids) <= 6 or len(set(ids)) != len(ids): + raise ValueError("Выберите профили потоков.") + profiles = [next((p for p in self.profiles if p["id"] == ident), None) for ident in ids] + if any(p is None for p in profiles) or len( + {(p["stream"], p["index"]) for p in profiles} + ) != len(profiles): + raise ValueError("Выбраны несовместимые профили потоков.") + if not any(p["stream"] in ("depth", "color", "infrared") for p in profiles): + raise ValueError("Выберите хотя бы один видеопоток.") + config = rs.config() + config.enable_device(self.serial) + for p in profiles: + stream, fmt = getattr(rs.stream, p["stream"]), getattr(rs.format, p["format"]) + if "width" in p: + config.enable_stream(stream, p["index"], p["width"], p["height"], fmt, p["fps"]) + else: + config.enable_stream(stream, p["index"], fmt, p["fps"]) + pipeline = rs.pipeline() + if not config.can_resolve(rs.pipeline_wrapper(pipeline)): + raise ValueError("Камера не поддерживает эту комбинацию профилей. Выберите другую.") + self.acquisition = "starting" + self.revision += 1 + self.images, self.motion, self.frames = {}, {}, {} + self.depth, self.last_frame = None, None + self.queue = queue.Queue(maxsize=2) + self.stop_event.clear() + record_path = None + if record: + if shutil.disk_usage(self.root).free < 2 * 1024**3: + self.acquisition = "idle" + raise ValueError("Для исходной записи нужно не меньше 2 ГиБ свободного места.") + ident = "capture_" + uuid.uuid4().hex + record_path = self.root / "recordings" / ident + record_path.mkdir(parents=True, mode=0o700) + config.enable_record_to_file(str(record_path / "source.bag")) + self.record = { + "id": ident, + "state": "recording", + "started_at": utc(), + "monotonic_ns": time.monotonic_ns(), + "device_id": self.id, + "session_id": self.session, + "profiles": profiles, + "firmware": self.firmware, + "sdk": "2.58.4.10922", + "options": self.options, + } + atomic(record_path / "manifest.json", self.record) + try: + active = pipeline.start(config, self.callback) + self.pipeline = pipeline + self.acquisition = "streaming" + self.message = "" + self.depth_scale = active.get_device().first_depth_sensor().get_depth_scale() + calibration = [] + for p in active.get_streams(): + if p.is_video_stream_profile(): + v = p.as_video_stream_profile().get_intrinsics() + calibration.append( + { + "stream": kind(p), + "index": p.stream_index(), + "width": v.width, + "height": v.height, + "fx": v.fx, + "fy": v.fy, + "ppx": v.ppx, + "ppy": v.ppy, + "coeffs": v.coeffs, + "model": str(v.model), + } + ) + if self.record: + self.record["calibration"] = calibration + self.record["depth_scale"] = self.depth_scale + atomic(record_path / "manifest.json", self.record) + self.thread = threading.Thread(target=self.consume, daemon=True) + self.thread.start() + except Exception: + with suppress(RuntimeError): + pipeline.stop() + self.pipeline = None + self.acquisition = "failed" + self.message = "Не удалось открыть потоки камеры. Проверьте подключение и профили." + if self.record: + self.record.update(state="failed", ended_at=utc()) + atomic(record_path / "manifest.json", self.record) + self.record = None + raise ValueError(self.message) from None + + def consume(self): + colorizer = rs.colorizer() + last_data, last_disk = time.monotonic(), time.monotonic() + while not self.stop_event.is_set(): + try: + frames = self.queue.get(timeout=1) + except queue.Empty: + if time.monotonic() - last_data > 8: + self.message = "Кадры перестали поступать. Проверьте USB и остановите захват." + self.acquisition = "failed" + self.online = False + break + continue + last_data = time.monotonic() + for frame in frames: + key = kind(frame.profile) + if key == "infrared": + key += str(frame.profile.stream_index()) + if not frame.is_video_frame(): + continue + data = np.asanyarray(frame.get_data()).copy() + if key == "depth": + self.depth = data + self.intrinsics = frame.profile.as_video_stream_profile().get_intrinsics() + data = np.asanyarray(colorizer.colorize(frame).get_data()).copy() + elif data.ndim == 2: + data = np.repeat(data[:, :, None], 3, axis=2) + elif frame.profile.format() == rs.format.bgr8: + data = data[:, :, ::-1].copy() + elif frame.profile.format() != rs.format.rgb8: + continue + self.images[key] = data + self.frames[key] = self.frames.get(key, 0) + 1 + self.last_frame = { + "observed_at": utc(), + "monotonic_ns": time.monotonic_ns(), + "device_timestamp_ms": frame.get_timestamp(), + "clock": str(frame.get_frame_timestamp_domain()), + } + if self.record and time.monotonic() - last_disk > 2: + last_disk = time.monotonic() + if shutil.disk_usage(self.root).free < 512 * 1024**2: + self.message = "Запись остановлена: мало свободного места." + break + if not self.stop_event.is_set(): + # Explicit device/disk failure closes raw recording; never auto-restart. + self.stop(failed=True, from_capture=True) + + def stop(self, failed=False, from_capture=False): + with self.lock: + self.stop_event.set() + pipeline, self.pipeline = self.pipeline, None + if pipeline is not None: + self.acquisition = "stopping" + pipeline.stop() + if self.thread and not from_capture: + self.thread.join(timeout=3) + self.acquisition = "failed" if failed else "idle" + self.revision += 1 + if self.record: + value = dict(self.record) + value.update( + state="failed" if failed else "complete", + ended_at=utc(), + ended_monotonic_ns=time.monotonic_ns(), + frames=dict(self.frames), + ) + path = self.root / "recordings" / value["id"] + source = path / "source.bag" + if source.exists(): + digest = hashlib.sha256() + with source.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + digest.update(chunk) + value.update(sha256=digest.hexdigest(), bytes=source.stat().st_size) + atomic(path / "manifest.json", value) + self.record = None + return {"ok": True} + + def verify(self): + if self.pipeline is not None: + raise ValueError("Остановите захват перед повторной проверкой.") + try: + self.start() + deadline = time.monotonic() + 8 + expected = { + p["stream"] + (str(p["index"]) if p["stream"] == "infrared" else "") + for p in self.profiles + if p["id"] in self.defaults() + } + while time.monotonic() < deadline: + if all(self.frames.get(key, 0) >= 2 for key in expected): + self.config["verified"] = True + self.verified_this_process = True + self.config["verified_at"] = utc() + self.save() + return { + "ok": True, + "frames": dict(self.frames), + "verified_at": self.config["verified_at"], + } + time.sleep(0.1) + raise ValueError( + "Не получены кадры всех выбранных потоков. Проверьте USB 3 и повторите проверку." + ) + finally: + self.stop() + + def rename(self, name): + if ( + not isinstance(name, str) + or not name.strip() + or len(name) > 80 + or any(ord(c) < 32 for c in name) + ): + raise ValueError("Введите название до 80 символов.") + with self.lock: + self.config["name"] = name.strip() + self.save() + self.revision += 1 + return {"ok": True} + + def set_option(self, identifier, value): + with self.lock: + item = next((v for v in self.options if v["id"] == identifier), None) + if ( + not item + or item["read_only"] + or type(value) not in (int, float) + or not math.isfinite(value) + or not item["min"] <= value <= item["max"] + ): + raise ValueError("Параметр недоступен или значение вне диапазона.") + sensor_index, option_id = map(int, identifier.split(":")) + sensor = self.sdk_device.query_sensors()[sensor_index] + option = rs.option(option_id) + sensor.set_option(option, float(value)) + item["value"] = sensor.get_option(option) + self.revision += 1 + return {"ok": True, "value": item["value"]} + + def points(self): + depth, intrinsics = self.depth, self.intrinsics + if depth is None or intrinsics is None: + return [] + # SDK deprojection respects the camera's actual distortion model. + h, w = depth.shape + stride = max(8, math.ceil(math.sqrt(h * w / 2000))) + result = [] + for y in range(0, h, stride): + for x in range(0, w, stride): + z = float(depth[y, x]) * self.depth_scale + if 0 < z < 15: + result.extend( + round(v, 4) for v in rs.rs2_deproject_pixel_to_point(intrinsics, [x, y], z) + ) + return result + + def recordings(self): + result = [] + for path in sorted(self.root.glob("recordings/*/manifest.json"), reverse=True)[:100]: + value = json.loads(path.read_text()) + result.append( + { + k: value.get(k) + for k in ("id", "state", "started_at", "ended_at", "bytes", "sha256") + } + ) + return result diff --git a/apps/node-agent/sensors/media.py b/apps/node-agent/sensors/media.py new file mode 100644 index 0000000..b229be6 --- /dev/null +++ b/apps/node-agent/sensors/media.py @@ -0,0 +1,126 @@ +"""Private WebRTC preview. No capture ownership, relay, STUN or public candidates.""" + +import asyncio +import ipaddress +import json +import time +import uuid + +import aioice.ice +from aiortc import RTCConfiguration, RTCPeerConnection, RTCSessionDescription, VideoStreamTrack +from av import VideoFrame + + +def private(address): + try: + value = ipaddress.ip_address(address) + return value.version == 4 and ( + value.is_loopback + or any( + value in ipaddress.ip_network(n) + for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "100.64.0.0/10") + ) + ) + except ValueError: + return False + + +host_addresses = aioice.ice.get_host_addresses +aioice.ice.get_host_addresses = lambda use_ipv4, use_ipv6: [ + v for v in host_addresses(use_ipv4=True, use_ipv6=False) if private(v) +] + + +class CameraTrack(VideoStreamTrack): + def __init__(self, device, layer): + super().__init__() + self.device, self.layer = device, layer + + async def recv(self): + pts, base = await self.next_timestamp() + # Bound preview to 15 Hz; hardware profiles and raw recording are independent. + await asyncio.sleep(1 / 30) + while self.layer not in self.device.images: + await asyncio.sleep(0.1) + frame = VideoFrame.from_ndarray(self.device.images[self.layer], format="rgb24") + frame.pts, frame.time_base = pts, base + return frame + + +class Peers: + def __init__(self): + self.items = {} + + async def offer(self, device, params): + layer = params.get("layer", "color") + if layer not in ("color", "depth", "infrared1", "infrared2", "points", "motion"): + raise ValueError("Неизвестный слой камеры.") + if len(self.items) >= 4: + raise ValueError("Закройте лишние окна просмотра камеры.") + sdp = params.get("sdp", "") + if not isinstance(sdp, str) or len(sdp) > 32768: + raise ValueError("Некорректное приглашение просмотра.") + for line in sdp.splitlines(): + if line.startswith("a=candidate:"): + fields = line.split() + if len(fields) < 8 or (not private(fields[4]) and not fields[4].endswith(".local")): + raise ValueError("Просмотр доступен только в частной сети.") + pc = RTCPeerConnection(RTCConfiguration(iceServers=[])) + ident = "peer_" + uuid.uuid4().hex + self.items[ident] = {"pc": pc, "seen": time.monotonic()} + + async def telemetry(channel): + try: + while pc.connectionState not in ("failed", "closed"): + if time.monotonic() - self.items.get(ident, {}).get("seen", 0) > 30: + break + if channel.readyState == "open" and channel.bufferedAmount < 65536: + payload = { + "layer": layer, + "motion": device.motion, + "frame": device.last_frame, + "acquisition": device.acquisition, + } + if layer == "points": + payload["points"] = await asyncio.to_thread(device.points) + channel.send(json.dumps(payload, allow_nan=False)) + await asyncio.sleep(0.25) + finally: + await self.close(ident) + + @pc.on("datachannel") + def datachannel(channel): + @channel.on("message") + def message(value): + if value == "keepalive" and ident in self.items: + self.items[ident]["seen"] = time.monotonic() + + asyncio.create_task(telemetry(channel)) + + @pc.on("connectionstatechange") + async def changed(): + if pc.connectionState in ("failed", "closed"): + self.items.pop(ident, None) + + try: + await pc.setRemoteDescription(RTCSessionDescription(sdp=sdp, type="offer")) + if layer not in ("points", "motion"): + pc.addTrack(CameraTrack(device, layer)) + await pc.setLocalDescription(await pc.createAnswer()) + + async def expiry(): + await asyncio.sleep(30) + if ident in self.items and pc.connectionState != "connected": + await self.close(ident) + + asyncio.create_task(expiry()) + return {"peer_id": ident, "sdp": pc.localDescription.sdp, "type": "answer"} + except Exception: + await self.close(ident) + raise + + async def close(self, ident): + entry = self.items.pop(ident, None) + if entry: + await entry["pc"].close() + return {"ok": True} diff --git a/apps/node-agent/sensors/server.py b/apps/node-agent/sensors/server.py new file mode 100644 index 0000000..1224c46 --- /dev/null +++ b/apps/node-agent/sensors/server.py @@ -0,0 +1,161 @@ +"""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.get_info(rs.camera_info.product_id).lower() != "0b5c": + continue + serial = dev.get_info(rs.camera_info.serial_number) + ident = device_id(serial) + found.add(ident) + if ident not in self.devices: + self.devices[ident] = Device(serial, self.root, self.execution) + self.devices[ident].refresh(dev) + for ident, device in self.devices.items(): + if ident not in found: + device.online = False + + 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 == "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) + return web.json_response(receipt["result"]) + + 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_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) diff --git a/apps/node-agent/ui/src/NodeSensors.tsx b/apps/node-agent/ui/src/NodeSensors.tsx new file mode 100644 index 0000000..45667b4 --- /dev/null +++ b/apps/node-agent/ui/src/NodeSensors.tsx @@ -0,0 +1,5 @@ +import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace'; +import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts'; +import {request} from './api'; +const transport:SensorTransport={inventory:()=>request('/api/devices'),submit:value=>request('/api/devices/operations','POST',value),operation:id=>request('/api/devices/operations/'+encodeURIComponent(id))}; +export function NodeSensors(){return ;} diff --git a/apps/node-agent/ui/src/main.tsx b/apps/node-agent/ui/src/main.tsx index 544a5ce..659d823 100644 --- a/apps/node-agent/ui/src/main.tsx +++ b/apps/node-agent/ui/src/main.tsx @@ -15,6 +15,7 @@ import { useEnvironment } from "./useEnvironment"; import { EnvironmentView } from "./EnvironmentView"; import { CoreConnectionView } from "./CoreConnectionView"; import "./node.css"; +import { NodeSensors } from "./NodeSensors"; function App() { const node = useNode(); @@ -32,6 +33,7 @@ function App() { function openView(id: ViewId) { if(environment.running) return; setAdding(false); setRoot(views.find(item => item.id === id)!.root); workspace.openView(id); } function selectRoot(id: RootId) { const first = roots.find(item => item.id === id)!.first; if (first) openView(first); } const content = !value ? null : workspace.activeView === "environment" ? : workspace.activeView === "overview" ? + : workspace.activeView === "sensors" ? : workspace.activeView === "network" ? : workspace.activeView === "usb" ? : workspace.activeView === "core" ? diff --git a/apps/node-agent/ui/src/nodeModel.ts b/apps/node-agent/ui/src/nodeModel.ts index e411b45..a4a5086 100644 --- a/apps/node-agent/ui/src/nodeModel.ts +++ b/apps/node-agent/ui/src/nodeModel.ts @@ -1,11 +1,12 @@ import type { IconName } from "@nodedc/ui-react"; export type RootId = "system" | "devices"; -export type ViewId = "environment" | "overview" | "network" | "diagnostics" | "usb" | "tailscale" | "ssh" | "core"; +export type ViewId = "sensors" | "environment" | "overview" | "network" | "diagnostics" | "usb" | "tailscale" | "ssh" | "core"; export const roots: { id: RootId; label: string; first: ViewId | null }[] = [ { id: "system", label: "Система", first: "environment" }, - { id: "devices", label: "Устройства", first: null }, + { id: "devices", label: "Устройства", first: "sensors" }, ]; export const views: { id: ViewId; root: RootId; label: string; icon: IconName }[] = [ + { id: "sensors", root: "devices", label: "Подключённые устройства", icon: "camera" }, { id: "environment", root: "system", label: "Настройка окружения", icon: "settings" }, { id: "overview", root: "system", label: "Обзор БК", icon: "activity" }, { id: "network", root: "system", label: "Сеть", icon: "network" }, diff --git a/apps/node-agent/ui/tsconfig.json b/apps/node-agent/ui/tsconfig.json index 24b1c12..1162518 100644 --- a/apps/node-agent/ui/tsconfig.json +++ b/apps/node-agent/ui/tsconfig.json @@ -1,8 +1,31 @@ { "compilerOptions": { - "target": "ES2022", "lib": ["ES2022", "DOM"], "module": "ESNext", - "moduleResolution": "bundler", "jsx": "react-jsx", "strict": true, - "skipLibCheck": true, "noEmit": true, "esModuleInterop": true + "target": "ES2022", + "lib": [ + "ES2022", + "DOM" + ], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "esModuleInterop": true, + "baseUrl": ".", + "paths": { + "react": [ + "node_modules/@types/react/index.d.ts" + ], + "react/jsx-runtime": [ + "node_modules/@types/react/jsx-runtime.d.ts" + ], + "@nodedc/ui-react": [ + "node_modules/@nodedc/ui-react/dist/index.d.ts" + ] + } }, - "include": ["src"] + "include": [ + "src" + ] } diff --git a/apps/node-agent/ui/vite.config.js b/apps/node-agent/ui/vite.config.js index bc49306..aba17a6 100644 --- a/apps/node-agent/ui/vite.config.js +++ b/apps/node-agent/ui/vite.config.js @@ -3,5 +3,5 @@ import { defineConfig } from "vite"; export default defineConfig({ // Design Guideline packages are linked during development. Their own React // must never become a second hook dispatcher in the portable production bundle. - resolve: { dedupe: ["react", "react-dom"] }, + resolve: { dedupe: ["react", "react-dom", "@nodedc/ui-react"] }, }); diff --git a/docs/node/05_SENSOR_HOST_AND_SHARED_CONTROL.md b/docs/node/05_SENSOR_HOST_AND_SHARED_CONTROL.md new file mode 100644 index 0000000..4666ef2 --- /dev/null +++ b/docs/node/05_SENSOR_HOST_AND_SHARED_CONTROL.md @@ -0,0 +1,79 @@ +# Сенсорный контур Node и два интерфейса управления + +Решение владельца от 05.09.2026. Развивает исходные требования MISSIONCOR-76. +Первый адаптер — RealSense D455; K1 остаётся отдельным следующим адаптером, +только Wi-Fi Bridge общей сети. USB-инвентаризация системы не равна устройству, +готовому к захвату. + +## Операторский сценарий + +Ручное сопряжение БК с Core обязательно и предшествует удалённому управлению. +Node обнаруживает поддерживаемую модель, даже если её драйвер ещё не развёрнут. +В «Устройствах» Node и внутри аппарата Core отображается одна и та же строка: +название, модель, фактическое состояние, круглая кнопка «Подготовить и проверить» +(download), отдельные настройки (settings), переход к просмотру (eye). +Подготовка запускается непосредственно из строки, без предварительной модалки. +Настройки имени остаются компактным окном. Глазик открывает следующий уровень +с камерой и её слоями; это основной экран, а не модальное окно. + +Это согласованная владельцем предметная композиция list/detail внутри уже +принятого раздела. Вариант с подготовкой только в модалке отвергнут владельцем: +он добавляет лишнее действие. Полноценный просмотр в модалке не подходит для +постоянной работы с захватом и слоями. ResourceRow/List, IconButton, Window, +SettingsCard, Select, SegmentedControl, ActivityIndicator и StatusBadge берутся +из Design Guideline. Общий предметный React-модуль используется обоими +приложениями через транспортный адаптер; визуальные компоненты не дублируются. + +## Единственный владелец состояния + +Node владеет обнаружением, подготовкой драйвера, конфигурацией, сеансом, +захватом и исходной записью. Core является вторым авторизованным клиентом. +Оба интерфейса читают один результат. Открытие глазика, обновление страницы, +переподключение и heartbeat никогда не запускают захват. START/STOP требуют +явного действия, идентификатора операции и дедупликации на борту. Потерянный +ответ не означает ошибку выполнения: неопределённый исход не повторяется +автоматически. Отзыв сопряжения прекращает приём новых команд от Core. + +Обнаружение, enrollment, connectivity, acquisition, DeviceInstanceRef, +DeviceSessionSnapshot и ExecutionBinding используют SDK v0alpha2. Адрес USB +является transport alias. Стабильная идентичность D455 выводится из аппаратного +серийного номера; сами серийные номера и реальные кадры не попадают в Git/Ops. + +## Подготовка модели + +Общая «Настройка окружения» готовит БК. Поддерживаемая модель имеет отдельный +версионный профиль подготовки, фиксированные зависимости с хешами и проверки. +Драйверный payload входит в .deb; оператор не выбирает пакеты, URL и команды. +Кнопка работает и локально, и через сопряжённый Core. Привилегия ограничена +запуском одного фиксированного systemd-задания подготовки модели. Node остаётся +без capabilities, с PrivateDevices и NoNewPrivileges. Драйвер работает отдельным +пользователем и получает доступ к D455 через узкие udev-правила. Нет глобального +pip, chmod 666 для всех камер, firmware-update или патча ядра. + +Этапы и ошибки сохраняются. Повторное действие проверяет установленный payload +и результат; две панели не запускают две установки. Успех установки отдельно +от успеха проверки камеры. «Готово» требует открытия SDK и получения реальных +кадров выбранного устройства; отсутствие камеры/профиля/прав даёт конкретный +неуспех. Закрытие UI не обрывает системное задание или запись. + +## Данные и просмотр + +RGB, depth, IR1/IR2 и IMU перечисляются из реального SDK. Профили, доступность +параметров и диапазоны получаются у камеры, несовместимые комбинации отвергаются +до START. Глубина остаётся метрической исходной Z16; цветная глубина и облако +точек — производные представления с калибровкой. Preview ограничен по частоте +и плотности; исходная запись не подменяется видеокодеком или предпросмотром. + +Управляющий канал отделён от WebRTC. Сигналинг проходит через локальную сессию +Node либо существующее mTLS-доверие Core. Нет публичного rendezvous/STUN/TURN; +используются частные адреса LAN/tailnet. Tailscale — сменный транспорт, а не +идентичность оборудования. WebRTC не инициирует аппаратный захват. + +## Приёмка + +Настройка из строки, прогресс и повторное открытие, одинаковое состояние в +Node/Core, переименование, глазик, профили, действительные кадры и все доступные +слои проверяются через интерфейс. Проверки кода и SSH-диагностика дополняют GUI, +но не заменяют её. Реальные эксперименты имеют приватный manifest с UTC, +monotonic, границами и SHA-256. Чистая установка остаётся отложенным владельцем +критерием; текущий подготовленный Mini не выдаётся за чистую систему. diff --git a/packages/sensor-ui/src/LiveViewport.tsx b/packages/sensor-ui/src/LiveViewport.tsx new file mode 100644 index 0000000..aaf3171 --- /dev/null +++ b/packages/sensor-ui/src/LiveViewport.tsx @@ -0,0 +1,37 @@ +import {useEffect,useRef,useState} from 'react'; +import {ActivityIndicator,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui-react'; +import {perform,type Sensor,type SensorTransport} from './contracts'; +import {PointViewport} from './PointViewport'; +interface Telemetry {points?:number[];motion?:Record;acquisition?:string;frame?:{observed_at:string}} +export function LiveViewport({device,layer,transport,failure}:{device:Sensor;layer:string;transport:SensorTransport;failure:(e:unknown)=>void}){ + const video=useRef(null);const frame=useRef(null);const [expanded,setExpanded]=useState(false); + const [state,setState]=useState('Подключаем просмотр');const [telemetry,setTelemetry]=useState({}); + useEffect(()=>{ + if(device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;} + let cancelled=false;let peerID='';let timer:ReturnType|undefined;let last=Date.now(); + const pc=new RTCPeerConnection({iceServers:[]});const channel=pc.createDataChannel('sensor',{ordered:false,maxRetransmits:0}); + if(layer!=='points'&&layer!=='motion')pc.addTransceiver('video',{direction:'recvonly'}); + pc.ontrack=event=>{if(video.current)video.current.srcObject=new MediaStream([event.track]);}; + pc.onconnectionstatechange=()=>{if(!cancelled)setState(pc.connectionState==='connected'?'Прямой эфир':pc.connectionState==='failed'?'Нет связи с камерой':'Подключаем просмотр');}; + channel.onmessage=event=>{try{const v=JSON.parse(event.data);last=Date.now();setTelemetry(v);}catch{ /* Ignore malformed preview, not control state. */ }}; + timer=setInterval(()=>{if(channel.readyState==='open')channel.send('keepalive');if(Date.now()-last>8000)setState('Нет свежих кадров');},2000); + async function connect(){try{ + await pc.setLocalDescription(await pc.createOffer()); + if(pc.iceGatheringState!=='complete')await new Promise((resolve,reject)=>{const t=setTimeout(()=>reject(new Error('Не удалось подготовить частный канал просмотра.')),10000);pc.addEventListener('icegatheringstatechange',()=>{if(pc.iceGatheringState==='complete'){clearTimeout(t);resolve();}});}); + if(cancelled)return; + const answer=await perform<{peer_id:string;sdp:string;type:'answer'}>(transport,device,'offer',{sdp:pc.localDescription!.sdp,layer});peerID=answer.peer_id; + if(cancelled){void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);return;} + await pc.setRemoteDescription({type:'answer',sdp:answer.sdp}); + }catch(e){if(!cancelled){setState('Просмотр недоступен');failure(e);}}} + void connect();return()=>{cancelled=true;if(timer)clearInterval(timer);pc.close();if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);}; + },[device.id,device.snapshot.context.session_id,device.snapshot.acquisition,layer,transport]); + useEffect(()=>{const escape=(e:KeyboardEvent)=>{if(e.key==='Escape')setExpanded(false);};document.addEventListener('keydown',escape);return()=>document.removeEventListener('keydown',escape);},[]); + const active=device.snapshot.acquisition==='streaming'; + return
    {state}setExpanded(!expanded)}>}> + {!active?

    Нажмите «Начать просмотр» или «Начать запись» для запуска камеры.

    :<> + {state==='Подключаем просмотр'&&} + {layer==='points'?:layer==='motion'?
    {Object.entries(telemetry.motion??{}).map(([kind,v])=>
    {kind==='accel'?'Ускорение · м/с²':'Угловая скорость · рад/с'}
    X {v.x.toFixed(3)} · Y {v.y.toFixed(3)} · Z {v.z.toFixed(3)}
    )}
    :
    ; +} diff --git a/packages/sensor-ui/src/PointViewport.tsx b/packages/sensor-ui/src/PointViewport.tsx new file mode 100644 index 0000000..c390d68 --- /dev/null +++ b/packages/sensor-ui/src/PointViewport.tsx @@ -0,0 +1,23 @@ +import {useEffect,useRef} from 'react'; + +/** Camera optical coordinates, metres. A bounded live preview, never the raw cloud. */ +export function PointViewport({points}:{points:number[]}) { + const ref=useRef(null);const orbit=useRef({yaw:0,pitch:0,scale:1});const data=useRef(points);data.current=points; + useEffect(()=>{ + const canvas=ref.current!;const ctx=canvas.getContext('2d')!;let dragging=false;let x=0,y=0; + const draw=()=>{const box=canvas.getBoundingClientRect();canvas.width=box.width*devicePixelRatio;canvas.height=box.height*devicePixelRatio;ctx.clearRect(0,0,canvas.width,canvas.height); + const color=getComputedStyle(canvas).getPropertyValue('--nodedc-text-primary');ctx.fillStyle=color.trim()||'currentColor'; + const a=orbit.current.yaw,b=orbit.current.pitch;const s=Math.min(canvas.width,canvas.height)*0.7*orbit.current.scale; + for(let i=0;i{dragging=true;x=e.clientX;y=e.clientY;canvas.setPointerCapture(e.pointerId);}; + const move=(e:PointerEvent)=>{if(!dragging)return;orbit.current.yaw+=(e.clientX-x)*.01;orbit.current.pitch+=(e.clientY-y)*.01;x=e.clientX;y=e.clientY;draw();}; + const up=()=>{dragging=false;};const wheel=(e:WheelEvent)=>{e.preventDefault();orbit.current.scale=Math.max(.2,Math.min(5,orbit.current.scale*Math.exp(-e.deltaY*.001)));draw();}; + canvas.addEventListener('pointerdown',down);canvas.addEventListener('pointermove',move);canvas.addEventListener('pointerup',up);canvas.addEventListener('wheel',wheel,{passive:false}); + draw();return()=>{resize.disconnect();clearInterval(timer);canvas.removeEventListener('pointerdown',down);canvas.removeEventListener('pointermove',move);canvas.removeEventListener('pointerup',up);canvas.removeEventListener('wheel',wheel);}; + },[]); + return ; +} diff --git a/packages/sensor-ui/src/SensorDetail.tsx b/packages/sensor-ui/src/SensorDetail.tsx new file mode 100644 index 0000000..52cdf09 --- /dev/null +++ b/packages/sensor-ui/src/SensorDetail.tsx @@ -0,0 +1,26 @@ +import {useEffect,useState} from 'react'; +import {ActivityIndicator,Button,Select,SettingsCard,TextField} from '@nodedc/ui-react'; +import {perform,type Sensor,type SensorTransport} from './contracts'; +import {LiveViewport} from './LiveViewport'; +export function SensorDetail({device,transport,back,refresh,failure}:{device:Sensor;transport:SensorTransport;back:()=>void;refresh:()=>Promise;failure:(e:unknown)=>void}){ + const [detail,setDetail]=useState(null);const [selected,setSelected]=useState>({});const [layer,setLayer]=useState('color');const [pending,setPending]=useState(false);const [option,setOption]=useState('');const [optionValue,setOptionValue]=useState(''); + async function load(){const value=await perform(transport,device,'details');setDetail(value);return value;} + useEffect(()=>{let live=true;if(device.prepared)void load().then(value=>{if(live){const selection:Record={};for(const p of value.profiles??[])if(value.defaults?.includes(p.id))selection[p.stream+':'+p.index]=p.id;setSelected(selection);}}).catch(failure);return()=>{live=false;};},[device.id,device.prepared,device.snapshot.context.session_id]); + async function act(action:string,parameters:Record={}){if(pending)return;setPending(true);try{await perform(transport,device,action,parameters);await refresh();await load();}catch(e){failure(e);}finally{setPending(false);}} + const groups=[...new Set((detail?.profiles??[]).map(p=>p.stream+':'+p.index))]; + const videoNames:Record={color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2',points:'Точки',motion:'Движение'}; + const available=[...new Set((detail?.profiles??[]).map(p=>p.stream==='infrared'?p.stream+p.index:p.stream==='gyro'||p.stream==='accel'?'motion':p.stream))];if(available.includes('depth'))available.push('points'); + const currentOption=detail?.options?.find(v=>v.id===option);const active=device.snapshot.acquisition==='streaming'||device.snapshot.acquisition==='starting'; + return
    + + {device.snapshot.message&&

    {device.snapshot.message}

    } +
    {pending&&}
    + {device.recording&&

    Идёт исходная запись на БК. Она продолжится после закрытия окна.

    } +
    + {detail&&<>p.stream+':'+p.index===group&&['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)).map(p=>({value:p.id,label:`${p.width?p.width+' × '+p.height+' · ':''}${p.fps} Гц · ${p.format}`}))]} onChange={value=>setSelected(v=>({...v,[group]:value}))} disabled={pending||active}/>)}
    +