Add D455 sensor host and shared Node/Core preparation surface

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 22:27:07 +03:00
parent 3616acc648
commit b1aaa40508
41 changed files with 2293 additions and 19 deletions
@@ -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<SensorTransport>(()=>({
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 <SensorWorkspace key={vehicleID} transport={transport} enabled={enabled}/>;
}
@@ -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
</dl>
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}
</SettingsCard>
<SettingsCard title="Устройства аппарата"><p>Устройства к аппарату ещё не подключены.</p></SettingsCard>
<SettingsCard title="Устройства аппарата"><VehicleSensors vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></SettingsCard>
</> : !fleet.items ? <ActivityIndicator label="Получаем аппараты" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="eye" /></IconButton>} /></li>)}</ResourceList>}
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавляем…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверяем БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
+6
View File
@@ -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
+1
View File
@@ -39,6 +39,7 @@ type PairState struct {
Revocations []CoreBinding `json:"revocations,omitempty"`
}
type Pairing struct {
Sensors *Sensors
mu sync.Mutex
path string
store *Store
@@ -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
+422
View File
@@ -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 &copy, 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 &copy, 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 &copy
}
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)
}
}
}
@@ -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")}}
+4
View File
@@ -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)
}
@@ -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;
}
});
@@ -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"
+20 -2
View File
@@ -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"!<arch>\n" + ar_member("debian-binary", b"2.0\n") + ar_member("control.tar.gz", tarball(controls)) + ar_member("data.tar.gz", tarball(files))
@@ -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)
@@ -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
@@ -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
+3
View File
@@ -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
+5
View File
@@ -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)
+7
View File
@@ -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
@@ -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
}
]
}
@@ -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)
@@ -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))
+13
View File
@@ -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()
+503
View File
@@ -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
+126
View File
@@ -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}
+161
View File
@@ -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)
+5
View File
@@ -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 <SensorWorkspace transport={transport}/>;}
+2
View File
@@ -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" ? <EnvironmentView environment={environment} failure={failure} success={node.success} /> : workspace.activeView === "overview" ? <NodeOverview value={value} refresh={refresh} failure={failure} openView={openView} />
: workspace.activeView === "sensors" ? <NodeSensors />
: workspace.activeView === "network" ? <NetworkView value={value} />
: workspace.activeView === "usb" ? <USBView value={value} />
: workspace.activeView === "core" ? <CoreConnectionView failure={failure} />
+3 -2
View File
@@ -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" },
+27 -4
View File
@@ -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"
]
}
+1 -1
View File
@@ -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"] },
});
@@ -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 не выдаётся за чистую систему.
+37
View File
@@ -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<string,{x:number;y:number;z:number}>;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<HTMLVideoElement>(null);const frame=useRef<HTMLDivElement>(null);const [expanded,setExpanded]=useState(false);
const [state,setState]=useState('Подключаем просмотр');const [telemetry,setTelemetry]=useState<Telemetry>({});
useEffect(()=>{
if(device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;}
let cancelled=false;let peerID='';let timer:ReturnType<typeof setInterval>|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<void>((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 <div ref={frame} className={expanded?'sensor-viewer sensor-viewer-expanded':'sensor-viewer'}><SettingsCard title={layer==='points'?'Облако точек':layer==='motion'?'Движение':({color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2'}[layer]??layer)} actions={<><StatusBadge tone={active&&state==='Прямой эфир'?'success':'neutral'}>{state}</StatusBadge><IconButton label={expanded?'Восстановить размер просмотра':'Развернуть просмотр'} onClick={()=>setExpanded(!expanded)}><Icon name={expanded?'minimize':'expand'}/></IconButton></>}>
{!active?<p>Нажмите «Начать просмотр» или «Начать запись» для запуска камеры.</p>:<>
{state==='Подключаем просмотр'&&<ActivityIndicator label="Открываем частный канал камеры"/>}
{layer==='points'?<PointViewport points={telemetry.points??[]}/>:layer==='motion'?<dl className="sensor-facts">{Object.entries(telemetry.motion??{}).map(([kind,v])=><div key={kind}><dt>{kind==='accel'?'Ускорение · м/с²':'Угловая скорость · рад/с'}</dt><dd>X {v.x.toFixed(3)} · Y {v.y.toFixed(3)} · Z {v.z.toFixed(3)}</dd></div>)}</dl>:<video ref={video} className="sensor-media" autoPlay muted playsInline/>}
<p className="sensor-note">{layer==='points'?'Предпросмотр до 2 000 точек · координаты камеры · метры':layer==='depth'?'Цветное представление глубины. Исходная глубина записывается без видеосжатия.':'Предпросмотр камеры. Исходная запись хранится на БК.'}</p>
</>}
</SettingsCard></div>;
}
+23
View File
@@ -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<HTMLCanvasElement>(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<data.current.length;i+=3){const [px,py,pz]=data.current.slice(i,i+3);const xx=px*Math.cos(a)+(pz-2)*Math.sin(a);const zz=-px*Math.sin(a)+(pz-2)*Math.cos(a);const yy=py*Math.cos(b)-zz*Math.sin(b);const z=py*Math.sin(b)+zz*Math.cos(b)+3;if(z<=0.05)continue;
ctx.globalAlpha=Math.min(1,Math.max(.2,1-pz/15));ctx.fillRect(canvas.width/2+xx/z*s,canvas.height/2+yy/z*s,2*devicePixelRatio,2*devicePixelRatio);
}ctx.globalAlpha=1;
};
const resize=new ResizeObserver(draw);resize.observe(canvas);const timer=setInterval(draw,250);
const down=(e:PointerEvent)=>{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 <canvas ref={ref} className="sensor-media" aria-label="Облако точек камеры в метрах. Перетаскивание вращает, прокрутка меняет масштаб."/>;
}
+26
View File
@@ -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<void>;failure:(e:unknown)=>void}){
const [detail,setDetail]=useState<Sensor|null>(null);const [selected,setSelected]=useState<Record<string,string>>({});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<Sensor>(transport,device,'details');setDetail(value);return value;}
useEffect(()=>{let live=true;if(device.prepared)void load().then(value=>{if(live){const selection:Record<string,string>={};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<string,unknown>={}){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<string,string>={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 <div className="sensor-content"><div><Button onClick={back}>К устройствам</Button></div>
<SettingsCard title={device.name} description={`${device.model} · USB ${device.usb}${device.firmware?' · '+device.firmware:''}`}>
{device.snapshot.message&&<p>{device.snapshot.message}</p>}
<div className="sensor-actions"><Button disabled={pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:false})}>Начать просмотр</Button><Button disabled={pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:true})}>Начать запись</Button><Button disabled={pending||(!active&&device.snapshot.acquisition!=='failed')} onClick={()=>void act('stop')}>Остановить захват</Button>{pending&&<ActivityIndicator label="Выполняем команду камеры"/>}</div>
{device.recording&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>}
</SettingsCard>
{detail&&<><Select label="Слой камеры" value={layer} options={available.map(value=>({value,label:videoNames[value]??value}))} onChange={setLayer}/><LiveViewport device={device} layer={layer} transport={transport} failure={failure}/>
<SettingsCard title="Профили потоков" description="Применяются при следующем запуске захвата."><div className="sensor-fields">{groups.map(group=><Select key={group} label={group} value={selected[group]??''} options={[{value:'',label:'Выключен'},...(detail.profiles??[]).filter(p=>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}/>)}</div></SettingsCard>
<SettingsCard title="Параметры камеры"><div className="sensor-fields"><Select searchable label="Параметр" value={option} options={(detail.options??[]).map(v=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<><TextField type="number" label={`${currentOption.min}${currentOption.max}${currentOption.read_only?' · только чтение':''}`} min={currentOption.min} max={currentOption.max} step={currentOption.step||'any'} value={optionValue} onChange={e=>setOptionValue(e.target.value)} disabled={pending||currentOption.read_only}/><Button disabled={pending||currentOption.read_only||optionValue===''} onClick={()=>void act('option',{id:option,value:Number(optionValue)})}>Применить параметр</Button></>}</div></SettingsCard>
<SettingsCard title="Исходные записи">{detail.recordings?.length?detail.recordings.map(item=><div className="sensor-record" key={item.id}><span>{new Date(item.started_at).toLocaleString('ru-RU')}</span><span>{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}</span><span>{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}</span></div>):<p>Записей пока нет.</p>}</SettingsCard></>}
{!detail&&<p>{device.prepared?'Получаем возможности камеры…':'Подготовьте устройство в списке.'}</p>}
</div>;
}
@@ -0,0 +1,24 @@
import {useCallback,useEffect,useState} from 'react';
import {ActivityIndicator,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
import {perform,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
import {SensorDetail} from './SensorDetail';
import './sensors.css';
export function SensorWorkspace({transport,enabled=true}:{transport:SensorTransport;enabled?:boolean}){
const [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<string|null>(null);const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
const failure=useCallback((e:unknown)=>{setError(e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
const refresh=useCallback(async()=>{if(!enabled){setFresh(false);return;}try{setInventory(await transport.inventory());setFresh(true);}catch(e){setFresh(false);failure(e);}},[transport,enabled,failure]);
useEffect(()=>{void refresh();const timer=setInterval(()=>void refresh(),3000);return()=>clearInterval(timer);},[refresh]);
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){if(localBusy)return;setBusy(device.id);try{await perform(transport,device,action,parameters);await refresh();setEditing(null);}catch(e){failure(e);}finally{setBusy(null);}}
const device=inventory?.items.find(v=>v.id===selected);
return <div className="sensor-workspace">{device?<SensorDetail device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure}/>:<>
<div className="sensor-actions"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>void refresh()}><Icon name="refresh"/></IconButton></div>
{!inventory?<ActivityIndicator label="Получаем устройства БК"/>:inventory.items.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите камеру к бортовому компьютеру."/>:<ResourceList aria-label="Устройства БК">{inventory.items.map(item=>{
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
const label=busy?'Подготовка или команда выполняется':!item.online?'Не подключено':item.snapshot.acquisition==='streaming'?item.recording?'Идёт запись':'Идёт захват':item.verified?'Проверено':item.prepared?'Драйвер установлен':'Нужно подготовить';
return <li key={item.id}><ResourceRow icon={<Icon name="camera"/>} title={item.name} description={item.model} metadata={<span>USB {item.usb}</span>} aria-busy={busy} status={<StatusBadge tone={item.verified&&item.online?'success':'neutral'}>{busy?<ActivityIndicator label={label}/>:item.verified&&item.online?<Icon name="check" label={label}/>:label}</StatusBadge>} actions={<><IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.snapshot.acquisition==='streaming'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton><IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton><IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
{(localBusy||inventory?.operations?.some(v=>v.state==='running'))&&inventory?.preparation&&<SettingsCard title="Подготовка устройства">{inventory.preparation.steps.map(step=><ResourceRow key={step.id} title={step.label} description={step.message} status={step.state==='complete'?<Icon name="check" label="Выполнено"/>:step.state==='running'?<ActivityIndicator label="Выполняется"/>:<StatusBadge>{step.state==='error'?'Ошибка':step.state==='blocked'?'Не выполнено':'Ожидает'}</StatusBadge>}/>)}</SettingsCard>}
</>}
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={!!localBusy} onClick={()=>setEditing(null)}>Отмена</Button><Button disabled={!!localBusy||!name.trim()} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={!!localBusy}/></Window>
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
</div>;
}
+40
View File
@@ -0,0 +1,40 @@
export interface SensorProfile { id: string; sensor: number; stream: string; index: number; format: string; fps: number; width?: number; height?: number }
export interface SensorOption { id: string; sensor: string; label: string; value: number; min: number; max: number; step: number; read_only: boolean }
export interface Sensor {
id: string; name: string; model: string; prepared: boolean; verified: boolean; online: boolean; usb: string; firmware?: string;
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
profiles?: SensorProfile[]; defaults?: string[]; options?: SensorOption[]; layers: string[];
frames?: Record<string,number>; last_frame?: {observed_at:string}; recording?: {id: string};
recordings?: {id:string;state:string;started_at:string;bytes?:number;sha256?:string}[];
}
export interface SensorInventory {
items: Sensor[]; operations: {operation_id:string;device_id:string;action_id:string;state:string;error?:string}[];
preparation?: {state:string;steps:{id:string;label:string;state:string;message?:string}[]};
}
export interface SensorCommand {
api_version: 'missioncore.nodedc/plugin-sdk/v0alpha2'; kind:'OperationRequest'; operation_id:string;
session: {session_id:string;device_id:string}; action_id:string; requested_at:string; deadline_at:string;
idempotency_key:string; parameters:Record<string,unknown>;
}
export interface SensorOperation {state:string;error?:string;result?:unknown}
export interface SensorTransport {
inventory: () => Promise<SensorInventory>;
submit: (value:SensorCommand) => Promise<SensorOperation>;
operation: (id:string) => Promise<SensorOperation>;
}
export function command(device:Sensor,action:string,parameters:Record<string,unknown>={}):SensorCommand {
const id='op_'+crypto.randomUUID().replaceAll('-','');const now=Date.now();
return {api_version:'missioncore.nodedc/plugin-sdk/v0alpha2',kind:'OperationRequest',operation_id:id,idempotency_key:id,
session:{session_id:device.snapshot.context.session_id,device_id:device.id},action_id:action,
requested_at:new Date(now).toISOString(),deadline_at:new Date(now+(action==='prepare'?350000:60000)).toISOString(),parameters};
}
export async function perform<T>(transport:SensorTransport,device:Sensor,action:string,parameters:Record<string,unknown>={}):Promise<T>{
const request=command(device,action,parameters);let value=await transport.submit(request);
const deadline=Date.parse(request.deadline_at)+10000;
while (value.state==='running'||value.state==='queued') {
if(Date.now()>deadline) throw new Error('Подтверждение пока не получено. Обновите состояние устройства.');
await new Promise(resolve=>setTimeout(resolve,1000));value=await transport.operation(request.operation_id);
}
if(value.state!=='complete') throw new Error(value.error||'Операция не завершена. Обновите состояние устройства.');
return value.result as T;
}
+10
View File
@@ -0,0 +1,10 @@
.sensor-workspace, .sensor-content {display:grid;gap:var(--nodedc-space-4);min-width:0}
.sensor-actions {display:flex;align-items:center;gap:var(--nodedc-space-3);flex-wrap:wrap}
.sensor-fields {display:grid;gap:var(--nodedc-space-4);grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}
.sensor-workspace p,.sensor-note {font-size:var(--nodedc-font-size-sm);color:var(--nodedc-text-secondary);line-height:1.5}
.sensor-facts {display:grid;gap:var(--nodedc-space-4);font-size:var(--nodedc-font-size-sm)}
.sensor-facts dd {margin:0}
.sensor-media {display:block;width:100%;height:360px;object-fit:contain;touch-action:none}
.sensor-viewer-expanded {position:fixed;inset:var(--nodedc-space-4);z-index:200;background:var(--nodedc-surface-1);overflow:auto}
.sensor-viewer-expanded .sensor-media {height:calc(100vh - 190px)}
.sensor-record {display:flex;flex-wrap:wrap;gap:var(--nodedc-space-4);padding-block:var(--nodedc-space-3);font-size:var(--nodedc-font-size-sm)}
+10 -7
View File
@@ -13,6 +13,7 @@ from cryptography import x509
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from missioncore_plugin_sdk.v0alpha2.identity import ExecutionBinding
from . import sensors
from .trust import SCHEMA, CoreTrust, PairingError, node_request, parse_invitation, pem, public_id
@@ -205,7 +206,10 @@ class FleetRegistry:
"last_seen": row["last_seen"],
"host": row["inventory"],
"execution_binding": row["runtime"],
"devices": [],
"devices": row.get("sensor_state", {}).get("items", []),
"sensor_state": row.get(
"sensor_state", {"items": [], "operations": [], "preparation": None}
),
"notice": row["notice"],
"core_endpoint": row["binding"]["endpoint"],
}
@@ -366,12 +370,10 @@ class FleetRegistry:
if path != "/v1/node/heartbeat":
return 404, {"error": "Unknown operation"}
binding = ExecutionBinding.model_validate(value["execution_binding"])
if (
binding.node_id != node_id
or binding.platform.value != "linux"
or value.get("devices") != []
):
if binding.node_id != node_id or binding.platform.value != "linux":
return 400, {"error": "Invalid Node inventory"}
sensor_state = sensors.validate_inventory(value, node_id)
sensor_response = sensors.heartbeat(row, value)
host = value.get("host")
if (
not isinstance(host, dict)
@@ -385,6 +387,7 @@ class FleetRegistry:
receipt=None,
last_seen=time.time(),
inventory=host,
sensor_state=sensor_state,
runtime=binding.model_dump(mode="json"),
notice="",
)
@@ -396,4 +399,4 @@ class FleetRegistry:
}
row["binding"]["client_pem"] = self.trust.leaf(node_id, key)
self.save(row)
return 200, {"ok": True, "client_pem": row["binding"]["client_pem"]}
return 200, {"ok": True, "client_pem": row["binding"]["client_pem"], **sensor_response}
+128
View File
@@ -0,0 +1,128 @@
"""Fleet projects Node-owned sensor state and forwards SDK operations, never hardware calls."""
import json
import time
from datetime import UTC, datetime
from missioncore_plugin_sdk.v0alpha2.operations import OperationRequest
from missioncore_plugin_sdk.v0alpha2.session import DeviceSessionSnapshot
from .trust import PairingError
ACTIONS = {
"prepare",
"details",
"rename",
"verify",
"start",
"stop",
"option",
"offer",
"close-peer",
}
def validate_inventory(value, node_id):
items = value.get("devices", [])
if not isinstance(items, list) or len(items) > 16:
raise ValueError("Invalid device inventory")
seen = set()
for item in items:
snapshot = DeviceSessionSnapshot.model_validate(item["snapshot"])
if (
snapshot.context.execution.node_id != node_id
or snapshot.context.device.device_id != item["id"]
or item["id"] in seen
):
raise ValueError("Invalid device execution binding")
seen.add(item["id"])
state = value.get("sensor_state", {"items": items, "operations": [], "preparation": None})
if state.get("items") != items or len(json.dumps(state)) > 262144:
raise ValueError("Invalid sensor state")
return state
def submit(fleet, vehicle_id, value):
command = OperationRequest.model_validate(value)
if (
command.action_id not in ACTIONS
or command.idempotency_key != command.operation_id
or len(json.dumps(value)) > 65536
):
raise PairingError("Неподдерживаемая команда устройства.")
with fleet.lock:
row = fleet.find(vehicle_id)
if fleet.public(row)["connectivity"] != "online":
raise PairingError("БК недоступен. Дождитесь связи перед настройкой устройства.")
existing = row.setdefault("sensor_commands", {}).get(command.operation_id)
if existing:
if existing["command"] != value:
raise PairingError("Идентификатор операции уже использован.")
return existing
if (
not datetime.now(UTC) < command.deadline_at
or (command.deadline_at - command.requested_at).total_seconds() > 360
):
raise PairingError("Срок команды истёк. Обновите устройство.")
item = next(
(
item
for item in row.get("sensor_state", {}).get("items", [])
if item["id"] == command.session.device_id
),
None,
)
if item is None or item["snapshot"]["context"]["session_id"] != command.session.session_id:
raise PairingError("Сеанс устройства изменился. Обновите сведения.")
row["sensor_commands"] = {
k: v for k, v in row["sensor_commands"].items() if time.time() - v["updated_at"] < 600
}
if len(row["sensor_commands"]) >= 32:
raise PairingError("Слишком много запросов к устройству. Повторите позже.")
operation = {"command": value, "state": "queued", "updated_at": time.time()}
row["sensor_commands"][command.operation_id] = operation
fleet.save(row)
return operation
def operation(fleet, vehicle_id, identifier):
with fleet.lock:
value = fleet.find(vehicle_id).get("sensor_commands", {}).get(identifier)
if value is None:
raise PairingError("Операция не найдена.")
if value["state"] in ("queued", "running") and datetime.fromisoformat(
value["command"]["deadline_at"]
) < datetime.now(UTC):
return {
**value,
"state": "unknown",
"error": "Подтверждение не получено. Обновите фактическое состояние устройства.",
}
return value
def heartbeat(row, value):
commands = row.setdefault("sensor_commands", {})
acknowledgements = []
results = value.get("sensor_results", [])
if not isinstance(results, list) or len(results) > 64:
raise ValueError("Invalid sensor operations")
for result in results:
command = result.get("command", {})
identifier = command.get("operation_id")
existing = commands.get(identifier)
if (
existing
and existing["command"] == command
and result.get("state") in ("running", "complete", "error", "unknown")
):
commands[identifier] = {**result, "updated_at": time.time()}
if result["state"] != "running":
acknowledgements.append(identifier)
pending = []
for entry in commands.values():
if entry["state"] == "queued" and datetime.fromisoformat(
entry["command"]["deadline_at"]
) > datetime.now(UTC):
pending.append(entry["command"])
return {"sensor_commands": pending[:4], "sensor_acknowledgements": acknowledgements}
+1 -1
View File
@@ -57,7 +57,7 @@ class NodeChannelHandler(BaseHTTPRequestHandler):
if (
self.headers.get("Content-Type") != "application/json"
or self.headers.get("Origin")
or not 0 < size <= 65536
or not 0 < size <= 1048576
):
raise ValueError
value = json.loads(self.rfile.read(size))
+24
View File
@@ -83,3 +83,27 @@ def fleet_revoke(vehicle_id: str, fleet: Annotated[FleetRegistry, Depends(local_
return fleet.revoke(vehicle_id)
except PairingError as error:
raise HTTPException(404, str(error)) from None
@router.post("/{vehicle_id}/devices/operations")
def sensor_command(
vehicle_id: str, body: dict, fleet: Annotated[FleetRegistry, Depends(local_operator)]
):
from k1link.fleet.sensors import submit
try:
return submit(fleet, vehicle_id, body)
except (PairingError, ValueError) as error:
raise HTTPException(409, str(error)) from None
@router.get("/{vehicle_id}/devices/operations/{operation_id}")
def sensor_operation(
vehicle_id: str, operation_id: str, fleet: Annotated[FleetRegistry, Depends(local_operator)]
):
from k1link.fleet.sensors import operation
try:
return operation(fleet, vehicle_id, operation_id)
except PairingError as error:
raise HTTPException(404, str(error)) from None
+87
View File
@@ -299,3 +299,90 @@ def test_bootstrap_pin_is_verified_before_secret_is_sent(setup, monkeypatch, wro
"ok": True
}
assert len(sent) == 1
def sensor_inventory(row):
now = datetime.now(UTC).isoformat()
item = {
"id": "rsd455_01234567890123456789012345678901",
"name": "Test camera",
"snapshot": {
"context": {
"session_id": "sensor_test",
"device": {
"device_id": "rsd455_01234567890123456789012345678901",
"model": {
"plugin_id": "missioncore.realsense",
"plugin_version": "0.6.0",
"model_id": "realsense.d455",
},
"stability": "stable",
"basis": "hardware-identifier",
},
"execution": {
"node_id": row["node_id"],
"agent_instance_id": "driver_test",
"platform": "linux",
},
"opened_at": now,
},
"revision": 0,
"enrollment": "empty",
"connectivity": "connected",
"acquisition": "idle",
"observed_at": now,
},
}
value = heartbeat(row)
value["devices"] = [item]
value["sensor_state"] = {"items": [item], "operations": [], "preparation": None}
return value
def test_sensor_commands_require_pairing_and_keep_uncertain_outcome(setup):
from k1link.fleet import sensors
fleet, _, _, _ = setup
public, _ = create(setup)
row = setup[0].find(public["id"])
inventory = sensor_inventory(row)
fleet.receive(cert(row), "/v1/node/heartbeat", inventory)
now = datetime.now(UTC)
command = {
"api_version": "missioncore.nodedc/plugin-sdk/v0alpha2",
"kind": "OperationRequest",
"operation_id": "op_01234567890123456789012345678901",
"idempotency_key": "op_01234567890123456789012345678901",
"session": {"device_id": inventory["devices"][0]["id"], "session_id": "sensor_test"},
"requested_at": now.isoformat(),
"deadline_at": (now + timedelta(seconds=60)).isoformat(),
"action_id": "start",
"parameters": {"record": True},
}
first = sensors.submit(fleet, row["id"], command)
assert first["state"] == "queued"
assert sensors.submit(fleet, row["id"], command) == first
with pytest.raises(PairingError):
sensors.submit(fleet, row["id"], {**command, "action_id": "stop"})
_, response = fleet.receive(cert(row), "/v1/node/heartbeat", inventory)
assert response["sensor_commands"] == [command]
inventory["sensor_results"] = [
{"command": command, "state": "unknown", "error": "test uncertainty"}
]
_, response = fleet.receive(cert(row), "/v1/node/heartbeat", inventory)
assert response["sensor_commands"] == []
assert sensors.operation(fleet, row["id"], command["operation_id"])["state"] == "unknown"
fleet.revoke(row["id"])
with pytest.raises(PairingError):
sensors.submit(fleet, row["id"], command)
def test_sensor_cannot_claim_another_board_identity(setup):
from k1link.fleet.sensors import validate_inventory
public, _ = create(setup)
row = setup[0].find(public["id"])
value = sensor_inventory(row)
value["devices"][0]["snapshot"]["context"]["execution"]["node_id"] = "node_wrong"
with pytest.raises(ValueError):
validate_inventory(value, row["node_id"])