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
@@ -37,14 +37,25 @@ export function useFleet() {
}, []);
useEffect(() => {
let active = true;
let timer: ReturnType<typeof setTimeout>;
async function poll() {
let fallback: ReturnType<typeof setInterval> | undefined;
const read = async () => {
try { const value = await fleetRequest<{ items: Vehicle[] }>(); if (active) { setItems(value.items); setError(""); } }
catch { if (active) setError("Реестр недоступен. Показаны последние полученные сведения; связь сейчас не подтверждена."); }
finally { if (active) timer = setTimeout(poll, 5000); }
}
void poll();
return () => { active = false; clearTimeout(timer); };
};
void read();
const events = new EventSource("/api/v1/fleet/events");
events.onmessage = event => {
if (!active) return;
try { const value = JSON.parse(event.data); setItems(value.items); setError(""); if (fallback) { clearInterval(fallback); fallback = undefined; } }
catch { unavailable(); }
};
const unavailable = () => {
if (!active) return;
setError("Связь обновляется. Показаны последние полученные сведения.");
if (!fallback) fallback = setInterval(() => void read(), 5000);
};
events.onerror = unavailable;
return () => { active = false; events.close(); if (fallback) clearInterval(fallback); };
}, []);
return { items, error, refresh };
}
@@ -4,7 +4,8 @@ import type {SensorInventory,SensorTransport} from '../../../../../packages/sens
import {fleetRequest} from '../../core/fleet/useFleet';
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
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;},
inventory:async()=>{const fleet=await fleetRequest<{items:{id:string;connectivity:string;sensor_state:SensorInventory}[]}>();const value=fleet.items.find(v=>v.id===vehicleID);if(!value)throw new Error('Аппарат не найден.');return {...value.sensor_state,fresh:value.connectivity==='online'};},
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{const value=JSON.parse(e.data).items.find((v:{id:string})=>v.id===vehicleID);if(value)receive({...value.sensor_state,fresh:value.connectivity==='online'});else unavailable();}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value),
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
}),[vehicleID]);
+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"] },
+72 -1
View File
@@ -110,5 +110,76 @@ UTC/monotonic timestamps, счётчики и SHA-256 завершённого
нужно остановить.
Первый тест исходной записи обнаружил отказ SDK от расширения .bag; это
исправлено в профиле 0.6.6 и требует повторной аппаратной проверки. SDK
исправлено в профиле 0.6.6 и аппаратно проверено в 0.6.7. SDK
[документирует формат .db3](https://github.com/realsenseai/librealsense/blob/master/doc/record-and-playback.md).
Обновление приёмки: запись .db3 и её RGB/depth-воспроизведение проверены в
общем локальном UI Node. В 0.6.7 исправлен автоматический JSX runtime для
общего модуля вне tsconfig приложения. 0.6.8 выносит подсчёт SHA-256 за mutex
инвентаризации: состояние остаётся stopping/finalizing и запрещает новый захват,
но системные сведения и heartbeat продолжают отвечать. Отдельный тест удерживает
хеширование и проверяет свободный inventory-lock и отказ нового START.
Последняя визуальная корректировка владельца: после успешной подготовки
отображается только одна лампочка (StatusBadge indicator), без галочки и
дублирующего текста. Доступное название и подсказка сохраняют смысл состояния.
Полная аппаратная приёмка в нативном GTK/WebKit-окне, работа через tailnet вне
LAN, смена USB-топологии/перезагрузка и чистая система не объявлены пройденными.
На момент этой записи применение нового replay action в работающем Core
ожидает отдельного разрешения на перезапуск: автоматическая проверка
интерпретировала просьбу «не прерывать процесс» как запрет останавливать сервер.
Корректировка владельца 06.09.2026: кнопка первой подготовки удаляется из
строки после успешной инициализации. Повторное действие находится в настройках:
«Конфигурация на БК → Обновить» и вызывает тот же профиль, без произвольных
команд или сетевого скачивания. Результат первой подготовки сохраняется
независимо от текущей связи/сеанса драйвера. По следующему уточнению владельца отключённый сенсор исчезает из списка
подключённых устройств в обоих UI. Его приватная запись, имя и настройки сохраняются;
действие подготовки требует подключения. Перезапуск службы не возвращает
первичную кнопку. Проверка перехода restart/offline добавлена в Go-тест.
## Подключение и отключение USB: уточнение 06.09.2026
Оператору не нужен Refresh. Первичное перечисление даёт полный снимок Linux
sysfs и SDK. События udev add/remove/change для USB device запускают сверку:
событие — только сигнал, его поля никогда не являются командой или готовым
описанием устройства. Серия событий интерфейсов объединяется; один физический
D455 имеет одну аппаратную идентичность, USB-адрес/порт могут измениться.
Node слушает фиксированный `udevadm monitor --udev --subsystem-match=usb
--property` без root, через уже разрешённый AF_NETLINK. Событие будит
существующий исходящий mTLS-канал и локальные подписки. Node и Core отправляют
снимки своим интерфейсам по SSE: одно соединение, новые состояния приходят от
сервера. Core ничего не опрашивает в USB на операторском компьютере. LAN или
Tailscale переносит управляющие сообщения; USB не пробрасывается. SDK драйвера
остаётся единственным владельцем камеры и потоков на БК.
Heartbeat раз в 5 секунд сохранён для связи, доставки команд и восстановления
пропущенных событий, поэтому обнаружение не зависит исключительно от monitor.
Локальный SSE пересылает контрольный снимок раз в 15 секунд, при операции — раз
в 2 секунды. При потере SSE UI временно использует опрос раз в 3 секунды и
автоматически возвращается на push. Новое соединение всегда получает полный
снимок: потерянная последовательность событий не оставляет фантомные строки.
Нет обещания атомарного обновления двух экранов: локальный экран обновляется
первым, Core — после передачи того же состояния по действующему доверию.
Подключение: строка появляется автоматически; имя и факт первой подготовки
берутся из сохранённой аппаратной идентичности. Новый сеанс не выдаётся за
новую установку. Лампочка готовности требует актуальных данных, а старый успех
проверки не выдаётся за действующий захват. Сама установка и подготовка не
запускаются просто от появления USB.
Отключение: строка исчезает из списка подключённых. Открытый экран сохраняет
контекст с сообщением об отключении; старые кадры очищаются. Живой захват
останавливается, незавершённая исходная запись получает failed/interrupted;
она не удаляется и не возобновляется сама. Просмотр уже сохранённого файла
не зависит от USB. Повторное подключение открывает новый сеанс; прежние команды
START с предыдущим сеансом отвергаются. Вторая физическая D455 с другим serial
не наследует имя и подготовку первой.
Потеря связи с БК не означает отключение USB. Core сохраняет последние сведения,
отмечает недоступность БК и запрещает аппаратные команды до свежего подтверждения.
Физические unplug/replug и смена USB-порта требуют отдельной аппаратной приёмки;
автоматическое пересоздание sandbox IIO при смене путей пока не подтверждено.
+10 -8
View File
@@ -2,27 +2,29 @@ import {useEffect,useState} from 'react';
import {ActivityIndicator,Button,Icon,IconButton,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}){
export function SensorDetail({device,transport,back,refresh,failure,enabled=true}:{enabled?:boolean;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;failure(null);setPending(true);try{await perform(transport,device,action,parameters);await refresh();await load();}catch(e){failure(e);}finally{setPending(false);}}
async function act(action:string,parameters:Record<string,unknown>={}){if(pending||!enabled)return;failure(null);setPending(true);try{await perform(transport,device,action,parameters);await refresh();await load();}catch(e){failure(e);}finally{setPending(false);}}
const supported=(detail?.profiles??[]).filter(p=>['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)&&(p.stream!=='infrared'||[1,2].includes(p.index)));
const groups=[...new Set(supported.map(p=>p.stream+':'+p.index))];
const videoNames:Record<string,string>={color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2',points:'Точки',motion:'Движение'};
const available=[...new Set(supported.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';
const currentOption=detail?.options?.find(v=>v.id===option);const active=device.snapshot.acquisition==='streaming'||device.snapshot.acquisition==='starting'||device.snapshot.acquisition==='stopping';
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.online&&<p>Камера отключена от БК.</p>}
{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')}>{device.playback_id?'Закрыть запись':'Остановить захват'}</Button>{pending&&<ActivityIndicator label="Выполняем команду камеры"/>}</div>
<div className="sensor-actions"><Button disabled={!enabled||pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:false})}>Начать просмотр</Button><Button disabled={!enabled||pending||!detail||active||!device.online} onClick={()=>void act('start',{profiles:Object.values(selected).filter(Boolean),record:true})}>Начать запись</Button><Button disabled={!enabled||pending||(!active&&device.snapshot.acquisition!=='failed')} onClick={()=>void act('stop')}>{device.playback_id?'Закрыть запись':'Остановить захват'}</Button>{pending&&<ActivityIndicator label="Выполняем команду камеры"/>}</div>
{device.playback_id&&<p>Просмотр исходной записи · повтор. Камера для него не запускается.</p>}
{device.recording&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>}
{device.snapshot.acquisition==='stopping'&&<p>Сохраняем исходную запись и проверяем её целостность.</p>}
{device.recording&&device.snapshot.acquisition!=='stopping'&&<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||!!device.playback_id}/><Button disabled={pending||currentOption.read_only||!!device.playback_id||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><IconButton label={`Открыть запись: ${new Date(item.started_at).toLocaleString('ru-RU')}`} disabled={pending||active||item.state!=='complete'} onClick={()=>void act('replay',{recording_id:item.id})}><Icon name="eye"/></IconButton></div>):<p>Записей пока нет.</p>}</SettingsCard></>}
<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={!enabled||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={!enabled||pending||currentOption.read_only||!!device.playback_id}/><Button disabled={!enabled||pending||currentOption.read_only||!!device.playback_id||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==='finalizing'?'Сохранение записи':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}</span><span>{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}</span><IconButton label={`Открыть запись: ${new Date(item.started_at).toLocaleString('ru-RU')}`} disabled={!enabled||pending||active||item.state!=='complete'} onClick={()=>void act('replay',{recording_id:item.id})}><Icon name="eye"/></IconButton></div>):<p>Записей пока нет.</p>}</SettingsCard></>}
{!detail&&<p>{device.prepared?'Получаем возможности камеры…':'Подготовьте устройство в списке.'}</p>}
</div>;
}
+18 -7
View File
@@ -6,21 +6,32 @@ import './sensors.css';
export function SensorWorkspace({transport,enabled=true,onDetailChange}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void}){
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===null?'':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]);
const refresh=useCallback(async()=>{if(!enabled){setFresh(false);return;}try{const value=await transport.inventory();setInventory(value);setFresh(value.fresh!==false);}catch(e){setFresh(false);failure(e);}},[transport,enabled,failure]);
useEffect(()=>{
if(!enabled){setFresh(false);return;}
let active=true;let fallback:ReturnType<typeof setInterval>|undefined;
const unavailable=()=>{if(!active)return;setFresh(false);if(!fallback)fallback=setInterval(()=>void refresh(),3000);};
void refresh();
const close=transport.subscribe?.(value=>{if(!active)return;if(fallback){clearInterval(fallback);fallback=undefined;}setInventory(value);setFresh(value.fresh!==false);},unavailable);
if(!close)unavailable();
return()=>{active=false;close?.();if(fallback)clearInterval(fallback);};
},[transport,enabled,refresh]);
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){if(localBusy)return;setError('');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);
const connected=inventory?.items.filter(v=>v.online)??[];
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
return <div className="sensor-workspace">{device?<SensorDetail device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure}/>:<>
return <div className="sensor-workspace">{device?<SensorDetail enabled={enabled&&fresh} 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=>{
{!inventory?<ActivityIndicator label="Получаем устройства БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите камеру к бортовому компьютеру."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
const operation=inventory.operations?.find(v=>v.device_id===item.id&&v.state==='running');const busy=!!operation||localBusy===item.id;
const configured=item.configured??item.snapshot.enrollment==='enrolled';
const prep=operation?.action_id==='prepare'&&inventory.preparation&&(inventory.preparation.started_at*1000>=Date.parse(operation.requested_at)-1000)?inventory.preparation:undefined;
const label=busy?'Подготовка или команда выполняется':!item.online?'Не подключено':item.snapshot.acquisition==='streaming'?item.recording?'Идёт запись':item.playback_id?'Просмотр записи':'Идёт захват':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} progress={busy?{label, value:prep?.state==='running'?prep.steps.filter(s=>s.state==='complete').length/(prep.steps.length+1):prep?.state==='complete'?5/6:undefined,valueText:prep?.steps.find(s=>s.state==='running')?.label??'Проверка кадров камеры'}:undefined} status={<StatusBadge tone={item.verified&&item.online?'success':'neutral'}>{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>}
const label=busy?'Подготовка или команда выполняется':!item.online?'Не подключено':item.snapshot.acquisition==='streaming'?item.recording?'Идёт запись':item.playback_id?'Просмотр записи':'Идёт захват':item.verified?'Проверено':configured?'Настроено, ожидает проверки камеры':'Требуется подготовка';
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} progress={busy?{label, value:prep?.state==='running'?prep.steps.filter(s=>s.state==='complete').length/(prep.steps.length+1):prep?.state==='complete'?5/6:undefined,valueText:prep?.steps.find(s=>s.state==='running')?.label??'Проверка кадров камеры'}:undefined} status={<StatusBadge variant={configured&&item.online?'indicator':'badge'} tone={item.verified&&item.online?'success':'neutral'} aria-label={label} title={label}>{configured&&item.online?null:label}</StatusBadge>} actions={<>{!configured&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} 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>}
{(inventory?.operations?.some(v=>v.state==='running'&&v.action_id==='prepare'&&!!inventory.preparation&&inventory.preparation.started_at*1000>=Date.parse(v.requested_at)-1000))&&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>}/>) }<ResourceRow title="Проверка кадров камеры" status={inventory.preparation.state==='complete'?<ActivityIndicator label="Проверяем потоки"/>:<StatusBadge>Ожидает</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>
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={!!localBusy} onClick={()=>setEditing(null)}>Отмена</Button><Button disabled={!!localBusy||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={!!localBusy}/>{editing&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={!!localBusy||!enabled||!fresh||!editingCurrent?.online||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
</div>;
}
+3 -2
View File
@@ -1,14 +1,14 @@
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; playback_id?: string | null;
id: string; name: string; model: string; prepared: boolean; configured?: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null;
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;requested_at:string;state:string;error?:string}[];
fresh?: boolean; items: Sensor[]; operations: {operation_id:string;device_id:string;action_id:string;requested_at:string;state:string;error?:string}[];
preparation?: {state:string;started_at:number;steps:{id:string;label:string;state:string;message?:string}[]};
}
export interface SensorCommand {
@@ -19,6 +19,7 @@ export interface SensorCommand {
export interface SensorOperation {state:string;error?:string;result?:unknown}
export interface SensorTransport {
inventory: () => Promise<SensorInventory>;
subscribe?: (receive:(value:SensorInventory)=>void, unavailable:()=>void) => (()=>void);
submit: (value:SensorCommand) => Promise<SensorOperation>;
operation: (id:string) => Promise<SensorOperation>;
}
+34
View File
@@ -0,0 +1,34 @@
"""Bounded notifications from the registry writer to local operator streams."""
import asyncio
import threading
class FleetEvents:
def __init__(self):
self.lock = threading.Lock()
self.listeners = set()
def subscribe(self):
loop = asyncio.get_running_loop()
queue = asyncio.Queue(maxsize=1)
def put_latest():
if not queue.full():
queue.put_nowait(None)
listener = (loop, put_latest)
with self.lock:
self.listeners.add(listener)
def close():
with self.lock:
self.listeners.discard(listener)
return queue, close
def notify(self):
with self.lock:
for loop, callback in self.listeners:
if not loop.is_closed():
loop.call_soon_threadsafe(callback)
+4
View File
@@ -27,6 +27,9 @@ class FleetRegistry:
self.started_at = time.time()
self.root = root
self.lock = threading.RLock()
from .events import FleetEvents
self.events = FleetEvents()
self.trust = CoreTrust(root)
path = root / "fleet.sqlite3"
if path.is_symlink():
@@ -58,6 +61,7 @@ class FleetRegistry:
"ON CONFLICT(id) DO UPDATE SET body=excluded.body",
(row["id"], row["node_id"], json.dumps(row)),
)
self.events.notify()
def find(self, identifier):
result = self.db.execute("SELECT body FROM vehicles WHERE id=?", (identifier,)).fetchone()
+29
View File
@@ -2,11 +2,15 @@
from __future__ import annotations
import asyncio
import ipaddress
import json
from contextlib import suppress
from typing import Annotated
from urllib.parse import urlsplit
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, Field
from k1link.fleet.registry import FleetRegistry
@@ -53,6 +57,31 @@ def fleet_list(response: Response, fleet: Annotated[FleetRegistry, Depends(local
return fleet.listing()
@router.get("/events")
async def fleet_events(fleet: Annotated[FleetRegistry, Depends(local_operator)]):
async def stream():
queue, close = fleet.events.subscribe()
try:
while not fleet.stop.is_set():
# Full replacement snapshots recover missed events/reconnections.
# The timeout updates link expiry locally; it never queries a Node.
value = await asyncio.to_thread(fleet.listing)
yield "retry: 3000\ndata: " + json.dumps(value) + "\n\n"
with suppress(TimeoutError):
await asyncio.wait_for(queue.get(), timeout=5)
finally:
close()
return StreamingResponse(
stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-store",
"X-Accel-Buffering": "no",
},
)
@router.post("/preview")
def fleet_preview(
body: PreviewRequest,
+23
View File
@@ -0,0 +1,23 @@
import asyncio
import threading
from k1link.fleet.events import FleetEvents
def test_registry_events_cross_thread_coalesce_and_unsubscribe():
async def check():
events = FleetEvents()
queue, close = events.subscribe()
thread = threading.Thread(target=lambda: [events.notify() for _ in range(20)])
thread.start()
thread.join()
await asyncio.sleep(0)
assert queue.qsize() == 1
await queue.get()
close()
events.notify()
await asyncio.sleep(0)
assert queue.empty()
assert not events.listeners
asyncio.run(check())
+83
View File
@@ -0,0 +1,83 @@
"""Record finalization must leave the board inventory responsive."""
import importlib.util
import threading
import types
from pathlib import Path
import pytest
def test_hashing_keeps_inventory_lock_free_and_rejects_new_capture(monkeypatch, tmp_path):
monkeypatch.setitem(__import__("sys").modules, "pyrealsense2", types.ModuleType("pyrealsense2"))
path = Path(__file__).parents[2] / "apps/node-agent/sensors/device.py"
spec = importlib.util.spec_from_file_location("recording_device", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
device = module.Device("synthetic-camera", tmp_path, {})
ident = "capture_" + "a" * 32
root = device.root / "recordings" / ident
root.mkdir(parents=True)
(root / "source.db3").write_bytes(b"synthetic record bytes")
device.record = {"id": ident, "state": "recording"}
entered, release = threading.Event(), threading.Event()
real = module.hashlib.sha256
class SlowHash:
def __init__(self):
self.digest = real()
def update(self, value):
entered.set()
assert release.wait(3)
self.digest.update(value)
def hexdigest(self):
return self.digest.hexdigest()
monkeypatch.setattr(module.hashlib, "sha256", SlowHash)
worker = threading.Thread(target=device.stop)
worker.start()
try:
assert entered.wait(2)
assert device.lock.acquire(timeout=0.2)
try:
assert device.acquisition == "stopping"
with pytest.raises(ValueError):
device.start()
finally:
device.lock.release()
finally:
release.set()
worker.join(3)
assert not worker.is_alive()
assert device.acquisition == "idle"
assert device.record is None
assert module.json.loads((root / "manifest.json").read_text())["state"] == "complete"
def test_disconnect_finalizes_failed_record_and_clears_live_frames(monkeypatch, tmp_path):
monkeypatch.setitem(__import__("sys").modules, "pyrealsense2", types.ModuleType("pyrealsense2"))
path = Path(__file__).parents[2] / "apps/node-agent/sensors/device.py"
spec = importlib.util.spec_from_file_location("disconnect_device", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
device = module.Device("synthetic-camera", tmp_path, {})
ident = "capture_" + "b" * 32
root = device.root / "recordings" / ident
root.mkdir(parents=True)
(root / "source.db3").write_bytes(b"frames before physical disconnect")
device.record = {"id": ident, "state": "recording"}
device.images = {"color": b"old frame"}
device.verified_this_process = True
stopped = []
device.pipeline = types.SimpleNamespace(stop=lambda: stopped.append(True))
device.disconnect()
assert stopped == [True]
assert not device.online and not device.verified_this_process
assert not device.images and device.pipeline is None
assert device.acquisition == "failed"
value = module.json.loads((root / "manifest.json").read_text())
assert value["state"] == "failed" and value["sha256"]
device.disconnect()
assert stopped == [True]