feat(fleet): preserve operator VESC integration before final driver merge

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 16:40:46 +03:00
parent dad11b47d7
commit 71a648fec8
128 changed files with 22390 additions and 59 deletions
@@ -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")
}
}
+24 -1
View File
@@ -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")
}
}
+39 -10
View File
@@ -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;
}
});
+2 -1
View File
@@ -11,7 +11,7 @@ import sys
from build_deb import build, VERSION, BRAND_SHA256
ROOT = Path(__file__).resolve().parents[1]
DG_COMMIT = "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}
+5 -3
View File
@@ -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",
+4
View File
@@ -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.
+5
View File
@@ -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
+15
View File
@@ -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
+4 -2
View File
@@ -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}/>;}