feat(node): push USB changes and preserve sensor setup across sessions
This commit is contained in:
@@ -37,14 +37,25 @@ export function useFleet() {
|
|||||||
}, []);
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
let timer: ReturnType<typeof setTimeout>;
|
let fallback: ReturnType<typeof setInterval> | undefined;
|
||||||
async function poll() {
|
const read = async () => {
|
||||||
try { const value = await fleetRequest<{ items: Vehicle[] }>(); if (active) { setItems(value.items); setError(""); } }
|
try { const value = await fleetRequest<{ items: Vehicle[] }>(); if (active) { setItems(value.items); setError(""); } }
|
||||||
catch { if (active) setError("Реестр недоступен. Показаны последние полученные сведения; связь сейчас не подтверждена."); }
|
catch { if (active) setError("Реестр недоступен. Показаны последние полученные сведения; связь сейчас не подтверждена."); }
|
||||||
finally { if (active) timer = setTimeout(poll, 5000); }
|
};
|
||||||
}
|
void read();
|
||||||
void poll();
|
const events = new EventSource("/api/v1/fleet/events");
|
||||||
return () => { active = false; clearTimeout(timer); };
|
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 };
|
return { items, error, refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type {SensorInventory,SensorTransport} from '../../../../../packages/sens
|
|||||||
import {fleetRequest} from '../../core/fleet/useFleet';
|
import {fleetRequest} from '../../core/fleet/useFleet';
|
||||||
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
|
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
|
||||||
const transport=useMemo<SensorTransport>(()=>({
|
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),
|
submit:value=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations`,'POST',value),
|
||||||
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
|
operation:id=>fleetRequest(`/${encodeURIComponent(vehicleID)}/devices/operations/${encodeURIComponent(id)}`),
|
||||||
}),[vehicleID]);
|
}),[vehicleID]);
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ func run() error {
|
|||||||
go func() { errs <- private.Serve(unix) }()
|
go func() { errs <- private.Serve(unix) }()
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
go app.Sensors.WatchUSB(ctx)
|
||||||
go pairing.Run(ctx)
|
go pairing.Run(ctx)
|
||||||
log.Print("Mission Core Node " + version + " listening on loopback")
|
log.Print("Mission Core Node " + version + " listening on loopback")
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -238,6 +238,12 @@ func (p *Pairing) send(ctx context.Context, b CoreBinding, path string, payload
|
|||||||
}
|
}
|
||||||
func (p *Pairing) channel(ctx context.Context) {
|
func (p *Pairing) channel(ctx context.Context) {
|
||||||
instance := "agent_" + token()
|
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)
|
timer := time.NewTicker(5 * time.Second)
|
||||||
defer timer.Stop()
|
defer timer.Stop()
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -335,6 +341,12 @@ func (p *Pairing) channel(ctx context.Context) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-timer.C:
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,14 +45,16 @@ type SensorOperation struct {
|
|||||||
Updated int64 `json:"updated_at"`
|
Updated int64 `json:"updated_at"`
|
||||||
}
|
}
|
||||||
type Sensors struct {
|
type Sensors struct {
|
||||||
mu sync.Mutex
|
events sensorEvents
|
||||||
prepareMu sync.Mutex
|
mu sync.Mutex
|
||||||
root string
|
prepareMu sync.Mutex
|
||||||
nodeID string
|
root string
|
||||||
instance string
|
nodeID string
|
||||||
client *http.Client
|
instance string
|
||||||
operations map[string]*SensorOperation
|
client *http.Client
|
||||||
names map[string]string
|
operations map[string]*SensorOperation
|
||||||
|
names map[string]string
|
||||||
|
initialized map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var sensorID = regexp.MustCompile(`^rsd455_[0-9a-f]{32}$`)
|
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 {
|
if e := os.MkdirAll(dir, 0700); e != nil {
|
||||||
return nil, e
|
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) {
|
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")
|
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"))
|
data, _ := os.ReadFile(filepath.Join(dir, "names.json"))
|
||||||
_ = json.Unmarshal(data, &s.names)
|
_ = 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
|
return s, nil
|
||||||
}
|
}
|
||||||
func (s *Sensors) write(name string, value any) error {
|
func (s *Sensors) write(name string, value any) error {
|
||||||
@@ -163,6 +178,7 @@ func (s *Sensors) Inventory() map[string]any {
|
|||||||
id, _ := item["id"].(string)
|
id, _ := item["id"].(string)
|
||||||
seen[id] = true
|
seen[id] = true
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
|
item["configured"] = s.initialized[id]
|
||||||
if n := s.names[id]; n != "" {
|
if n := s.names[id]; n != "" {
|
||||||
item["name"] = n
|
item["name"] = n
|
||||||
}
|
}
|
||||||
@@ -189,15 +205,21 @@ func (s *Sensors) Inventory() map[string]any {
|
|||||||
if seen[id] {
|
if seen[id] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
now := time.Now().UTC().Format(time.RFC3339Nano)
|
seen[id] = true
|
||||||
name := "RealSense D455"
|
items = append(items, s.discovery(id, read("speed")+" Мбит/с", true))
|
||||||
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}})
|
|
||||||
}
|
}
|
||||||
|
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
|
var preparation any
|
||||||
if data, e := os.ReadFile("/var/lib/mission-core-node-drivers/preparation.json"); e == nil && len(data) < 32768 {
|
if data, e := os.ReadFile("/var/lib/mission-core-node-drivers/preparation.json"); e == nil && len(data) < 32768 {
|
||||||
_ = json.Unmarshal(data, &preparation)
|
_ = json.Unmarshal(data, &preparation)
|
||||||
@@ -212,6 +234,26 @@ func (s *Sensors) Inventory() map[string]any {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return map[string]any{"schema": "missioncore.node.devices/v1", "items": items, "preparation": preparation, "operations": operations}
|
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 {
|
func sensorViewAction(action string) bool {
|
||||||
return action == "details" || action == "offer" || action == "close-peer"
|
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
|
s.operations[c.ID] = value
|
||||||
copy := *value
|
copy := *value
|
||||||
|
s.events.notify()
|
||||||
go s.execute(c)
|
go s.execute(c)
|
||||||
return ©, nil
|
return ©, nil
|
||||||
}
|
}
|
||||||
func (s *Sensors) execute(c SensorCommand) {
|
func (s *Sensors) execute(c SensorCommand) {
|
||||||
|
defer s.events.notify()
|
||||||
var result any
|
var result any
|
||||||
var err error
|
var err error
|
||||||
uncertain := false
|
uncertain := false
|
||||||
@@ -313,6 +357,13 @@ func (s *Sensors) execute(c SensorCommand) {
|
|||||||
}
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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 := s.operations[c.ID]
|
||||||
v.Updated = time.Now().Unix()
|
v.Updated = time.Now().Unix()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -394,6 +445,7 @@ func (s *Sensors) RemoteResults() []any {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
func (s *Sensors) Routes(mux *http.ServeMux, server *Server) {
|
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) {
|
mux.HandleFunc("GET /api/devices", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if server.authorized(w, r) {
|
if server.authorized(w, r) {
|
||||||
reply(w, 200, s.Inventory())
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import sys
|
|||||||
from build_deb import build, VERSION, BRAND_SHA256
|
from build_deb import build, VERSION, BRAND_SHA256
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
DG_COMMIT = "17e150b1c74ab8a345fe34ce51dccd5bb862fa85"
|
DG_COMMIT = "be6463fd596ae2a1a96409d2cee84edc90e7167f"
|
||||||
|
|
||||||
|
|
||||||
def guideline_sources():
|
def guideline_sources():
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import tarfile
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
VERSION = "0.6.6"
|
VERSION = "0.6.8"
|
||||||
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -93,13 +93,30 @@ class Device:
|
|||||||
self.verified_this_process = False
|
self.verified_this_process = False
|
||||||
for path in self.root.glob("recordings/*/manifest.json"):
|
for path in self.root.glob("recordings/*/manifest.json"):
|
||||||
value = json.loads(path.read_text())
|
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())
|
value.update(state="interrupted", recovered_at=utc())
|
||||||
atomic(path, value)
|
atomic(path, value)
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
atomic(self.root / "config.json", self.config)
|
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):
|
def refresh(self, dev):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.sdk_device = dev
|
self.sdk_device = dev
|
||||||
@@ -110,7 +127,7 @@ class Device:
|
|||||||
self.online = True
|
self.online = True
|
||||||
self.firmware = dev.get_info(rs.camera_info.firmware_version)
|
self.firmware = dev.get_info(rs.camera_info.firmware_version)
|
||||||
self.transport = dev.get_info(rs.camera_info.usb_type_descriptor)
|
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
|
return
|
||||||
profiles, options = [], []
|
profiles, options = [], []
|
||||||
for index, sensor in enumerate(dev.query_sensors()):
|
for index, sensor in enumerate(dev.query_sensors()):
|
||||||
@@ -254,7 +271,7 @@ class Device:
|
|||||||
|
|
||||||
def start(self, selected=None, record=False):
|
def start(self, selected=None, record=False):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.pipeline is not None:
|
if self.pipeline is not None or self.acquisition == "stopping":
|
||||||
raise ValueError("Захват уже запущен. Сначала остановите его.")
|
raise ValueError("Захват уже запущен. Сначала остановите его.")
|
||||||
if not self.online or self.sdk_device is None:
|
if not self.online or self.sdk_device is None:
|
||||||
raise ValueError("Камера не подключена.")
|
raise ValueError("Камера не подключена.")
|
||||||
@@ -352,7 +369,7 @@ class Device:
|
|||||||
if not isinstance(ident, str) or not re.fullmatch(r"capture_[0-9a-f]{32}", ident):
|
if not isinstance(ident, str) or not re.fullmatch(r"capture_[0-9a-f]{32}", ident):
|
||||||
raise ValueError("Некорректная запись.")
|
raise ValueError("Некорректная запись.")
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.pipeline is not None:
|
if self.pipeline is not None or self.acquisition == "stopping":
|
||||||
raise ValueError("Сначала остановите текущий захват или просмотр записи.")
|
raise ValueError("Сначала остановите текущий захват или просмотр записи.")
|
||||||
directory = self.root / "recordings" / ident
|
directory = self.root / "recordings" / ident
|
||||||
source, manifest = directory / "source.db3", directory / "manifest.json"
|
source, manifest = directory / "source.db3", directory / "manifest.json"
|
||||||
@@ -440,26 +457,39 @@ class Device:
|
|||||||
|
|
||||||
def stop(self, failed=False, from_capture=False):
|
def stop(self, failed=False, from_capture=False):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
if self.acquisition == "stopping":
|
||||||
|
raise ValueError("Исходная запись ещё сохраняется. Дождитесь завершения.")
|
||||||
self.stop_event.set()
|
self.stop_event.set()
|
||||||
pipeline, self.pipeline = self.pipeline, None
|
pipeline, self.pipeline = self.pipeline, None
|
||||||
if pipeline is not None:
|
if pipeline is not None:
|
||||||
self.acquisition = "stopping"
|
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
|
del pipeline
|
||||||
if self.thread and not from_capture:
|
if self.thread and not from_capture:
|
||||||
self.thread.join(timeout=3)
|
self.thread.join(timeout=3)
|
||||||
self.playback_id = None
|
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
|
self.revision += 1
|
||||||
if self.record:
|
if value:
|
||||||
value = dict(self.record)
|
|
||||||
value.update(
|
value.update(
|
||||||
state="failed" if failed else "complete",
|
state="finalizing",
|
||||||
ended_at=utc(),
|
ended_at=utc(),
|
||||||
ended_monotonic_ns=time.monotonic_ns(),
|
ended_monotonic_ns=time.monotonic_ns(),
|
||||||
frames=dict(self.frames),
|
frames=dict(self.frames),
|
||||||
)
|
)
|
||||||
|
self.record = value
|
||||||
path = self.root / "recordings" / value["id"]
|
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"
|
source = path / "source.db3"
|
||||||
if source.exists():
|
if source.exists():
|
||||||
digest = hashlib.sha256()
|
digest = hashlib.sha256()
|
||||||
@@ -467,12 +497,24 @@ class Device:
|
|||||||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||||||
digest.update(chunk)
|
digest.update(chunk)
|
||||||
value.update(sha256=digest.hexdigest(), bytes=source.stat().st_size)
|
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)
|
atomic(path / "manifest.json", value)
|
||||||
self.record = None
|
except OSError:
|
||||||
return {"ok": True}
|
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):
|
def verify(self):
|
||||||
if self.pipeline is not None:
|
if self.pipeline is not None or self.acquisition == "stopping":
|
||||||
raise ValueError("Остановите захват перед повторной проверкой.")
|
raise ValueError("Остановите захват перед повторной проверкой.")
|
||||||
try:
|
try:
|
||||||
self.start()
|
self.start()
|
||||||
|
|||||||
@@ -64,8 +64,7 @@ class Host:
|
|||||||
self.devices[ident].refresh(dev)
|
self.devices[ident].refresh(dev)
|
||||||
for ident, device in self.devices.items():
|
for ident, device in self.devices.items():
|
||||||
if ident not in found:
|
if ident not in found:
|
||||||
device.online = False
|
device.disconnect()
|
||||||
device.verified_this_process = False
|
|
||||||
|
|
||||||
await asyncio.to_thread(work)
|
await asyncio.to_thread(work)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace';
|
import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||||
import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||||
import {request} from './api';
|
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}/>;}
|
export function NodeSensors(){return <SensorWorkspace transport={transport}/>;}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function desktopAction(action: DesktopAction): boolean {
|
|||||||
}
|
}
|
||||||
export function desktopLogin(): boolean { return desktopAction("authorize"); }
|
export function desktopLogin(): boolean { return desktopAction("authorize"); }
|
||||||
export async function request<T>(path: string, method = "GET", body?: unknown): Promise<T> {
|
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) {
|
if (!res.ok) {
|
||||||
const error = await res.json().catch(() => ({}));
|
const error = await res.json().catch(() => ({}));
|
||||||
throw new APIError(error.error ?? "Не удалось выполнить запрос к ноде", res.status);
|
throw new APIError(error.error ?? "Не удалось выполнить запрос к ноде", res.status);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
export default defineConfig({
|
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
|
// Design Guideline packages are linked during development. Their own React
|
||||||
// must never become a second hook dispatcher in the portable production bundle.
|
// must never become a second hook dispatcher in the portable production bundle.
|
||||||
resolve: { dedupe: ["react", "react-dom", "@nodedc/ui-react"] },
|
resolve: { dedupe: ["react", "react-dom", "@nodedc/ui-react"] },
|
||||||
|
|||||||
@@ -110,5 +110,76 @@ UTC/monotonic timestamps, счётчики и SHA-256 завершённого
|
|||||||
нужно остановить.
|
нужно остановить.
|
||||||
|
|
||||||
Первый тест исходной записи обнаружил отказ SDK от расширения .bag; это
|
Первый тест исходной записи обнаружил отказ 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](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 при смене путей пока не подтверждено.
|
||||||
|
|||||||
@@ -2,27 +2,29 @@ import {useEffect,useState} from 'react';
|
|||||||
import {ActivityIndicator,Button,Icon,IconButton,Select,SettingsCard,TextField} from '@nodedc/ui-react';
|
import {ActivityIndicator,Button,Icon,IconButton,Select,SettingsCard,TextField} from '@nodedc/ui-react';
|
||||||
import {perform,type Sensor,type SensorTransport} from './contracts';
|
import {perform,type Sensor,type SensorTransport} from './contracts';
|
||||||
import {LiveViewport} from './LiveViewport';
|
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('');
|
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;}
|
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]);
|
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 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 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 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 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>
|
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:''}`}>
|
<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>}
|
{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.playback_id&&<p>Просмотр исходной записи · повтор. Камера для него не запускается.</p>}
|
||||||
{device.recording&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>}
|
{device.snapshot.acquisition==='stopping'&&<p>Сохраняем исходную запись и проверяем её целостность.</p>}
|
||||||
|
{device.recording&&device.snapshot.acquisition!=='stopping'&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>}
|
||||||
</SettingsCard>
|
</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}/>
|
{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="Профили потоков" 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={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="Параметры камеры"><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==='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="Исходные записи">{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>}
|
{!detail&&<p>{device.prepared?'Получаем возможности камеры…':'Подготовьте устройство в списке.'}</p>}
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,21 +6,32 @@ import './sensors.css';
|
|||||||
export function SensorWorkspace({transport,enabled=true,onDetailChange}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void}){
|
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 [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 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]);
|
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(()=>{void refresh();const timer=setInterval(()=>void refresh(),3000);return()=>clearInterval(timer);},[refresh]);
|
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);}}
|
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 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]);
|
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>
|
<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 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 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?'Драйвер установлен':'Требуется подготовка';
|
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 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>}
|
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>}
|
{(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('')}/>
|
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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 SensorOption { id: string; sensor: string; label: string; value: number; min: number; max: number; step: number; read_only: boolean }
|
||||||
export interface Sensor {
|
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};
|
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[];
|
profiles?: SensorProfile[]; defaults?: string[]; options?: SensorOption[]; layers: string[];
|
||||||
frames?: Record<string,number>; last_frame?: {observed_at:string}; recording?: {id: 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}[];
|
recordings?: {id:string;state:string;started_at:string;bytes?:number;sha256?:string}[];
|
||||||
}
|
}
|
||||||
export interface SensorInventory {
|
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}[]};
|
preparation?: {state:string;started_at:number;steps:{id:string;label:string;state:string;message?:string}[]};
|
||||||
}
|
}
|
||||||
export interface SensorCommand {
|
export interface SensorCommand {
|
||||||
@@ -19,6 +19,7 @@ export interface SensorCommand {
|
|||||||
export interface SensorOperation {state:string;error?:string;result?:unknown}
|
export interface SensorOperation {state:string;error?:string;result?:unknown}
|
||||||
export interface SensorTransport {
|
export interface SensorTransport {
|
||||||
inventory: () => Promise<SensorInventory>;
|
inventory: () => Promise<SensorInventory>;
|
||||||
|
subscribe?: (receive:(value:SensorInventory)=>void, unavailable:()=>void) => (()=>void);
|
||||||
submit: (value:SensorCommand) => Promise<SensorOperation>;
|
submit: (value:SensorCommand) => Promise<SensorOperation>;
|
||||||
operation: (id:string) => Promise<SensorOperation>;
|
operation: (id:string) => Promise<SensorOperation>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -27,6 +27,9 @@ class FleetRegistry:
|
|||||||
self.started_at = time.time()
|
self.started_at = time.time()
|
||||||
self.root = root
|
self.root = root
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
|
from .events import FleetEvents
|
||||||
|
|
||||||
|
self.events = FleetEvents()
|
||||||
self.trust = CoreTrust(root)
|
self.trust = CoreTrust(root)
|
||||||
path = root / "fleet.sqlite3"
|
path = root / "fleet.sqlite3"
|
||||||
if path.is_symlink():
|
if path.is_symlink():
|
||||||
@@ -58,6 +61,7 @@ class FleetRegistry:
|
|||||||
"ON CONFLICT(id) DO UPDATE SET body=excluded.body",
|
"ON CONFLICT(id) DO UPDATE SET body=excluded.body",
|
||||||
(row["id"], row["node_id"], json.dumps(row)),
|
(row["id"], row["node_id"], json.dumps(row)),
|
||||||
)
|
)
|
||||||
|
self.events.notify()
|
||||||
|
|
||||||
def find(self, identifier):
|
def find(self, identifier):
|
||||||
result = self.db.execute("SELECT body FROM vehicles WHERE id=?", (identifier,)).fetchone()
|
result = self.db.execute("SELECT body FROM vehicles WHERE id=?", (identifier,)).fetchone()
|
||||||
|
|||||||
@@ -2,11 +2,15 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import json
|
||||||
|
from contextlib import suppress
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from k1link.fleet.registry import FleetRegistry
|
from k1link.fleet.registry import FleetRegistry
|
||||||
@@ -53,6 +57,31 @@ def fleet_list(response: Response, fleet: Annotated[FleetRegistry, Depends(local
|
|||||||
return fleet.listing()
|
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")
|
@router.post("/preview")
|
||||||
def fleet_preview(
|
def fleet_preview(
|
||||||
body: PreviewRequest,
|
body: PreviewRequest,
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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]
|
||||||
Reference in New Issue
Block a user