feat(vesc): integrate native calibration diagnostics and configuration archives
This commit is contained in:
@@ -152,6 +152,7 @@ func (p *Pairing) remoteHandler() http.Handler {
|
||||
|
||||
func (p *Pairing) Run(ctx context.Context) {
|
||||
go p.channel(ctx)
|
||||
go p.roverChannel(ctx)
|
||||
var server *http.Server
|
||||
endpoint := ""
|
||||
serverIdentity := ""
|
||||
@@ -307,6 +308,9 @@ func (p *Pairing) channel(ctx context.Context) {
|
||||
payload["devices"] = inv["items"]
|
||||
payload["sensor_state"] = inv
|
||||
payload["sensor_results"] = p.Sensors.RemoteResults()
|
||||
if batch := p.Sensors.ConfigurationBatch(binding.BindingID); batch != nil {
|
||||
payload["vesc_configurations"] = batch
|
||||
}
|
||||
}
|
||||
if p.DeviceEnrollment != nil {
|
||||
payload["device_enrollment"] = p.DeviceEnrollment.Status()
|
||||
@@ -337,6 +341,7 @@ func (p *Pairing) channel(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
if p.Sensors != nil {
|
||||
p.Sensors.AcknowledgeConfigurations(binding.BindingID, result["vesc_configurations_ack"], payload["vesc_configurations"])
|
||||
var ack []string
|
||||
if json.Unmarshal(result["sensor_acknowledgements"], &ack) == nil {
|
||||
p.Sensors.Acknowledge(ack)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (s *Sensors) vescArchive(path string) (map[string]any, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
for i := range sensorModels {
|
||||
if sensorModels[i].ID == "vesc.controller" {
|
||||
return s.modelDriver(ctx, &sensorModels[i], path, nil)
|
||||
}
|
||||
}
|
||||
panic("shipped VESC model is missing")
|
||||
}
|
||||
|
||||
func archiveCursorName(binding string) string {
|
||||
return "vesc-archive-" + digest(binding)[:24] + ".cursor"
|
||||
}
|
||||
|
||||
// Each pairing receives the complete immutable history, including backups
|
||||
// made locally and while Core was offline. ACK follows durable Core storage.
|
||||
func (s *Sensors) ConfigurationBatch(binding string) map[string]any {
|
||||
var after int64
|
||||
data, _ := os.ReadFile(filepath.Join(s.root, archiveCursorName(binding)))
|
||||
if json.Unmarshal(data, &after) != nil || after < 0 {
|
||||
after = 0
|
||||
}
|
||||
result, err := s.vescArchive("/archive-export?after=" + strconv.FormatInt(after, 10))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Sensors) AcknowledgeConfigurations(binding string, ack json.RawMessage, batch any) {
|
||||
value, ok := batch.(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var sequence int64
|
||||
if json.Unmarshal(ack, &sequence) != nil || sequence < 0 {
|
||||
return
|
||||
}
|
||||
next, ok := value["next"].(float64)
|
||||
if !ok || next != float64(sequence) {
|
||||
return
|
||||
}
|
||||
// A failed cursor write causes replay; immutable archive insertion deduplicates it.
|
||||
_ = s.write(archiveCursorName(binding), sequence)
|
||||
}
|
||||
|
||||
func (s *Sensors) configurationRoutes(mux *http.ServeMux, server *Server) {
|
||||
for _, route := range []string{"GET /api/device-configurations/{device}", "GET /api/device-configurations/{device}/{version}"} {
|
||||
mux.HandleFunc(route, func(w http.ResponseWriter, r *http.Request) {
|
||||
if !server.authorized(w, r) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
device := r.PathValue("device")
|
||||
model := modelForDevice(device)
|
||||
if model == nil || model.ID != "vesc.controller" {
|
||||
reply(w, 404, map[string]string{"error": "История не найдена"})
|
||||
return
|
||||
}
|
||||
path := "/archives/" + url.PathEscape(device)
|
||||
if version := r.PathValue("version"); version != "" {
|
||||
path += "/" + url.PathEscape(version)
|
||||
} else {
|
||||
path += "?before=" + url.QueryEscape(r.URL.Query().Get("before"))
|
||||
}
|
||||
result, err := s.vescArchive(path)
|
||||
if err != nil {
|
||||
reply(w, 503, map[string]string{"error": "Архив конфигураций недоступен"})
|
||||
return
|
||||
}
|
||||
reply(w, 200, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVESCArchiveAckRequiresExactBatchAndPairingScope(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
batch := map[string]any{"next": float64(9)}
|
||||
s.AcknowledgeConfigurations("pair-a", json.RawMessage(`10`), batch)
|
||||
if _, err := os.Stat(filepath.Join(s.root, archiveCursorName("pair-a"))); !os.IsNotExist(err) {
|
||||
t.Fatal("incorrect ACK persisted")
|
||||
}
|
||||
s.AcknowledgeConfigurations("pair-a", json.RawMessage(`9`), batch)
|
||||
data, err := os.ReadFile(filepath.Join(s.root, archiveCursorName("pair-a")))
|
||||
if err != nil || string(data) != "9" {
|
||||
t.Fatalf("durable cursor: %q %v", data, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(s.root, archiveCursorName("pair-b"))); !os.IsNotExist(err) {
|
||||
t.Fatal("history skipped on another pairing")
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ type sensorModel struct {
|
||||
Socket, PrepareUnit, Report string
|
||||
Actions map[string]bool
|
||||
ActionTimeouts map[string]time.Duration
|
||||
// This model may prepare an attachment only to read its protocol identity.
|
||||
ProtocolIdentity bool
|
||||
}
|
||||
|
||||
func actions(names ...string) map[string]bool {
|
||||
@@ -44,6 +46,11 @@ var sensorModels = []sensorModel{
|
||||
PrepareUnit: "mission-core-node-insta360-x4-profile.service", Report: "/var/lib/mission-core-node-profiles/insta360-x4/preparation.json",
|
||||
ActionTimeouts: map[string]time.Duration{"power.wake": 55 * time.Second},
|
||||
Actions: actions("prepare", "details", "rename", "verify", "preview.start", "preview.stop", "record.start", "record.stop", "photo.capture", "settings.read", "settings.apply", "files.list", "offer", "close-peer", "recovery.configure", "power.wake")},
|
||||
{ID: "vesc.controller", Name: "VESC", Prefix: "vesc", Kind: "vesc.controller", Plugin: "missioncore.vesc", Version: "0.7.4",
|
||||
Vendor: "0483", Product: "5740", USBName: "ChibiOS/RT Virtual COM Port", Socket: "/run/mission-core-vesc/driver.sock",
|
||||
PrepareUnit: "mission-core-node-vesc-prepare.service", Report: "/var/lib/mission-core-node-profiles/vesc/preparation.json",
|
||||
ActionTimeouts: map[string]time.Duration{"vesc.link.check": 45 * time.Second, "vesc.motor.run": 90 * time.Second, "vesc.drive.run": 120 * time.Second, "vesc.hall.measure": 60 * time.Second, "vesc.foc.calibrate": 300 * time.Second, "vesc.motor.pulse": 60 * time.Second, "vesc.control.release": 60 * time.Second},
|
||||
ProtocolIdentity: true, Actions: actions("prepare", "details", "rename", "verify", "vesc.link.check", "vesc.telemetry.read", "vesc.limits.read", "vesc.config.backup", "vesc.input.read", "vesc.can.read", "vesc.drive.assign", "vesc.drive.layout", "vesc.drive.unassign", "vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.hall.measure", "vesc.foc.calibrate", "vesc.motor.stop", "vesc.control.release")},
|
||||
}
|
||||
|
||||
func modelForDevice(id string) *sensorModel {
|
||||
@@ -74,6 +81,22 @@ func modelDeviceID(model *sensorModel, serial string) string {
|
||||
return model.Prefix + "_" + hex.EncodeToString(h[:])[:32]
|
||||
}
|
||||
|
||||
func preparedDeviceID(command SensorCommand, result any) string {
|
||||
model := modelForDevice(command.Session.DeviceID)
|
||||
if model == nil {
|
||||
return ""
|
||||
}
|
||||
if !model.ProtocolIdentity {
|
||||
return command.Session.DeviceID
|
||||
}
|
||||
if value, ok := result.(map[string]any); ok {
|
||||
if id, ok := value["device_id"].(string); ok && modelForDevice(id) == model {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sensorClient(socket string) *http.Client {
|
||||
return &http.Client{Timeout: 25 * time.Second, Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
|
||||
@@ -110,7 +133,7 @@ func discoverSensors(root string) []usbSensor {
|
||||
}
|
||||
unique := []usbSensor{}
|
||||
for _, item := range items {
|
||||
if !item.stable || counts[item.id] != 1 {
|
||||
if item.model.ProtocolIdentity || !item.stable || counts[item.id] != 1 {
|
||||
item.stable = false
|
||||
item.id = modelDeviceID(item.model, "provisional:"+item.binding)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func (s *Sensors) preparationPhase(c SensorCommand, model *sensorModel, job *pro
|
||||
}
|
||||
op.Preparation = &sensorPreparation{OperationID: c.ID, DeviceID: c.Session.DeviceID, ModelID: model.ID,
|
||||
StartedAt: float64(started.UnixMilli()) / 1000, ProfileStartedAt: job.started, State: state, Phase: phase,
|
||||
Steps: []preparationStep{{ID: "profile", Label: "Подготовка драйвера", State: deploy}, {ID: "verify", Label: "Проверка выбранной камеры", State: verify}}}
|
||||
Steps: []preparationStep{{ID: "profile", Label: "Подготовка драйвера", State: deploy}, {ID: "verify", Label: "Проверка выбранного устройства", State: verify}}}
|
||||
op.Updated = time.Now().Unix()
|
||||
err := s.write(c.ID+".json", op)
|
||||
s.events.notify()
|
||||
@@ -217,18 +217,20 @@ func (s *Sensors) prepare(c SensorCommand, selected map[string]any) (result any,
|
||||
binding := s.discoverySessions[c.Session.DeviceID].binding
|
||||
s.mu.Unlock()
|
||||
if initialBinding != "" && binding != initialBinding {
|
||||
return nil, errors.New("Камера переподключена во время подготовки. Повторите проверку устройства.")
|
||||
return nil, errors.New("Устройство переподключено во время подготовки. Повторите проверку устройства.")
|
||||
}
|
||||
for _, raw := range inventory["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
if item["id"] != c.Session.DeviceID || item["online"] != true || item["prepared"] != true {
|
||||
matches := item["id"] == c.Session.DeviceID || (model.ProtocolIdentity && item["attachment_id"] == c.Session.DeviceID)
|
||||
if !matches || item["online"] != true || item["prepared"] != true || (model.ProtocolIdentity && item["verified"] != true) {
|
||||
continue
|
||||
}
|
||||
verify := c
|
||||
verify.Action = "verify"
|
||||
verify.Session.DeviceID, _ = item["id"].(string)
|
||||
verify.Session.SessionID = sensorSessionID(item)
|
||||
if verify.Session.SessionID == "" {
|
||||
return nil, errors.New("Драйвер не подтвердил сеанс камеры.")
|
||||
return nil, errors.New("Драйвер не подтвердил сеанс устройства.")
|
||||
}
|
||||
response, e := s.modelDriver(ctx, model, "/operation", verify)
|
||||
if e != nil || response["state"] == "unknown" {
|
||||
@@ -237,7 +239,7 @@ func (s *Sensors) prepare(c SensorCommand, selected map[string]any) (result any,
|
||||
if response["state"] != "complete" {
|
||||
message, _ := response["error"].(string)
|
||||
if message == "" {
|
||||
message = "Не удалось проверить изображение выбранной камеры."
|
||||
message = "Не удалось проверить выбранное устройство."
|
||||
}
|
||||
return nil, errors.New(message)
|
||||
}
|
||||
@@ -249,7 +251,7 @@ func (s *Sensors) prepare(c SensorCommand, selected map[string]any) (result any,
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
return nil, errors.New("Драйвер установлен, но камера не открылась. Проверьте USB-подключение.")
|
||||
return nil, errors.New("Драйвер установлен, но устройство не ответило. Проверьте питание и USB-подключение.")
|
||||
}
|
||||
|
||||
// Project only the matching model run. A previous success or another model's
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVESCDeclaredVersionMatchesBundledDriver(t *testing.T) {
|
||||
model := modelForDevice("vesc_00000000000000000000000000000000")
|
||||
root := filepath.Join("..", "..", "..", "..", "plugins", "vesc")
|
||||
driver, err := os.ReadFile(filepath.Join(root, "runtime", "__init__.py"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
match := regexp.MustCompile(`(?m)^VERSION = "([^"]+)"`).FindSubmatch(driver)
|
||||
if model == nil || len(match) != 2 || model.Version != string(match[1]) {
|
||||
t.Fatal("Node must admit the exact driver shipped in the same release", model, string(driver))
|
||||
}
|
||||
preparation, err := os.ReadFile(filepath.Join(root, "packaging", "prepare.py"))
|
||||
if err != nil || !strings.Contains(string(preparation), `"version": "`+model.Version+`"`) {
|
||||
t.Fatal("VESC preparation must declare the bundled driver version", err)
|
||||
}
|
||||
}
|
||||
|
||||
func fakeVESC(t *testing.T, s *Sensors, port, number string) {
|
||||
t.Helper()
|
||||
fakeUSB(t, s.usbRoot, port, "duplicate", "ChibiOS/RT Virtual COM Port", number)
|
||||
for key, value := range map[string]string{"idVendor": "0483", "idProduct": "5740"} {
|
||||
if err := os.WriteFile(filepath.Join(s.usbRoot, port, key), []byte(value), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVESCProvisionalIdentityIsReadOnlyAndDoesNotWeakenCameras(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeVESC(t, s, "1-2", "2")
|
||||
fakeVESC(t, s, "1-3", "3")
|
||||
items := s.Inventory()["items"].([]any)
|
||||
if len(items) != 2 {
|
||||
t.Fatal("both controllers must be visible")
|
||||
}
|
||||
for _, raw := range items {
|
||||
item := raw.(map[string]any)
|
||||
if item["initializable"] != true || item["prepared"] != false {
|
||||
t.Fatal("identity bootstrap unavailable or falsely prepared")
|
||||
}
|
||||
c := sensorTestCommand()
|
||||
c.Session.DeviceID = item["id"].(string)
|
||||
for _, action := range []string{"start", "stop", "option", "settings.apply", "firmware.write"} {
|
||||
c.Action = action
|
||||
if _, err := s.Submit(c, false); err == nil {
|
||||
t.Fatal("VESC acquired write authority", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
if items[0].(map[string]any)["id"] == items[1].(map[string]any)["id"] {
|
||||
t.Fatal("duplicate USB serial collapsed")
|
||||
}
|
||||
fakeUSB(t, s.usbRoot, "2-2", "same", "Insta360 X4", "4")
|
||||
fakeUSB(t, s.usbRoot, "2-3", "same", "Insta360 X4", "5")
|
||||
for _, raw := range s.Inventory()["items"].([]any) {
|
||||
item := raw.(map[string]any)
|
||||
if item["kind"] == "insta360.x4" && item["initializable"] != false {
|
||||
t.Fatal("camera guard weakened")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVESCPreparePromotesAttachmentToProtocolUUID(t *testing.T) {
|
||||
s := isolatedSensors(t)
|
||||
fakeVESC(t, s, "1-2", "2")
|
||||
first := s.Inventory()["items"].([]any)[0].(map[string]any)
|
||||
attachment := first["id"].(string)
|
||||
model := modelForDevice(attachment)
|
||||
stable := modelDeviceID(model, "uuid:synthetic-controller")
|
||||
var installed atomic.Bool
|
||||
s.runPreparation = func(_ context.Context, unit string) error {
|
||||
if unit != "mission-core-node-vesc-prepare.service" {
|
||||
t.Error("wrong profile")
|
||||
}
|
||||
installed.Store(true)
|
||||
return nil
|
||||
}
|
||||
s.clients[model.ID].Transport = sensorRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path == "/inventory" {
|
||||
items := []any{}
|
||||
if installed.Load() {
|
||||
item := s.discovery(stable, "12", true)
|
||||
item["prepared"] = true
|
||||
item["verified"] = true
|
||||
item["configured"] = true
|
||||
item["attachment_id"] = attachment
|
||||
item["snapshot"].(map[string]any)["context"].(map[string]any)["session_id"] = "protocol_session"
|
||||
items = append(items, item)
|
||||
}
|
||||
return testSensorReply(map[string]any{"items": items}), nil
|
||||
}
|
||||
var command SensorCommand
|
||||
_ = json.NewDecoder(r.Body).Decode(&command)
|
||||
if command.Action != "verify" || command.Session.DeviceID != stable || command.Session.SessionID != "protocol_session" {
|
||||
t.Error("verification did not follow protocol identity")
|
||||
}
|
||||
return testSensorReply(map[string]any{"state": "complete", "result": map[string]any{"device_id": stable}}), nil
|
||||
})
|
||||
c := sensorTestCommand()
|
||||
c.Action = "prepare"
|
||||
c.Session = SensorSession{DeviceID: attachment, SessionID: sensorSessionID(first)}
|
||||
if _, err := s.Submit(c, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
awaitSensor(t, func() bool { return s.Get(c.ID).State != "running" })
|
||||
if s.Get(c.ID).State != "complete" {
|
||||
t.Fatal(s.Get(c.ID))
|
||||
}
|
||||
items := s.Inventory()["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["id"] != stable {
|
||||
t.Fatal("provisional row survived promotion")
|
||||
}
|
||||
s.mu.Lock()
|
||||
a, b := s.initialized[stable], s.initialized[attachment]
|
||||
s.mu.Unlock()
|
||||
if !a || b {
|
||||
t.Fatal("initialization persisted transport identity")
|
||||
}
|
||||
// A stale runtime attachment is never presented as a currently connected controller.
|
||||
if err := os.RemoveAll(filepath.Join(s.usbRoot, "1-2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items = s.Inventory()["items"].([]any)
|
||||
if len(items) != 1 || items[0].(map[string]any)["online"] != false {
|
||||
t.Fatal("unplugged driver observation accepted")
|
||||
}
|
||||
}
|
||||
@@ -106,7 +106,9 @@ func OpenSensors(root, nodeID string) (*Sensors, error) {
|
||||
}
|
||||
for _, op := range s.operations {
|
||||
if op.Command.Action == "prepare" && op.State == "complete" {
|
||||
s.initialized[op.Command.Session.DeviceID] = true
|
||||
if id := preparedDeviceID(op.Command, op.Result); id != "" {
|
||||
s.initialized[id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if e := s.write("initialized.json", s.initialized); e != nil {
|
||||
@@ -192,7 +194,7 @@ func (s *Sensors) modelDriver(ctx context.Context, model *sensorModel, path stri
|
||||
}
|
||||
response, e := client.Do(req)
|
||||
if e != nil {
|
||||
return nil, errors.New("Служба камеры недоступна. Подготовьте устройство.")
|
||||
return nil, errors.New("Служба устройства недоступна. Подготовьте устройство.")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
var result map[string]any
|
||||
@@ -241,8 +243,24 @@ func (s *Sensors) Inventory() map[string]any {
|
||||
unsafePreparation[id] = item["preparation_safe"] == false || snapshot["acquisition"] != "idle"
|
||||
continue
|
||||
}
|
||||
if model.ProtocolIdentity {
|
||||
if !currentCameraSnapshot(item, model) {
|
||||
continue
|
||||
}
|
||||
attachment, _ := item["attachment_id"].(string)
|
||||
matched := false
|
||||
for _, candidate := range usb {
|
||||
if candidate.model == model && candidate.id == attachment {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
seen[attachment] = true
|
||||
}
|
||||
s.mu.Lock()
|
||||
if model.PrepareUnit != "" {
|
||||
if model.PrepareUnit != "" && !model.ProtocolIdentity {
|
||||
item["configured"] = s.initialized[id]
|
||||
}
|
||||
if name := s.names[id]; name != "" {
|
||||
@@ -325,13 +343,18 @@ func (s *Sensors) discovery(id, speed string, online bool) map[string]any {
|
||||
if !initializable {
|
||||
stability, basis = "provisional", "transport-local"
|
||||
}
|
||||
// VESC bootstrap grants only a fixed identity read on the selected attachment.
|
||||
// This does not promote the attachment to stable identity or allow motor control.
|
||||
if online && model.ProtocolIdentity {
|
||||
initializable = true
|
||||
}
|
||||
return map[string]any{"id": id, "name": name, "model": model.Name, "kind": model.Kind, "initializable": initializable, "configured": configured, "prepared": false, "verified": false, "online": online, "usb": speed, "layers": []any{}, "snapshot": map[string]any{
|
||||
"context": map[string]any{"session_id": session, "device": map[string]any{"device_id": id, "model": map[string]string{"plugin_id": model.Plugin, "plugin_version": model.Version, "model_id": model.ID}, "stability": stability, "basis": basis}, "execution": map[string]string{"node_id": s.nodeID, "agent_instance_id": s.instance, "platform": "linux"}, "opened_at": now},
|
||||
"revision": 0, "enrollment": enrollment, "connectivity": connectivity, "acquisition": "idle", "observed_at": now}}
|
||||
}
|
||||
|
||||
func sensorViewAction(action string) bool {
|
||||
return action == "details" || action == "offer" || action == "close-peer" || action == "settings.read" || action == "files.list"
|
||||
return action == "details" || action == "offer" || action == "close-peer" || action == "settings.read" || action == "files.list" || action == "vesc.telemetry.read" || action == "vesc.limits.read" || action == "vesc.config.backup"
|
||||
}
|
||||
|
||||
func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error) {
|
||||
@@ -365,10 +388,10 @@ func (s *Sensors) Submit(c SensorCommand, remote bool) (*SensorOperation, error)
|
||||
return nil, errors.New("Срок команды истёк. Устройство не изменено.")
|
||||
}
|
||||
for _, v := range s.operations {
|
||||
if v.State == "running" && v.Command.Action == "prepare" && (v.Preparation == nil || v.Preparation.Phase == "profile") && c.Action != "prepare" && !sensorViewAction(c.Action) && modelForDevice(v.Command.Session.DeviceID) == model {
|
||||
if v.State == "running" && v.Command.Action == "prepare" && (v.Preparation == nil || v.Preparation.Phase == "profile") && c.Action != "prepare" && c.Action != "vesc.motor.stop" && !sensorViewAction(c.Action) && modelForDevice(v.Command.Session.DeviceID) == model {
|
||||
return nil, errors.New("Подготовка модели ещё выполняется.")
|
||||
}
|
||||
if v.State == "running" && v.Command.Session.DeviceID == c.Session.DeviceID && !sensorViewAction(c.Action) && !sensorViewAction(v.Command.Action) {
|
||||
if v.State == "running" && v.Command.Session.DeviceID == c.Session.DeviceID && c.Action != "vesc.motor.stop" && !sensorViewAction(c.Action) && !sensorViewAction(v.Command.Action) {
|
||||
return nil, errors.New("Другая операция устройства ещё выполняется.")
|
||||
}
|
||||
}
|
||||
@@ -411,11 +434,11 @@ func (s *Sensors) execute(c SensorCommand) {
|
||||
if !deadline.After(time.Now()) {
|
||||
err = errors.New("Срок команды истёк. Устройство не изменено.")
|
||||
} else if item == nil {
|
||||
err = errors.New("Камера не обнаружена. Проверьте подключение.")
|
||||
err = errors.New("Устройство не обнаружено. Проверьте подключение.")
|
||||
} else if c.Action == "prepare" && item["online"] != true {
|
||||
err = errors.New("Камера отключена. Проверьте подключение.")
|
||||
err = errors.New("Устройство отключено. Проверьте подключение.")
|
||||
} else if c.Action == "prepare" && item["initializable"] == false {
|
||||
err = errors.New("Не удалось однозначно определить камеру. Проверьте её идентификатор и подключение.")
|
||||
err = errors.New("Не удалось однозначно определить устройство. Проверьте его идентификатор и подключение.")
|
||||
} else if (c.Action == "prepare" || c.Action == "rename") && sensorSessionID(item) != c.Session.SessionID {
|
||||
err = errors.New("Сеанс устройства изменился. Обновите сведения.")
|
||||
} else if c.Action == "prepare" {
|
||||
@@ -451,7 +474,12 @@ func (s *Sensors) execute(c SensorCommand) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err == nil && c.Action == "prepare" {
|
||||
s.initialized[c.Session.DeviceID] = true
|
||||
initializedID := preparedDeviceID(c, result)
|
||||
if initializedID == "" {
|
||||
err = errors.New("Драйвер не подтвердил личность устройства.")
|
||||
} else {
|
||||
s.initialized[initializedID] = true
|
||||
}
|
||||
if e := s.write("initialized.json", s.initialized); e != nil {
|
||||
err = e
|
||||
uncertain = true
|
||||
@@ -496,6 +524,7 @@ func (s *Sensors) RemoteResults() []any {
|
||||
return out
|
||||
}
|
||||
func (s *Sensors) Routes(mux *http.ServeMux, server *Server) {
|
||||
s.configurationRoutes(mux, server)
|
||||
mux.HandleFunc("GET /api/devices/events", func(w http.ResponseWriter, r *http.Request) { s.stream(w, r, server) })
|
||||
mux.HandleFunc("GET /api/devices", func(w http.ResponseWriter, r *http.Request) {
|
||||
if server.authorized(w, r) {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import {vescSensorUi} from '../../../../plugins/vesc/frontend/src/plugin';
|
||||
import {xgridsK1SensorUi} from '../../../../plugins/xgrids-k1/frontend/src/sensors/plugin';
|
||||
import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/plugin';
|
||||
import {createIsolatedRerunHost} from '../../../control-station/src/components/rerun/isolatedRerunHost';
|
||||
import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {request} from './api';
|
||||
const transport:SensorTransport={localPreview:{open:(command,signal)=>fetch("/api/devices/preview",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify(command),signal}),read:(peer,after,signal)=>fetch("/api/devices/preview/read",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({peer_id:peer,after}),signal})},enrollment:{state:()=>request("/api/devices/enrollment"),submit:value=>request("/api/devices/enrollment/operations","POST",value),operation:id=>request("/api/devices/enrollment/operations/"+encodeURIComponent(id))},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 contributions={[xgridsK1SensorUi,insta360X4SensorUi]} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
|
||||
import {request,type Status} from './api';
|
||||
import {Button,Icon,SettingsCard} from '@nodedc/ui-react';
|
||||
import {createBoardLayoutStore} from '../../../../packages/sensor-ui/src/boardLayout';
|
||||
const layout=createBoardLayoutStore({read:()=>request('/api/presentation/board-layout'),patch:(section,open)=>request('/api/presentation/board-layout','PATCH',{section,open})});
|
||||
const configurationArchive:NonNullable<SensorTransport['configurationArchive']>={list:(device,before)=>request(`/api/device-configurations/${encodeURIComponent(device)}${before?'?before='+encodeURIComponent(before):''}`),read:(device,version)=>request(`/api/device-configurations/${encodeURIComponent(device)}/${encodeURIComponent(version)}`)};
|
||||
const transport:SensorTransport={configurationArchive,localPreview:{open:(command,signal)=>fetch("/api/devices/preview",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify(command),signal}),read:(peer,after,signal)=>fetch("/api/devices/preview/read",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({peer_id:peer,after}),signal})},enrollment:{state:()=>request("/api/devices/enrollment"),submit:value=>request("/api/devices/enrollment/operations","POST",value),operation:id=>request("/api/devices/enrollment/operations/"+encodeURIComponent(id))},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({value,openSettings}:{value:Status;openSettings:()=>void}){return <SensorWorkspace board={{layout,description:value.name,computer:<SettingsCard title={value.name} description={value.host.hostname}><dl className="node-facts"><div><dt>Бортовой компьютер</dt><dd>{value.node_id}</dd></div><div><dt>Операционная система</dt><dd>{value.host.os}</dd></div><div><dt>Архитектура</dt><dd>{value.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{value.host.cpus}</dd></div><div><dt>Память</dt><dd>{value.host.memory_kib?`${(value.host.memory_kib/1048576).toFixed(1)} ГиБ`:"Нет сведений"}</dd></div><div><dt>Mission Core Node</dt><dd>{value.version}</dd></div></dl><Button onClick={openSettings}><Icon name="settings"/>Подготовка среды</Button></SettingsCard>}} contributions={[xgridsK1SensorUi,insta360X4SensorUi,vescSensorUi]} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
|
||||
|
||||
Reference in New Issue
Block a user