feat(node): push USB changes and preserve sensor setup across sessions

This commit is contained in:
DCCONSTRUCTIONS
2026-09-06 00:24:48 +03:00
parent a8647c4d87
commit 22d69107ba
24 changed files with 636 additions and 60 deletions
+1
View File
@@ -115,6 +115,7 @@ func run() error {
go func() { errs <- private.Serve(unix) }()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go app.Sensors.WatchUSB(ctx)
go pairing.Run(ctx)
log.Print("Mission Core Node " + version + " listening on loopback")
select {
@@ -238,6 +238,12 @@ func (p *Pairing) send(ctx context.Context, b CoreBinding, path string, payload
}
func (p *Pairing) channel(ctx context.Context) {
instance := "agent_" + token()
var changed <-chan struct{}
if p.Sensors != nil {
var unsubscribe func()
changed, unsubscribe = p.Sensors.events.subscribe()
defer unsubscribe()
}
timer := time.NewTicker(5 * time.Second)
defer timer.Stop()
defer func() {
@@ -335,6 +341,12 @@ func (p *Pairing) channel(ctx context.Context) {
case <-ctx.Done():
return
case <-timer.C:
case <-changed:
select {
case <-ctx.Done():
return
case <-time.After(250 * time.Millisecond):
}
}
}
}
@@ -0,0 +1,137 @@
package node
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os/exec"
"strings"
"sync"
"time"
)
// Events are hints to reconcile OS/SDK state, never commands or trusted inventory.
// A bounded latest-state signal prevents a slow viewer from blocking discovery.
type sensorEvents struct {
mu sync.Mutex
listeners map[chan struct{}]bool
}
func (e *sensorEvents) subscribe() (<-chan struct{}, func()) {
e.mu.Lock()
defer e.mu.Unlock()
if e.listeners == nil {
e.listeners = map[chan struct{}]bool{}
}
c := make(chan struct{}, 1)
e.listeners[c] = true
return c, func() { e.mu.Lock(); delete(e.listeners, c); e.mu.Unlock() }
}
func (e *sensorEvents) notify() {
e.mu.Lock()
defer e.mu.Unlock()
for c := range e.listeners {
select {
case c <- struct{}{}:
default:
}
}
}
func usbEvents(input io.Reader, changed func()) {
scanner := bufio.NewScanner(input)
fields := map[string]string{}
for scanner.Scan() {
line := scanner.Text()
if line == "" {
if fields["SUBSYSTEM"] == "usb" && fields["DEVTYPE"] == "usb_device" &&
(fields["ACTION"] == "add" || fields["ACTION"] == "remove" || fields["ACTION"] == "change") {
changed()
}
fields = map[string]string{}
} else if key, value, ok := strings.Cut(line, "="); ok && len(fields) < 128 {
fields[key] = value
}
}
}
func (s *Sensors) WatchUSB(ctx context.Context) {
for ctx.Err() == nil {
cmd := exec.CommandContext(ctx, "/usr/bin/udevadm", "monitor", "--udev", "--subsystem-match=usb", "--property")
cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C"}
pipe, err := cmd.StdoutPipe()
if err == nil && cmd.Start() == nil {
usbEvents(pipe, s.events.notify)
_ = cmd.Wait()
}
// A failed monitor cannot disable the existing heartbeat reconciliation.
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
}
}
}
func (s *Sensors) stream(w http.ResponseWriter, r *http.Request, server *Server) {
if !server.authorized(w, r) {
return
}
if _, ok := w.(http.Flusher); !ok {
http.Error(w, "Streaming unavailable", 503)
return
}
changed, unsubscribe := s.events.subscribe()
defer unsubscribe()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("X-Accel-Buffering", "no")
controller := http.NewResponseController(w)
timer := time.NewTimer(0)
defer timer.Stop()
for {
select {
case <-r.Context().Done():
return
case <-changed:
// USB devices expose several interfaces; coalesce the enumeration burst.
select {
case <-r.Context().Done():
return
case <-time.After(250 * time.Millisecond):
}
case <-timer.C:
}
if !server.authorized(w, r) {
return
}
value := s.Inventory()
data, err := json.Marshal(value)
if err != nil {
return
}
_ = controller.SetWriteDeadline(time.Now().Add(10 * time.Second))
if _, err = fmt.Fprintf(w, "retry: 3000\ndata: %s\n\n", data); err != nil {
return
}
if controller.Flush() != nil {
return
}
delay := 15 * time.Second
for _, raw := range value["operations"].([]any) {
if raw.(map[string]any)["state"] == "running" {
delay = 2 * time.Second
break
}
}
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(delay)
}
}
@@ -0,0 +1,36 @@
package node
import (
"strings"
"testing"
)
func TestUSBEventsAreHintsAndDoNotMultiplyInterfaces(t *testing.T) {
input := "UDEV [1] add /devices/example (usb)\nACTION=add\nSUBSYSTEM=usb\nDEVTYPE=usb_device\n\n" +
"ACTION=add\nSUBSYSTEM=usb\nDEVTYPE=usb_interface\n\n" +
"ACTION=remove\nSUBSYSTEM=usb\nDEVTYPE=usb_device\n\n" +
"ACTION=add\nSUBSYSTEM=net\nDEVTYPE=usb_device\n\n"
e := sensorEvents{}
first, closeFirst := e.subscribe()
second, closeSecond := e.subscribe()
defer closeSecond()
count := 0
usbEvents(strings.NewReader(input), func() { count++; e.notify() })
if count != 2 || len(first) != 1 || len(second) != 1 {
t.Fatalf("events=%d first=%d second=%d", count, len(first), len(second))
}
<-first
closeFirst()
e.notify()
if len(first) != 0 {
t.Fatal("closed viewer still receives events")
}
}
func TestSensorEventStreamRequiresLocalSession(t *testing.T) {
s := newTestServer(t)
s.Sensors, _ = OpenSensors(t.TempDir(), "node_test")
if response := call(s, "GET", "/api/devices/events", "", nil); response.Code != 401 {
t.Fatal("unauthenticated device stream", response.Code)
}
}
+69 -17
View File
@@ -45,14 +45,16 @@ type SensorOperation struct {
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
events sensorEvents
mu sync.Mutex
prepareMu sync.Mutex
root string
nodeID string
instance string
client *http.Client
operations map[string]*SensorOperation
names map[string]string
initialized map[string]bool
}
var sensorID = regexp.MustCompile(`^rsd455_[0-9a-f]{32}$`)
@@ -63,7 +65,7 @@ func OpenSensors(root, nodeID string) (*Sensors, error) {
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 := &Sensors{root: dir, nodeID: nodeID, instance: "discovery_" + digest(token())[:24], operations: map[string]*SensorOperation{}, names: map[string]string{}, initialized: map[string]bool{}}
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")
}}}
@@ -85,6 +87,19 @@ func OpenSensors(root, nodeID string) (*Sensors, error) {
}
data, _ := os.ReadFile(filepath.Join(dir, "names.json"))
_ = json.Unmarshal(data, &s.names)
data, _ = os.ReadFile(filepath.Join(dir, "initialized.json"))
_ = json.Unmarshal(data, &s.initialized)
if s.initialized == nil {
s.initialized = map[string]bool{}
}
for _, op := range s.operations {
if op.Command.Action == "prepare" && op.State == "complete" {
s.initialized[op.Command.Session.DeviceID] = true
}
}
if e := s.write("initialized.json", s.initialized); e != nil {
return nil, e
}
return s, nil
}
func (s *Sensors) write(name string, value any) error {
@@ -163,6 +178,7 @@ func (s *Sensors) Inventory() map[string]any {
id, _ := item["id"].(string)
seen[id] = true
s.mu.Lock()
item["configured"] = s.initialized[id]
if n := s.names[id]; n != "" {
item["name"] = n
}
@@ -189,15 +205,21 @@ func (s *Sensors) Inventory() map[string]any {
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.6", "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}})
seen[id] = true
items = append(items, s.discovery(id, read("speed")+" Мбит/с", true))
}
s.mu.Lock()
configured := []string{}
for id, ready := range s.initialized {
if ready && sensorID.MatchString(id) && !seen[id] {
configured = append(configured, id)
}
}
s.mu.Unlock()
for _, id := range configured {
items = append(items, s.discovery(id, "—", false))
}
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)
@@ -212,6 +234,26 @@ func (s *Sensors) Inventory() map[string]any {
s.mu.Unlock()
return map[string]any{"schema": "missioncore.node.devices/v1", "items": items, "preparation": preparation, "operations": operations}
}
func (s *Sensors) discovery(id, speed string, online bool) map[string]any {
now := time.Now().UTC().Format(time.RFC3339Nano)
s.mu.Lock()
name, configured := s.names[id], s.initialized[id]
s.mu.Unlock()
if name == "" {
name = "RealSense D455"
}
connectivity, enrollment := "offline", "empty"
if online {
connectivity = "connected"
}
if configured {
enrollment = "enrolled"
}
return map[string]any{"id": id, "name": name, "model": "RealSense D455", "configured": configured, "prepared": false, "verified": false, "online": online, "usb": 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.6", "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": enrollment, "connectivity": connectivity, "acquisition": "idle", "observed_at": now}}
}
func sensorViewAction(action string) bool {
return action == "details" || action == "offer" || action == "close-peer"
}
@@ -267,10 +309,12 @@ func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error)
}
s.operations[c.ID] = value
copy := *value
s.events.notify()
go s.execute(c)
return &copy, nil
}
func (s *Sensors) execute(c SensorCommand) {
defer s.events.notify()
var result any
var err error
uncertain := false
@@ -313,6 +357,13 @@ func (s *Sensors) execute(c SensorCommand) {
}
s.mu.Lock()
defer s.mu.Unlock()
if err == nil && c.Action == "prepare" {
s.initialized[c.Session.DeviceID] = true
if e := s.write("initialized.json", s.initialized); e != nil {
err = e
uncertain = true
}
}
v := s.operations[c.ID]
v.Updated = time.Now().Unix()
if err != nil {
@@ -394,6 +445,7 @@ func (s *Sensors) RemoteResults() []any {
return out
}
func (s *Sensors) Routes(mux *http.ServeMux, server *Server) {
mux.HandleFunc("GET /api/devices/events", func(w http.ResponseWriter, r *http.Request) { s.stream(w, r, server) })
mux.HandleFunc("GET /api/devices", func(w http.ResponseWriter, r *http.Request) {
if server.authorized(w, r) {
reply(w, 200, s.Inventory())
@@ -84,3 +84,28 @@ func TestSensorPreservesUncertainDriverOutcome(t *testing.T) {
})
}
}
func TestSensorConfiguredIdentitySurvivesRestartAndDisconnect(t *testing.T) {
root := t.TempDir()
s, _ := OpenSensors(root, "node_test")
c := sensorTestCommand()
s.initialized[c.Session.DeviceID] = true
if e := s.write("initialized.json", s.initialized); e != nil {
t.Fatal(e)
}
s, e := OpenSensors(root, "node_test")
if e != nil {
t.Fatal(e)
}
s.client = &http.Client{Transport: sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"items":[]}`)), Header: http.Header{}}, nil
})}
items := s.Inventory()["items"].([]any)
if len(items) != 1 {
t.Fatalf("configured camera disappeared: %d", len(items))
}
item := items[0].(map[string]any)
if item["id"] != c.Session.DeviceID || item["online"] != false || item["configured"] != true {
t.Fatalf("incorrect offline identity: %+v", item)
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ import sys
from build_deb import build, VERSION, BRAND_SHA256
ROOT = Path(__file__).resolve().parents[1]
DG_COMMIT = "17e150b1c74ab8a345fe34ce51dccd5bb862fa85"
DG_COMMIT = "be6463fd596ae2a1a96409d2cee84edc90e7167f"
def guideline_sources():
+1 -1
View File
@@ -13,7 +13,7 @@ import tarfile
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.6.6"
VERSION = "0.6.8"
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
+54 -12
View File
@@ -93,13 +93,30 @@ class Device:
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":
if value.get("state") in ("recording", "finalizing"):
value.update(state="interrupted", recovered_at=utc())
atomic(path, value)
def save(self):
atomic(self.root / "config.json", self.config)
def disconnect(self):
with self.lock:
if not self.online:
return
self.online = False
self.verified_this_process = False
self.sdk_device = None
# Stored playback is independent from a physical USB camera.
live = self.pipeline is not None and self.playback_id is None
if self.playback_id is None:
self.images = {}
self.motion = {}
self.depth = None
self.message = "Камера отключена от БК."
if live:
self.stop(failed=True)
def refresh(self, dev):
with self.lock:
self.sdk_device = dev
@@ -110,7 +127,7 @@ class Device:
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:
if self.pipeline is not None or self.acquisition == "stopping":
return
profiles, options = [], []
for index, sensor in enumerate(dev.query_sensors()):
@@ -254,7 +271,7 @@ class Device:
def start(self, selected=None, record=False):
with self.lock:
if self.pipeline is not None:
if self.pipeline is not None or self.acquisition == "stopping":
raise ValueError("Захват уже запущен. Сначала остановите его.")
if not self.online or self.sdk_device is None:
raise ValueError("Камера не подключена.")
@@ -352,7 +369,7 @@ class Device:
if not isinstance(ident, str) or not re.fullmatch(r"capture_[0-9a-f]{32}", ident):
raise ValueError("Некорректная запись.")
with self.lock:
if self.pipeline is not None:
if self.pipeline is not None or self.acquisition == "stopping":
raise ValueError("Сначала остановите текущий захват или просмотр записи.")
directory = self.root / "recordings" / ident
source, manifest = directory / "source.db3", directory / "manifest.json"
@@ -440,26 +457,39 @@ class Device:
def stop(self, failed=False, from_capture=False):
with self.lock:
if self.acquisition == "stopping":
raise ValueError("Исходная запись ещё сохраняется. Дождитесь завершения.")
self.stop_event.set()
pipeline, self.pipeline = self.pipeline, None
if pipeline is not None:
self.acquisition = "stopping"
pipeline.stop()
try:
pipeline.stop()
except RuntimeError:
# Removal may invalidate the SDK handle before STOP reaches it.
failed = True
self.message = "Захват прерван. Проверьте подключение камеры."
del pipeline
if self.thread and not from_capture:
self.thread.join(timeout=3)
self.playback_id = None
self.acquisition = "failed" if failed else "idle"
value = dict(self.record) if self.record else None
self.acquisition = "stopping" if value else "failed" if failed else "idle"
self.revision += 1
if self.record:
value = dict(self.record)
if value:
value.update(
state="failed" if failed else "complete",
state="finalizing",
ended_at=utc(),
ended_monotonic_ns=time.monotonic_ns(),
frames=dict(self.frames),
)
self.record = value
path = self.root / "recordings" / value["id"]
atomic(path / "manifest.json", value)
if value:
# Hashing a large source must not lock inventory or heartbeat. The
# stopping state still prevents START, preparation and package updates.
try:
source = path / "source.db3"
if source.exists():
digest = hashlib.sha256()
@@ -467,12 +497,24 @@ class Device:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
digest.update(chunk)
value.update(sha256=digest.hexdigest(), bytes=source.stat().st_size)
else:
failed = True
value["state"] = "failed" if failed else "complete"
atomic(path / "manifest.json", value)
self.record = None
return {"ok": True}
except OSError:
failed = True
self.message = "Не удалось завершить сохранение исходной записи. Проверьте диск БК."
value["state"] = "failed"
atomic(path / "manifest.json", value)
finally:
with self.lock:
self.record = None
self.acquisition = "failed" if failed else "idle"
self.revision += 1
return {"ok": True}
def verify(self):
if self.pipeline is not None:
if self.pipeline is not None or self.acquisition == "stopping":
raise ValueError("Остановите захват перед повторной проверкой.")
try:
self.start()
+1 -2
View File
@@ -64,8 +64,7 @@ class Host:
self.devices[ident].refresh(dev)
for ident, device in self.devices.items():
if ident not in found:
device.online = False
device.verified_this_process = False
device.disconnect()
await asyncio.to_thread(work)
+1 -1
View File
@@ -1,5 +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))};
const transport:SensorTransport={subscribe:(receive,unavailable)=>{const events=new EventSource('/api/devices/events');events.onmessage=e=>{try{receive(JSON.parse(e.data));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},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}/>;}
+1 -1
View File
@@ -28,7 +28,7 @@ export function desktopAction(action: DesktopAction): boolean {
}
export function desktopLogin(): boolean { return desktopAction("authorize"); }
export async function request<T>(path: string, method = "GET", body?: unknown): Promise<T> {
const res = await fetch(path, { method, credentials: "same-origin", cache: "no-store", headers: body === undefined ? {} : {"Content-Type": "application/json"}, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(10000) });
const res = await fetch(path, { method, credentials: "same-origin", cache: "no-store", headers: body === undefined ? {} : {"Content-Type": "application/json"}, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(10000) }).catch(() => { throw new Error("БК не ответил вовремя. Обновите сведения; действие могло продолжиться на борту."); });
if (!res.ok) {
const error = await res.json().catch(() => ({}));
throw new APIError(error.error ?? "Не удалось выполнить запрос к ноде", res.status);
+2
View File
@@ -1,6 +1,8 @@
import { defineConfig } from "vite";
export default defineConfig({
// Shared sensor TSX lives outside this app tsconfig; use the same JSX runtime.
esbuild: { jsx: "automatic" },
// 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", "@nodedc/ui-react"] },