Add D455 sensor host and shared Node/Core preparation surface

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 22:27:07 +03:00
parent 3616acc648
commit b1aaa40508
41 changed files with 2293 additions and 19 deletions
+1
View File
@@ -39,6 +39,7 @@ type PairState struct {
Revocations []CoreBinding `json:"revocations,omitempty"`
}
type Pairing struct {
Sensors *Sensors
mu sync.Mutex
path string
store *Store
@@ -231,7 +231,7 @@ func (p *Pairing) send(ctx context.Context, b CoreBinding, path string, payload
}
defer response.Body.Close()
var out map[string]json.RawMessage
if json.NewDecoder(io.LimitReader(response.Body, 16384)).Decode(&out) != nil {
if json.NewDecoder(io.LimitReader(response.Body, 1048576)).Decode(&out) != nil {
return nil, response.StatusCode, errors.New("invalid Core response")
}
return out, response.StatusCode, nil
@@ -270,12 +270,30 @@ func (p *Pairing) channel(ctx context.Context) {
if binding != nil {
id, name := p.store.Public()
payload := map[string]any{"schema": PairSchema, "binding_id": binding.BindingID, "node_id": id, "name": name, "version": p.version, "execution_binding": map[string]string{"node_id": id, "agent_instance_id": instance, "platform": "linux"}, "host": p.inventory(), "devices": []any{}}
if p.Sensors != nil {
inv := p.Sensors.Inventory()
payload["devices"] = inv["items"]
payload["sensor_state"] = inv
payload["sensor_results"] = p.Sensors.RemoteResults()
}
result, status, e := p.send(ctx, *binding, "/v1/node/heartbeat", payload)
p.mu.Lock()
if p.state.Phase == "paired" && p.state.Binding != nil && p.state.Binding.BindingID == binding.BindingID {
if e == nil && status == 200 {
p.connection = "online"
p.lastSeen = p.now().Unix()
if p.Sensors != nil {
var ack []string
if json.Unmarshal(result["sensor_acknowledgements"], &ack) == nil {
p.Sensors.Acknowledge(ack)
}
var commands []SensorCommand
if json.Unmarshal(result["sensor_commands"], &commands) == nil {
for _, c := range commands {
_, _ = p.Sensors.Submit(c, true)
}
}
}
var cert string
if json.Unmarshal(result["client_pem"], &cert) == nil && cert != "" && cert != binding.ClientPEM {
next := *binding
+422
View File
@@ -0,0 +1,422 @@
package node
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
const SensorSchema = "missioncore.nodedc/plugin-sdk/v0alpha2"
type SensorSession struct {
SessionID string `json:"session_id"`
DeviceID string `json:"device_id"`
}
type SensorCommand struct {
APIVersion string `json:"api_version"`
Kind string `json:"kind"`
ID string `json:"operation_id"`
Session SensorSession `json:"session"`
Action string `json:"action_id"`
Requested string `json:"requested_at"`
Deadline string `json:"deadline_at"`
Idempotency string `json:"idempotency_key"`
Parameters map[string]any `json:"parameters"`
}
type SensorOperation struct {
Command SensorCommand `json:"command"`
State string `json:"state"`
Error string `json:"error,omitempty"`
Result any `json:"result,omitempty"`
Remote bool `json:"remote,omitempty"`
Updated int64 `json:"updated_at"`
}
type Sensors struct {
mu sync.Mutex
prepareMu sync.Mutex
root string
nodeID string
instance string
client *http.Client
operations map[string]*SensorOperation
names map[string]string
}
var sensorID = regexp.MustCompile(`^rsd455_[0-9a-f]{32}$`)
var operationID = regexp.MustCompile(`^op_[0-9a-f]{32}$`)
func OpenSensors(root, nodeID string) (*Sensors, error) {
dir := filepath.Join(root, "sensors")
if e := os.MkdirAll(dir, 0700); e != nil {
return nil, e
}
s := &Sensors{root: dir, nodeID: nodeID, instance: "discovery_" + digest(token())[:24], operations: map[string]*SensorOperation{}, names: map[string]string{}}
s.client = &http.Client{Timeout: 25 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, "unix", "/run/mission-core-sensors/driver.sock")
}}}
files, _ := filepath.Glob(filepath.Join(dir, "op_*.json"))
for _, p := range files {
data, e := os.ReadFile(p)
if e != nil {
return nil, e
}
var v SensorOperation
if json.Unmarshal(data, &v) != nil {
return nil, errors.New("invalid sensor operation journal")
}
if v.State == "running" {
v.State = "unknown"
v.Error = "Результат операции неизвестен после перезапуска. Проверьте состояние устройства."
}
s.operations[v.Command.ID] = &v
}
data, _ := os.ReadFile(filepath.Join(dir, "names.json"))
_ = json.Unmarshal(data, &s.names)
return s, nil
}
func (s *Sensors) write(name string, value any) error {
data, e := json.Marshal(value)
if e != nil {
return e
}
f, e := os.CreateTemp(s.root, ".sensor-")
if e != nil {
return e
}
defer os.Remove(f.Name())
if _, e = f.Write(data); e != nil {
f.Close()
return e
}
if e = f.Sync(); e != nil {
f.Close()
return e
}
f.Close()
if e = os.Rename(f.Name(), filepath.Join(s.root, name)); e != nil {
return e
}
d, e := os.Open(s.root)
if e != nil {
return e
}
defer d.Close()
return d.Sync()
}
func (s *Sensors) driver(path string, body any) (map[string]any, error) {
method := "GET"
var reader io.Reader
if body != nil {
method = "POST"
data, e := json.Marshal(body)
if e != nil {
return nil, e
}
reader = bytes.NewReader(data)
}
req, e := http.NewRequest(method, "http://driver"+path, reader)
if e != nil {
return nil, e
}
req.Header.Set("X-Node-Id", s.nodeID)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
response, e := s.client.Do(req)
if e != nil {
return nil, errors.New("Служба камеры недоступна. Подготовьте устройство.")
}
defer response.Body.Close()
var result map[string]any
if json.NewDecoder(io.LimitReader(response.Body, 1024*1024)).Decode(&result) != nil {
return nil, errors.New("Не удалось прочитать результат драйвера.")
}
if response.StatusCode != 200 {
message, _ := result["error"].(string)
return nil, errors.New(message)
}
return result, nil
}
func (s *Sensors) Inventory() map[string]any {
items := []any{}
seen := map[string]bool{}
if result, e := s.driver("/inventory", nil); e == nil {
if found, ok := result["items"].([]any); ok {
for _, v := range found {
item, ok := v.(map[string]any)
if !ok {
continue
}
id, _ := item["id"].(string)
seen[id] = true
s.mu.Lock()
if n := s.names[id]; n != "" {
item["name"] = n
}
s.mu.Unlock()
items = append(items, item)
}
}
}
paths, _ := filepath.Glob("/sys/bus/usb/devices/*")
for _, path := range paths {
read := func(n string) string {
b, _ := os.ReadFile(filepath.Join(path, n))
return strings.TrimSpace(string(b))
}
if read("idVendor") != "8086" || read("idProduct") != "0b5c" {
continue
}
serial := read("serial")
if serial == "" {
continue
}
h := sha256.Sum256([]byte(serial))
id := "rsd455_" + hex.EncodeToString(h[:])[:32]
if seen[id] {
continue
}
now := time.Now().UTC().Format(time.RFC3339Nano)
name := "RealSense D455"
s.mu.Lock()
if n := s.names[id]; n != "" {
name = n
}
s.mu.Unlock()
items = append(items, map[string]any{"id": id, "name": name, "model": "RealSense D455", "prepared": false, "verified": false, "online": true, "usb": read("speed") + " Мбит/с", "layers": []any{}, "snapshot": map[string]any{"context": map[string]any{"session_id": s.instance + "_" + id, "device": map[string]any{"device_id": id, "model": map[string]string{"plugin_id": "missioncore.realsense", "plugin_version": "0.6.0", "model_id": "realsense.d455"}, "stability": "stable", "basis": "hardware-identifier"}, "execution": map[string]string{"node_id": s.nodeID, "agent_instance_id": s.instance, "platform": "linux"}, "opened_at": now}, "revision": 0, "enrollment": "empty", "connectivity": "connected", "acquisition": "idle", "observed_at": now}})
}
var preparation any
if data, e := os.ReadFile("/var/lib/mission-core-node-drivers/preparation.json"); e == nil && len(data) < 32768 {
_ = json.Unmarshal(data, &preparation)
}
s.mu.Lock()
operations := []any{}
for _, v := range s.operations {
if time.Now().Unix()-v.Updated < 600 {
operations = append(operations, map[string]any{"operation_id": v.Command.ID, "device_id": v.Command.Session.DeviceID, "action_id": v.Command.Action, "state": v.State, "error": v.Error})
}
}
s.mu.Unlock()
return map[string]any{"schema": "missioncore.node.devices/v1", "items": items, "preparation": preparation, "operations": operations}
}
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
if c.APIVersion != SensorSchema || c.Kind != "OperationRequest" || !operationID.MatchString(c.ID) || c.Idempotency != c.ID || !sensorID.MatchString(c.Session.DeviceID) || len(c.Session.SessionID) > 192 {
return nil, errors.New("Некорректная команда устройства.")
}
if !map[string]bool{"prepare": true, "details": true, "rename": true, "verify": true, "start": true, "stop": true, "option": true, "offer": true, "close-peer": true}[c.Action] {
return nil, errors.New("Операция не поддерживается.")
}
deadline, e := time.Parse(time.RFC3339Nano, c.Deadline)
requested, e2 := time.Parse(time.RFC3339Nano, c.Requested)
if e != nil || e2 != nil || !deadline.After(requested) || deadline.Sub(requested) > 6*time.Minute {
return nil, errors.New("Некорректный срок команды.")
}
s.mu.Lock()
defer s.mu.Unlock()
if old := s.operations[c.ID]; old != nil {
a, _ := json.Marshal(old.Command)
b, _ := json.Marshal(c)
if !bytes.Equal(a, b) {
return nil, errors.New("Идентификатор операции уже использован.")
}
copy := *old
return &copy, nil
}
if !deadline.After(time.Now()) {
return nil, errors.New("Срок команды истёк. Устройство не изменено.")
}
for _, v := range s.operations {
if v.State == "running" && v.Command.Session.DeviceID == c.Session.DeviceID {
return nil, errors.New("Другая операция устройства ещё выполняется.")
}
}
if len(s.operations) > 2000 {
for id, v := range s.operations {
if v.State != "running" && time.Now().Unix()-v.Updated > 86400 {
delete(s.operations, id)
os.Remove(filepath.Join(s.root, id+".json"))
}
}
}
if len(s.operations) > 2000 {
return nil, errors.New("Журнал операций заполнен. Повторите позже.")
}
value := &SensorOperation{Command: c, State: "running", Remote: remote, Updated: time.Now().Unix()}
if e = s.write(c.ID+".json", value); e != nil {
return nil, e
}
s.operations[c.ID] = value
copy := *value
go s.execute(c)
return &copy, nil
}
func (s *Sensors) execute(c SensorCommand) {
var result any
var err error
inv := s.Inventory()
var item map[string]any
for _, v := range inv["items"].([]any) {
i := v.(map[string]any)
if i["id"] == c.Session.DeviceID {
item = i
break
}
}
if item == nil {
err = errors.New("Камера не обнаружена. Проверьте подключение.")
} else if c.Action == "prepare" {
result, err = s.prepare(c)
} else if c.Action == "rename" {
name, ok := c.Parameters["name"].(string)
if !ok || strings.TrimSpace(name) == "" || len([]rune(name)) > 80 || strings.ContainsAny(name, "\n\r\t") {
err = errors.New("Введите название до 80 символов.")
} else {
s.mu.Lock()
s.names[c.Session.DeviceID] = strings.TrimSpace(name)
err = s.write("names.json", s.names)
s.mu.Unlock()
result = map[string]bool{"ok": err == nil}
}
} else {
var v map[string]any
v, err = s.driver("/operation", c)
if err == nil {
if v["state"] == "complete" {
result = v["result"]
} else {
message, _ := v["error"].(string)
err = errors.New(message)
}
}
}
s.mu.Lock()
defer s.mu.Unlock()
v := s.operations[c.ID]
v.Updated = time.Now().Unix()
if err != nil {
v.State = "error"
v.Error = err.Error()
} else {
v.State = "complete"
v.Result = result
}
if s.write(c.ID+".json", v) != nil {
v.State = "unknown"
v.Error = "Не удалось сохранить результат операции. Обновите состояние устройства."
}
}
func (s *Sensors) prepare(c SensorCommand) (any, error) {
s.prepareMu.Lock()
defer s.prepareMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 310*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "/usr/bin/systemctl", "start", "mission-core-node-realsense-prepare.service")
cmd.Env = []string{"PATH=/usr/bin:/bin", "LANG=C", "DBUS_SYSTEM_BUS_ADDRESS=unix:path=/run/dbus/system_bus_socket"}
if cmd.Run() != nil {
return nil, errors.New("Подготовка драйвера не завершена. Проверьте этапы и повторите действие.")
}
for i := 0; i < 12; i++ {
inv := s.Inventory()
for _, v := range inv["items"].([]any) {
item := v.(map[string]any)
if item["id"] == c.Session.DeviceID && item["prepared"] == true {
snap := item["snapshot"].(map[string]any)
sc := snap["context"].(map[string]any)
verify := c
verify.Action = "verify"
verify.Session.SessionID = sc["session_id"].(string)
result, e := s.driver("/operation", verify)
if e != nil {
return nil, e
}
if result["state"] != "complete" {
message, _ := result["error"].(string)
return nil, errors.New(message)
}
return result["result"], nil
}
}
time.Sleep(time.Second)
}
return nil, errors.New("Драйвер установлен, но камера не открылась. Проверьте USB-подключение.")
}
func (s *Sensors) Get(id string) *SensorOperation {
s.mu.Lock()
defer s.mu.Unlock()
if v := s.operations[id]; v != nil {
copy := *v
return &copy
}
return nil
}
func (s *Sensors) RemoteResults() []any {
s.mu.Lock()
defer s.mu.Unlock()
out := []any{}
for _, v := range s.operations {
if v.Remote && time.Now().Unix()-v.Updated < 600 {
copy := *v
out = append(out, copy)
}
}
return out
}
func (s *Sensors) Routes(mux *http.ServeMux, server *Server) {
mux.HandleFunc("GET /api/devices", func(w http.ResponseWriter, r *http.Request) {
if server.authorized(w, r) {
reply(w, 200, s.Inventory())
}
})
mux.HandleFunc("POST /api/devices/operations", func(w http.ResponseWriter, r *http.Request) {
if !server.authorized(w, r) {
return
}
var c SensorCommand
r.Body = http.MaxBytesReader(w, r.Body, 65536)
if r.Header.Get("Content-Type") != "application/json" || json.NewDecoder(r.Body).Decode(&c) != nil {
reply(w, 400, map[string]string{"error": "Некорректная команда"})
return
}
v, e := s.Submit(c, false)
if e != nil {
reply(w, 409, map[string]string{"error": e.Error()})
return
}
reply(w, 202, v)
})
mux.HandleFunc("GET /api/devices/operations/{id}", func(w http.ResponseWriter, r *http.Request) {
if !server.authorized(w, r) {
return
}
v := s.Get(r.PathValue("id"))
if v == nil {
reply(w, 404, map[string]string{"error": "Операция не найдена"})
return
}
reply(w, 200, v)
})
}
func (s *Sensors) Acknowledge(ids []string) {
s.mu.Lock()
defer s.mu.Unlock()
for _, id := range ids {
if v := s.operations[id]; v != nil && v.State != "running" {
v.Remote = false
_ = s.write(id+".json", v)
}
}
}
@@ -0,0 +1,10 @@
package node
import (
"testing"
"time"
)
func sensorTestCommand() SensorCommand {now:=time.Now();id:="op_01234567890123456789012345678901";return SensorCommand{APIVersion:SensorSchema,Kind:"OperationRequest",ID:id,Idempotency:id,Session:SensorSession{SessionID:"session_test",DeviceID:"rsd455_01234567890123456789012345678901"},Action:"start",Requested:now.UTC().Format(time.RFC3339Nano),Deadline:now.Add(time.Minute).UTC().Format(time.RFC3339Nano),Parameters:map[string]any{}}}
func TestSensorRejectsAuthorityAndExpiredRequests(t *testing.T){s,e:=OpenSensors(t.TempDir(),"node_test");if e!=nil{t.Fatal(e)};c:=sensorTestCommand();c.Action="shell";if _,e=s.Submit(c,false);e==nil{t.Fatal("arbitrary action admitted")};c=sensorTestCommand();c.ID="../../owned";if _,e=s.Submit(c,false);e==nil{t.Fatal("path admitted")};c=sensorTestCommand();c.Requested=time.Now().Add(-2*time.Minute).Format(time.RFC3339Nano);c.Deadline=time.Now().Add(-time.Minute).Format(time.RFC3339Nano);if _,e=s.Submit(c,false);e==nil{t.Fatal("expired command admitted")}}
func TestSensorUncertainCrashDoesNotReplay(t *testing.T){root:=t.TempDir();s,_:=OpenSensors(root,"node_test");c:=sensorTestCommand();old:=&SensorOperation{Command:c,State:"running",Updated:time.Now().Unix()};if e:=s.write(c.ID+".json",old);e!=nil{t.Fatal(e)};s,e:=OpenSensors(root,"node_test");if e!=nil{t.Fatal(e)};v,e:=s.Submit(c,false);if e!=nil||v.State!="unknown"{t.Fatalf("replay: %+v %v",v,e)};c.Parameters=map[string]any{"record":true};if _,e=s.Submit(c,false);e==nil{t.Fatal("id collision did not reject different command")}}
+4
View File
@@ -15,6 +15,7 @@ import (
type Server struct {
Store *Store
Pairing *Pairing
Sensors *Sensors
Assets fs.FS
Origin string
Version string
@@ -78,6 +79,9 @@ func reply(w http.ResponseWriter, status int, v any) {
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
if s.Sensors != nil {
s.Sensors.Routes(mux, s)
}
if s.Pairing != nil {
s.Pairing.localRoutes(mux, s)
}