feat(fleet): preserve operator VESC integration before final driver merge
This commit is contained in:
@@ -307,6 +307,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 +340,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.6.3",
|
||||
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.config.backup", "vesc.input.read", "vesc.can.read", "vesc.drive.assign", "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,122 @@
|
||||
package node
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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.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,7 +1,7 @@
|
||||
// An authenticated Node action may start only this fixed model job.
|
||||
polkit.addRule(function(action, subject) {
|
||||
var unit = action.lookup("unit");
|
||||
if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && (unit === "mission-core-node-realsense-prepare.service" || unit === "mission-core-node-insta360-x4-profile.service") && action.lookup("verb") === "start") {
|
||||
if (subject.user === "mission-core-node" && action.id === "org.freedesktop.systemd1.manage-units" && (unit === "mission-core-node-vesc-prepare.service" || unit === "mission-core-node-realsense-prepare.service" || unit === "mission-core-node-insta360-x4-profile.service") && action.lookup("verb") === "start") {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import sys
|
||||
from build_deb import build, VERSION, BRAND_SHA256
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DG_COMMIT = "8c53f73ee521561a1cc7944a7f7cf113702f40b5"
|
||||
DG_COMMIT = "8dd9190573d6616024ef01b9b34bf90b72960f44"
|
||||
|
||||
|
||||
def guideline_sources():
|
||||
@@ -36,6 +36,7 @@ def provenance():
|
||||
"design_guideline_files": guideline_sources(),
|
||||
"shared_sensor_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/sensor-ui/src").rglob("*")) if p.is_file()},
|
||||
"shared_spatial_ui_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "packages/spatial-ui/src").rglob("*")) if p.is_file()},
|
||||
"vesc_plugin_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "plugins/vesc").rglob("*")) if p.is_file() and not any(x in p.relative_to(ROOT.parents[1]).parts for x in ("__pycache__", "build"))},
|
||||
"k1_frontend_files": {str(p.relative_to(ROOT.parents[1])): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted((ROOT.parents[1] / "plugins/xgrids-k1/frontend/src").rglob("*")) if p.is_file()},
|
||||
"toolchain": json.loads((ROOT / "toolchain.json").read_text()), "files": files}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import sys
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BINARY_VERSION = "0.8.21"
|
||||
VERSION = "0.8.21-3"
|
||||
BINARY_VERSION = "0.8.35"
|
||||
VERSION = "0.8.35-1"
|
||||
sys.path.insert(0, str(ROOT.parents[1] / "scripts/packaging"))
|
||||
from debian import package
|
||||
|
||||
@@ -44,7 +44,7 @@ Architecture: amd64
|
||||
Maintainer: NODE.DC local build <noreply@example.invalid>
|
||||
Section: admin
|
||||
Priority: optional
|
||||
Depends: adduser, systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2, python3-psycopg2, postgresql-16 (>= 16.15), timescaledb-2-oss-postgresql-16 (= 2.29.2~ubuntu24.04-1615), udev, libc6 (>= 2.35), libstdc++6 (>= 12), libgcc-s1, zlib1g
|
||||
Depends: adduser, systemd (>= 255), python3 (>= 3.12), python3 (<< 3.13), python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme, network-manager, iproute2, python3-psycopg2, postgresql-16 (>= 16.15), timescaledb-2-oss-postgresql-16 (= 2.29.2~ubuntu24.04-1615), udev, libc6 (>= 2.39), libstdc++6 (>= 12), libgcc-s1, zlib1g
|
||||
Description: Mission Core onboard computer configuration
|
||||
Local graphical setup, host inventory, SSH access and persistent node identity.
|
||||
""".encode()
|
||||
@@ -111,6 +111,8 @@ Description: Mission Core onboard computer configuration
|
||||
files.append(("usr/lib/mission-core-node/sdk/missioncore_plugin_sdk/" + str(path.relative_to(sdk)), path.read_bytes(), 0o644))
|
||||
if (ROOT / "build/provenance.json").exists():
|
||||
files.append(("usr/share/doc/mission-core-node/provenance.json", (ROOT / "build/provenance.json").read_bytes(), 0o644))
|
||||
import runpy
|
||||
files.extend(runpy.run_path(str(ROOT.parents[1] / "plugins/vesc/packaging/payload.py"))["payload"]())
|
||||
archive = package(controls, files)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(archive)
|
||||
|
||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
NODE = Path(__file__).resolve().parents[1]
|
||||
REPO = NODE.parents[1]
|
||||
DG = REPO.parent / "NODEDC_DESIGN_GUIDELINE"
|
||||
DG_COMMIT = "8c53f73ee521561a1cc7944a7f7cf113702f40b5"
|
||||
DG_COMMIT = "8dd9190573d6616024ef01b9b34bf90b72960f44"
|
||||
|
||||
|
||||
def files(root):
|
||||
@@ -73,6 +73,13 @@ def build(qualified, node_only=False):
|
||||
# board, runtime path override or operator compiler dependency is needed.
|
||||
virtual = str(REPO.name + "/apps/node-agent/build/model-packages/" + package_name)
|
||||
entries[virtual] = qualified
|
||||
native_root = REPO / "plugins/vesc"
|
||||
native = json.loads((native_root / "packaging/native-runtime.json").read_text())
|
||||
native_path = native_root / "build/native-runtime" / native["file"]
|
||||
native_bytes = native_path.read_bytes()
|
||||
if len(native_bytes) != native["bytes"] or hashlib.sha256(native_bytes).hexdigest() != native["sha256"]:
|
||||
raise ValueError("Qualified VESC Tool runtime changed")
|
||||
entries[REPO.name + "/plugins/vesc/build/native-runtime/" + native["file"]] = native_path
|
||||
metadata = {}
|
||||
for name, path in entries.items():
|
||||
data = path.read_bytes()
|
||||
|
||||
@@ -5,7 +5,7 @@ mc_node_release_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
if [ ! -t 0 ]; then
|
||||
exec /usr/bin/gnome-terminal --wait --title="Mission Core Node" -- "$mc_node_release_dir/install"
|
||||
fi
|
||||
printf '%s\n' 'Mission Core Node' 'Обновление приложения и встроенных пакетов камер на этом Ubuntu-компьютере.' 'Подготовка X4 выполняется затем из списка устройств в Node или Core.' 'Пароль администратора вводится только в этом локальном окне Ubuntu.'
|
||||
printf '%s\n' 'Mission Core Node' 'Обновление приложения и встроенных драйверов на этом Ubuntu-компьютере.' 'Подготовка VESC и X4 выполняется затем из списка устройств в Node или Core.' 'Пароль администратора вводится только в этом локальном окне Ubuntu.'
|
||||
set +e
|
||||
/usr/bin/sudo /usr/bin/python3 -I "$mc_node_release_dir/install_release.py" 2>&1 | /usr/bin/tee -a "$mc_node_release_dir/install-output.log"
|
||||
mc_node_install_result=${PIPESTATUS[0]}
|
||||
|
||||
@@ -351,7 +351,7 @@ def main():
|
||||
if report["state"] != "complete":
|
||||
raise RuntimeError(report["error"])
|
||||
print(
|
||||
"Mission Core Node обновлён. X4 можно подготовить из списка устройств в Node или Core.",
|
||||
"Mission Core Node обновлён. VESC и X4 можно подготовить из списка устройств в Node или Core.",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -243,6 +243,7 @@ def main():
|
||||
sys.path.insert(0, str(node / "packaging"))
|
||||
from build_deb import BINARY_VERSION, VERSION, build
|
||||
|
||||
run("vesc-reader-tests", ["/usr/bin/python3", "-m", "unittest", "discover", "-s", "plugins/vesc/tests", "-v"], cwd=repo)
|
||||
binary = node / "build/node-agent-linux-amd64"
|
||||
run(
|
||||
"node-binary",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
set -eu
|
||||
case "$1" in
|
||||
configure)
|
||||
/usr/bin/python3 -I /usr/lib/mission-core-vesc/clear_runtime_cache.py
|
||||
if ! getent passwd mission-core-node >/dev/null; then
|
||||
adduser --system --group --home /var/lib/mission-core-node --no-create-home --disabled-login mission-core-node
|
||||
fi
|
||||
@@ -15,6 +16,9 @@ case "$1" in
|
||||
systemctl enable mission-core-node.service
|
||||
systemctl restart mission-core-node.service
|
||||
systemctl try-restart mission-core-realsense.service
|
||||
if [ -f /var/lib/mission-core-node-profiles/vesc/preparation.json ]; then
|
||||
systemctl start mission-core-node-vesc-prepare.service
|
||||
fi
|
||||
if [ -f /run/mission-core-node-k1-upgrade-active ]; then
|
||||
# A jointly upgraded plugin starts itself after its own configuration.
|
||||
# Restore it here only when that package is already configured.
|
||||
|
||||
@@ -18,6 +18,10 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
||||
case "$mc_node_x4_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_vesc_job=$(systemctl show --property=ActiveState --value mission-core-node-vesc-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_vesc_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки VESC." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_device_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
||||
@@ -38,6 +42,7 @@ if [ "$1" = install ] || [ "$1" = upgrade ]; then
|
||||
(umask 077; : > /run/mission-core-node-k1-upgrade-active)
|
||||
systemctl stop mission-core-k1.service
|
||||
fi
|
||||
systemctl stop mission-core-vesc.service 2>/dev/null || true
|
||||
systemctl stop mission-core-node-monitor.service 2>/dev/null || true
|
||||
fi
|
||||
. /etc/os-release
|
||||
|
||||
@@ -17,6 +17,10 @@ if [ -d /run/systemd/system ]; then
|
||||
case "$mc_node_x4_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки X4." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_vesc_job=$(systemctl show --property=ActiveState --value mission-core-node-vesc-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_vesc_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки VESC." >&2; exit 1 ;;
|
||||
esac
|
||||
mc_node_device_job=$(systemctl show --property=ActiveState --value mission-core-node-realsense-prepare.service 2>/dev/null || true)
|
||||
case "$mc_node_device_job" in
|
||||
active|activating) echo "Mission Core Node: дождитесь завершения подготовки устройства." >&2; exit 1 ;;
|
||||
@@ -30,8 +34,19 @@ if [ -d /run/systemd/system ]; then
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
case "$1" in
|
||||
upgrade|remove|deconfigure)
|
||||
if [ -d /run/systemd/system ] && [ -f /usr/lib/systemd/system/mission-core-vesc.service ]; then
|
||||
systemctl disable --now mission-core-vesc.service
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
case "$1" in
|
||||
remove|deconfigure)
|
||||
if cmp -s /usr/share/mission-core-node/profiles/vesc/70-mission-core-vesc.rules /etc/udev/rules.d/70-mission-core-vesc.rules; then
|
||||
rm /etc/udev/rules.d/70-mission-core-vesc.rules
|
||||
if [ -d /run/systemd/system ]; then udevadm control --reload-rules; fi
|
||||
fi
|
||||
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf
|
||||
if [ -e "$mc_node_ssh_snippet" ]; then
|
||||
if cmp -s /usr/share/mission-core-node/60-mission-core-node.conf "$mc_node_ssh_snippet"; then
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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}/>;}
|
||||
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(){return <SensorWorkspace contributions={[xgridsK1SensorUi,insta360X4SensorUi,vescSensorUi]} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
|
||||
|
||||
Reference in New Issue
Block a user