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) {
|
func (p *Pairing) Run(ctx context.Context) {
|
||||||
go p.channel(ctx)
|
go p.channel(ctx)
|
||||||
|
go p.roverChannel(ctx)
|
||||||
var server *http.Server
|
var server *http.Server
|
||||||
endpoint := ""
|
endpoint := ""
|
||||||
serverIdentity := ""
|
serverIdentity := ""
|
||||||
@@ -307,6 +308,9 @@ func (p *Pairing) channel(ctx context.Context) {
|
|||||||
payload["devices"] = inv["items"]
|
payload["devices"] = inv["items"]
|
||||||
payload["sensor_state"] = inv
|
payload["sensor_state"] = inv
|
||||||
payload["sensor_results"] = p.Sensors.RemoteResults()
|
payload["sensor_results"] = p.Sensors.RemoteResults()
|
||||||
|
if batch := p.Sensors.ConfigurationBatch(binding.BindingID); batch != nil {
|
||||||
|
payload["vesc_configurations"] = batch
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if p.DeviceEnrollment != nil {
|
if p.DeviceEnrollment != nil {
|
||||||
payload["device_enrollment"] = p.DeviceEnrollment.Status()
|
payload["device_enrollment"] = p.DeviceEnrollment.Status()
|
||||||
@@ -337,6 +341,7 @@ func (p *Pairing) channel(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if p.Sensors != nil {
|
if p.Sensors != nil {
|
||||||
|
p.Sensors.AcknowledgeConfigurations(binding.BindingID, result["vesc_configurations_ack"], payload["vesc_configurations"])
|
||||||
var ack []string
|
var ack []string
|
||||||
if json.Unmarshal(result["sensor_acknowledgements"], &ack) == nil {
|
if json.Unmarshal(result["sensor_acknowledgements"], &ack) == nil {
|
||||||
p.Sensors.Acknowledge(ack)
|
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
|
Socket, PrepareUnit, Report string
|
||||||
Actions map[string]bool
|
Actions map[string]bool
|
||||||
ActionTimeouts map[string]time.Duration
|
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 {
|
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",
|
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},
|
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")},
|
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 {
|
func modelForDevice(id string) *sensorModel {
|
||||||
@@ -74,6 +81,22 @@ func modelDeviceID(model *sensorModel, serial string) string {
|
|||||||
return model.Prefix + "_" + hex.EncodeToString(h[:])[:32]
|
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 {
|
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 &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)
|
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
|
||||||
@@ -110,7 +133,7 @@ func discoverSensors(root string) []usbSensor {
|
|||||||
}
|
}
|
||||||
unique := []usbSensor{}
|
unique := []usbSensor{}
|
||||||
for _, item := range items {
|
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.stable = false
|
||||||
item.id = modelDeviceID(item.model, "provisional:"+item.binding)
|
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,
|
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,
|
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()
|
op.Updated = time.Now().Unix()
|
||||||
err := s.write(c.ID+".json", op)
|
err := s.write(c.ID+".json", op)
|
||||||
s.events.notify()
|
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
|
binding := s.discoverySessions[c.Session.DeviceID].binding
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if initialBinding != "" && binding != initialBinding {
|
if initialBinding != "" && binding != initialBinding {
|
||||||
return nil, errors.New("Камера переподключена во время подготовки. Повторите проверку устройства.")
|
return nil, errors.New("Устройство переподключено во время подготовки. Повторите проверку устройства.")
|
||||||
}
|
}
|
||||||
for _, raw := range inventory["items"].([]any) {
|
for _, raw := range inventory["items"].([]any) {
|
||||||
item := raw.(map[string]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
|
continue
|
||||||
}
|
}
|
||||||
verify := c
|
verify := c
|
||||||
verify.Action = "verify"
|
verify.Action = "verify"
|
||||||
|
verify.Session.DeviceID, _ = item["id"].(string)
|
||||||
verify.Session.SessionID = sensorSessionID(item)
|
verify.Session.SessionID = sensorSessionID(item)
|
||||||
if verify.Session.SessionID == "" {
|
if verify.Session.SessionID == "" {
|
||||||
return nil, errors.New("Драйвер не подтвердил сеанс камеры.")
|
return nil, errors.New("Драйвер не подтвердил сеанс устройства.")
|
||||||
}
|
}
|
||||||
response, e := s.modelDriver(ctx, model, "/operation", verify)
|
response, e := s.modelDriver(ctx, model, "/operation", verify)
|
||||||
if e != nil || response["state"] == "unknown" {
|
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" {
|
if response["state"] != "complete" {
|
||||||
message, _ := response["error"].(string)
|
message, _ := response["error"].(string)
|
||||||
if message == "" {
|
if message == "" {
|
||||||
message = "Не удалось проверить изображение выбранной камеры."
|
message = "Не удалось проверить выбранное устройство."
|
||||||
}
|
}
|
||||||
return nil, errors.New(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):
|
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
|
// 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 {
|
for _, op := range s.operations {
|
||||||
if op.Command.Action == "prepare" && op.State == "complete" {
|
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 {
|
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)
|
response, e := client.Do(req)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return nil, errors.New("Служба камеры недоступна. Подготовьте устройство.")
|
return nil, errors.New("Служба устройства недоступна. Подготовьте устройство.")
|
||||||
}
|
}
|
||||||
defer response.Body.Close()
|
defer response.Body.Close()
|
||||||
var result map[string]any
|
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"
|
unsafePreparation[id] = item["preparation_safe"] == false || snapshot["acquisition"] != "idle"
|
||||||
continue
|
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()
|
s.mu.Lock()
|
||||||
if model.PrepareUnit != "" {
|
if model.PrepareUnit != "" && !model.ProtocolIdentity {
|
||||||
item["configured"] = s.initialized[id]
|
item["configured"] = s.initialized[id]
|
||||||
}
|
}
|
||||||
if name := s.names[id]; name != "" {
|
if name := s.names[id]; name != "" {
|
||||||
@@ -325,13 +343,18 @@ func (s *Sensors) discovery(id, speed string, online bool) map[string]any {
|
|||||||
if !initializable {
|
if !initializable {
|
||||||
stability, basis = "provisional", "transport-local"
|
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{
|
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},
|
"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}}
|
"revision": 0, "enrollment": enrollment, "connectivity": connectivity, "acquisition": "idle", "observed_at": now}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sensorViewAction(action string) bool {
|
func sensorViewAction(action string) bool {
|
||||||
return action == "details" || action == "offer" || action == "close-peer" || 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) {
|
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("Срок команды истёк. Устройство не изменено.")
|
return nil, errors.New("Срок команды истёк. Устройство не изменено.")
|
||||||
}
|
}
|
||||||
for _, v := range s.operations {
|
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("Подготовка модели ещё выполняется.")
|
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("Другая операция устройства ещё выполняется.")
|
return nil, errors.New("Другая операция устройства ещё выполняется.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,11 +434,11 @@ func (s *Sensors) execute(c SensorCommand) {
|
|||||||
if !deadline.After(time.Now()) {
|
if !deadline.After(time.Now()) {
|
||||||
err = errors.New("Срок команды истёк. Устройство не изменено.")
|
err = errors.New("Срок команды истёк. Устройство не изменено.")
|
||||||
} else if item == nil {
|
} else if item == nil {
|
||||||
err = errors.New("Камера не обнаружена. Проверьте подключение.")
|
err = errors.New("Устройство не обнаружено. Проверьте подключение.")
|
||||||
} else if c.Action == "prepare" && item["online"] != true {
|
} else if c.Action == "prepare" && item["online"] != true {
|
||||||
err = errors.New("Камера отключена. Проверьте подключение.")
|
err = errors.New("Устройство отключено. Проверьте подключение.")
|
||||||
} else if c.Action == "prepare" && item["initializable"] == false {
|
} 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 {
|
} else if (c.Action == "prepare" || c.Action == "rename") && sensorSessionID(item) != c.Session.SessionID {
|
||||||
err = errors.New("Сеанс устройства изменился. Обновите сведения.")
|
err = errors.New("Сеанс устройства изменился. Обновите сведения.")
|
||||||
} else if c.Action == "prepare" {
|
} else if c.Action == "prepare" {
|
||||||
@@ -451,7 +474,12 @@ func (s *Sensors) execute(c SensorCommand) {
|
|||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if err == nil && c.Action == "prepare" {
|
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 {
|
if e := s.write("initialized.json", s.initialized); e != nil {
|
||||||
err = e
|
err = e
|
||||||
uncertain = true
|
uncertain = true
|
||||||
@@ -496,6 +524,7 @@ func (s *Sensors) RemoteResults() []any {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
func (s *Sensors) Routes(mux *http.ServeMux, server *Server) {
|
func (s *Sensors) Routes(mux *http.ServeMux, server *Server) {
|
||||||
|
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/events", func(w http.ResponseWriter, r *http.Request) { s.stream(w, r, server) })
|
||||||
mux.HandleFunc("GET /api/devices", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("GET /api/devices", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if server.authorized(w, r) {
|
if server.authorized(w, r) {
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
import {vescSensorUi} from '../../../../plugins/vesc/frontend/src/plugin';
|
||||||
import {xgridsK1SensorUi} from '../../../../plugins/xgrids-k1/frontend/src/sensors/plugin';
|
import {xgridsK1SensorUi} from '../../../../plugins/xgrids-k1/frontend/src/sensors/plugin';
|
||||||
import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/plugin';
|
import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/plugin';
|
||||||
import {createIsolatedRerunHost} from '../../../control-station/src/components/rerun/isolatedRerunHost';
|
import {createIsolatedRerunHost} from '../../../control-station/src/components/rerun/isolatedRerunHost';
|
||||||
import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace';
|
import {SensorWorkspace} from '../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||||
import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
import type {SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||||
import {request} from './api';
|
import {request,type Status} 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))};
|
import {Button,Icon,SettingsCard} from '@nodedc/ui-react';
|
||||||
export function NodeSensors(){return <SensorWorkspace contributions={[xgridsK1SensorUi,insta360X4SensorUi]} createRerunHost={createIsolatedRerunHost} transport={transport}/>;}
|
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}/>;}
|
||||||
|
|||||||
@@ -9,8 +9,9 @@ import {sensorStatus} from './sensorStatus';
|
|||||||
import {sensorContribution,type SensorUiContribution,wirelessContributions} from './extensions';
|
import {sensorContribution,type SensorUiContribution,wirelessContributions} from './extensions';
|
||||||
import type {RerunHostFactory} from './rerunHost';
|
import type {RerunHostFactory} from './rerunHost';
|
||||||
import './sensors.css';
|
import './sensors.css';
|
||||||
|
import {BoardSections,type BoardSectionsProps} from './BoardSections';
|
||||||
import {WirelessEnrollmentWindow} from './WirelessEnrollmentWindow';
|
import {WirelessEnrollmentWindow} from './WirelessEnrollmentWindow';
|
||||||
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,contributions=[]}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[]}){
|
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,contributions=[],board}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[];board?:BoardSectionsProps}){
|
||||||
const [adding,setAdding]=useState(false);
|
const [adding,setAdding]=useState(false);
|
||||||
const [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<ReadonlyMap<string,string>>(()=>new Map());const activeActions=useRef(new Map<string,string>());const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
|
const [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<ReadonlyMap<string,string>>(()=>new Map());const activeActions=useRef(new Map<string,string>());const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
|
||||||
const failure=useCallback((e:unknown)=>{setError(e===null?'':e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
|
const failure=useCallback((e:unknown)=>{setError(e===null?'':e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
|
||||||
@@ -44,7 +45,7 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
|||||||
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
|
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
|
||||||
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
|
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
|
||||||
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
|
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
|
||||||
return <div className="sensor-workspace">{device?Detail?<Detail key={device.snapshot.context.session_id} enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:<>
|
const inventoryView=<>
|
||||||
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&wirelessContributions(contributions).length>0&&<IconButton label="Подключить беспроводное устройство к БК" disabled={!enabled} onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>}<IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>{void refresh();}}><Icon name="refresh"/></IconButton></div></div>
|
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&wirelessContributions(contributions).length>0&&<IconButton label="Подключить беспроводное устройство к БК" disabled={!enabled} onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>}<IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>{void refresh();}}><Icon name="refresh"/></IconButton></div></div>
|
||||||
{!inventory?<LoadingRegion loading label="Получение устройств БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
{!inventory?<LoadingRegion loading label="Получение устройств БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
||||||
const operation=inventory.operations?.find(v=>v.device_id===item.id&&['queued','running'].includes(v.state));const busy=!!operation||localBusy.has(item.id);
|
const operation=inventory.operations?.find(v=>v.device_id===item.id&&['queued','running'].includes(v.state));const busy=!!operation||localBusy.has(item.id);
|
||||||
@@ -52,9 +53,11 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
|||||||
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
||||||
const prep=devicePreparation(inventory,item);
|
const prep=devicePreparation(inventory,item);
|
||||||
const status=(sensorContribution(contributions,item)?.status??sensorStatus)(item,enabled&&fresh);const label=busy?(operation?.action_id==='prepare'?'Подготовка устройства':'Выполняется команда'):status.label;
|
const status=(sensorContribution(contributions,item)?.status??sensorStatus)(item,enabled&&fresh);const label=busy?(operation?.action_id==='prepare'?'Подготовка устройства':'Выполняется команда'):status.label;
|
||||||
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy&&pending==='prepare'?preparationProgress(prep,label):undefined} status={<StatusBadge variant={configured?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured?null:label}</StatusBadge>} actions={<><SensorRowActions device={item} actions={sensorContribution(contributions,item)?.rowActions?.(item)} enabled={enabled&&fresh} busy={busy} pending={pending} perform={(name,parameters)=>void action(item,name,parameters)}/>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.initializable===false||item.preparation_safe===false||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton></>}/></li>;})}</ResourceList>}
|
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy&&pending==='prepare'?preparationProgress(prep,label):undefined} status={<StatusBadge variant={configured?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured?null:label}</StatusBadge>} actions={<><SensorRowActions device={item} actions={sensorContribution(contributions,item)?.rowActions?.(item)} enabled={enabled&&fresh} busy={busy} pending={pending} perform={(name,parameters)=>void action(item,name,parameters)}/>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.initializable===false||item.preparation_safe===false||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}{sensorContribution(contributions,item)?.detailLabel?<Button disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}>{sensorContribution(contributions,item)?.detailLabel}</Button>:<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton>}</>}/></li>;})}</ResourceList>}
|
||||||
<SensorPreparations inventory={inventory}/>
|
<SensorPreparations inventory={inventory}/>
|
||||||
</>}
|
</>;
|
||||||
|
const boardSettings=contributions.filter(value=>value.BoardSettings&&contributions.filter(other=>other.kind===value.kind).length===1);
|
||||||
|
return <div className="sensor-workspace">{device?Detail?<Detail key={device.snapshot.context.session_id} enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:(board?<BoardSections {...board} settings={<div className="sensor-content">{boardSettings.map(contribution=>{const View=contribution.BoardSettings!;return <View key={contribution.kind} inventory={inventory} transport={transport} enabled={enabled&&fresh} refresh={refresh} failure={failure} openDevice={setSelected}/>;})}{!boardSettings.length&&<p>Для подключённых устройств общие настройки пока недоступны.</p>}</div>} devices={inventoryView}/>:inventoryView)}
|
||||||
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={localBusy.has(editing?.id??'')} onClick={()=>setEditing(null)}>Отмена</Button><Button loading={localBusy.get(editing?.id??'')==='rename'} disabled={localBusy.has(editing?.id??'')||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={localBusy.has(editing?.id??'')}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={localBusy.has(editing?.id??'')||!enabled||!fresh||!editingCurrent?.online||editingCurrent?.initializable===false||editingCurrent?.preparation_safe===false||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
|
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={localBusy.has(editing?.id??'')} onClick={()=>setEditing(null)}>Отмена</Button><Button loading={localBusy.get(editing?.id??'')==='rename'} disabled={localBusy.has(editing?.id??'')||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={localBusy.has(editing?.id??'')}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={localBusy.has(editing?.id??'')||!enabled||!fresh||!editingCurrent?.online||editingCurrent?.initializable===false||editingCurrent?.preparation_safe===false||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
|
||||||
{adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&<WirelessEnrollmentWindow contributions={contributions} transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}} onComplete={completeEnrollment}/>}
|
{adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&<WirelessEnrollmentWindow contributions={contributions} transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}} onComplete={completeEnrollment}/>}
|
||||||
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export interface Sensor {
|
|||||||
kind?:string; connection_label?:string; control?:{generation:number;revision:number;phase:string;can_start:boolean;can_stop?:boolean;can_verify?:boolean;network_applied?:boolean;reason_code?:string|null;acquisition_id:string|null;acquisition_phase?:string};
|
kind?:string; connection_label?:string; control?:{generation:number;revision:number;phase:string;can_start:boolean;can_stop?:boolean;can_verify?:boolean;network_applied?:boolean;reason_code?:string|null;acquisition_id:string|null;acquisition_phase?:string};
|
||||||
live_settings?: Record<string,unknown>;
|
live_settings?: Record<string,unknown>;
|
||||||
camera_status?: Record<string,unknown>;
|
camera_status?: Record<string,unknown>;
|
||||||
|
vesc_status?: Record<string,unknown>;
|
||||||
id: string; name: string; model: string; initializable?:boolean; preparation_safe?:boolean; prepared: boolean; configured?: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null;
|
id: string; name: string; model: string; initializable?:boolean; preparation_safe?:boolean; prepared: boolean; configured?: boolean; verified: boolean; online: boolean; usb: string; firmware?: string; playback_id?: string | null;
|
||||||
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
|
snapshot: {context: {session_id: string; device: {device_id: string}; execution: {node_id: string}}; acquisition: string; enrollment: string; message?: string};
|
||||||
profiles?: SensorProfile[]; defaults?: string[]; options?: SensorOption[]; layers: string[];
|
profiles?: SensorProfile[]; defaults?: string[]; options?: SensorOption[]; layers: string[];
|
||||||
@@ -33,6 +34,10 @@ export interface SensorCommand {
|
|||||||
}
|
}
|
||||||
export interface SensorOperation {state:string;error?:string;result?:unknown}
|
export interface SensorOperation {state:string;error?:string;result?:unknown}
|
||||||
export interface SensorTransport {
|
export interface SensorTransport {
|
||||||
|
configurationArchive?: {
|
||||||
|
list:(deviceId:string,before?:string)=>Promise<{items:ConfigurationVersion[];next:string|null}>;
|
||||||
|
read:(deviceId:string,versionId:string)=>Promise<Record<string,unknown>>;
|
||||||
|
};
|
||||||
localPreview?: {
|
localPreview?: {
|
||||||
open: (command:SensorCommand, signal:AbortSignal) => Promise<Response>;
|
open: (command:SensorCommand, signal:AbortSignal) => Promise<Response>;
|
||||||
read: (peer:string, after:number, signal:AbortSignal) => Promise<Response>;
|
read: (peer:string, after:number, signal:AbortSignal) => Promise<Response>;
|
||||||
@@ -43,6 +48,7 @@ export interface SensorTransport {
|
|||||||
submit: (value:SensorCommand) => Promise<SensorOperation>;
|
submit: (value:SensorCommand) => Promise<SensorOperation>;
|
||||||
operation: (id:string) => Promise<SensorOperation>;
|
operation: (id:string) => Promise<SensorOperation>;
|
||||||
}
|
}
|
||||||
|
export interface ConfigurationVersion {id:string;sequence:number;device_id:string;observed_at:string;firmware:string;configs:Record<string,{sha256:string;bytes:number}>}
|
||||||
export function command(device:Sensor,action:string,parameters:Record<string,unknown>={},timeoutMs=60000):SensorCommand {
|
export function command(device:Sensor,action:string,parameters:Record<string,unknown>={},timeoutMs=60000):SensorCommand {
|
||||||
const id='op_'+crypto.randomUUID().replaceAll('-','');const now=Date.now();
|
const id='op_'+crypto.randomUUID().replaceAll('-','');const now=Date.now();
|
||||||
return {api_version:'missioncore.nodedc/plugin-sdk/v0alpha2',kind:'OperationRequest',operation_id:id,idempotency_key:id,
|
return {api_version:'missioncore.nodedc/plugin-sdk/v0alpha2',kind:'OperationRequest',operation_id:id,idempotency_key:id,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type {ComponentType,ReactNode} from 'react';
|
import type {ComponentType,ReactNode} from 'react';
|
||||||
import type {IconName} from '@nodedc/ui-react';
|
import type {IconName} from '@nodedc/ui-react';
|
||||||
import type {Sensor, SensorTransport} from './contracts';
|
import type {Sensor, SensorInventory, SensorTransport} from './contracts';
|
||||||
import type {EnrollmentTransport} from './enrollment';
|
import type {EnrollmentTransport} from './enrollment';
|
||||||
import type {RerunHostFactory} from './rerunHost';
|
import type {RerunHostFactory} from './rerunHost';
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ export interface SensorEnrollmentProps {
|
|||||||
renderWindow:(view:{content:ReactNode;actions?:ReactNode;busy?:boolean})=>ReactNode;
|
renderWindow:(view:{content:ReactNode;actions?:ReactNode;busy?:boolean})=>ReactNode;
|
||||||
}
|
}
|
||||||
export interface SensorUiContribution {
|
export interface SensorUiContribution {
|
||||||
|
BoardSettings?:ComponentType<SensorBoardSettingsProps>;
|
||||||
observation?:import('./observation').SensorObservationContribution;
|
observation?:import('./observation').SensorObservationContribution;
|
||||||
kind:string;
|
kind:string;
|
||||||
Detail:ComponentType<SensorDetailProps>;
|
Detail:ComponentType<SensorDetailProps>;
|
||||||
@@ -22,11 +23,17 @@ export interface SensorUiContribution {
|
|||||||
retainOffline:boolean;
|
retainOffline:boolean;
|
||||||
supportsPreparation:boolean;
|
supportsPreparation:boolean;
|
||||||
supportsRenaming?:boolean;
|
supportsRenaming?:boolean;
|
||||||
|
detailLabel?:string;
|
||||||
rowActions?:(device:Sensor)=>readonly SensorRowAction[];
|
rowActions?:(device:Sensor)=>readonly SensorRowAction[];
|
||||||
status?:(device:Sensor,fresh:boolean)=>{label:string;tone:'neutral'|'success'|'warning'|'danger'};
|
status?:(device:Sensor,fresh:boolean)=>{label:string;tone:'neutral'|'success'|'warning'|'danger'};
|
||||||
wirelessEnrollment?:{label:string;View:ComponentType<SensorEnrollmentProps>};
|
wirelessEnrollment?:{label:string;View:ComponentType<SensorEnrollmentProps>};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SensorBoardSettingsProps {
|
||||||
|
inventory:SensorInventory|null;transport:SensorTransport;enabled:boolean;
|
||||||
|
refresh:()=>Promise<void>;failure:(error:unknown)=>void;openDevice:(id:string)=>void;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SensorRowAction {
|
export interface SensorRowAction {
|
||||||
actionId:string; label:string; description?:string; disabled?:boolean;
|
actionId:string; label:string; description?:string; disabled?:boolean;
|
||||||
parameters?:Record<string,unknown>;
|
parameters?:Record<string,unknown>;
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
# VESC onboard plugin — profile 0.4.0
|
||||||
|
|
||||||
|
**Native release in preparation:** 0.4.0 / Node 0.8.30-1 uses the actual VESC Tool 7.00 C++ engine for USB ownership, commands, configuration codecs and Hall measurement. Installation and physical qualification are still pending.
|
||||||
|
|
||||||
|
**Previous release status, 2026-09-23:** 0.3.0 / Node 0.8.29-1 is an uninstalled,
|
||||||
|
withheld experiment. The owner requires the actual upstream VESC Tool as the
|
||||||
|
onboard engine behind Mission Core controls. Do not deploy the separate Python
|
||||||
|
Hall workflow below as the intended calibration backend. Hardware currently
|
||||||
|
runs 0.2.5 / Node 0.8.28-1. The verified native adapter and canonical procedure
|
||||||
|
are documented in [the native engine plan](../../docs/node/18_VESC_TOOL_NATIVE_BACKEND.md).
|
||||||
|
|
||||||
|
This increment discovers USB candidates, reads protocol identity and telemetry,
|
||||||
|
saves opaque motor/application configuration backups and provides bounded
|
||||||
|
identification pulses and user-assigned drive positions. It is not complete
|
||||||
|
VESC Tool functional parity and does not implement continuous driving,
|
||||||
|
configuration writes, calibration, CAN forwarding, custom configuration,
|
||||||
|
firmware update or script execution.
|
||||||
|
|
||||||
|
## Placement and ownership
|
||||||
|
|
||||||
|
One `mission-core-vesc` service owns the serial descriptors. The Node model
|
||||||
|
registry admits only its Unix socket and explicit domain operations. Local Node UI
|
||||||
|
and paired Core use the same `SensorUiContribution` and operation journal.
|
||||||
|
`VESC Tool` is a text action in the existing device row, opening the existing
|
||||||
|
Detail slot. No workspace, root, visual control or motor icon was added.
|
||||||
|
|
||||||
|
Candidates must match `0483:5740` and `ChibiOS/RT Virtual COM Port`. USB serial
|
||||||
|
is never their durable identity: every candidate starts with a transport-local
|
||||||
|
attachment including USB devnum. Only a valid firmware reply with a nonzero
|
||||||
|
12-byte UUID establishes a stable ID. Duplicate protocol UUIDs remain separate
|
||||||
|
provisional rows and cannot receive read operations. Replug renews the session.
|
||||||
|
Names and physical Left/Right roles must not be inferred from tty numbering.
|
||||||
|
|
||||||
|
The read-only serial path admits command bytes 0, 4, 14, 17, 31 and 62, with no
|
||||||
|
arguments: identity, values, motor config, application config, decoded PPM and
|
||||||
|
CAN discovery. The separate fixed test commands are described below. No
|
||||||
|
keepalive, detect, config write, terminal or arbitrary packet API exists. Firmware identity is checked again before each operation;
|
||||||
|
backup verifies it at the end as well. CRC/framing/length and response limits
|
||||||
|
are enforced. Every port is held with flock/TIOCEXCL; an already running external
|
||||||
|
tool must release it before this service is prepared.
|
||||||
|
|
||||||
|
## Compatibility and backup
|
||||||
|
|
||||||
|
Wire reference is the official [VESC Tool source](https://github.com/vedderb/vesc_tool/tree/dc53c658cbb89a947246034f7a00149cf79abdfc),
|
||||||
|
specifically packet.cpp, commands.cpp and datatypes.h. The reference identifies
|
||||||
|
itself as test version 7.01; it is not installed by this profile. The first
|
||||||
|
reader implements the common values prefix for motor firmware major 5–7 and
|
||||||
|
hardware type 0 (or an older reply without hardware type). Exact board/firmware
|
||||||
|
qualification remains a hardware acceptance result, not a claim from a version
|
||||||
|
number alone. Unknown layouts may identify themselves but are not decoded.
|
||||||
|
|
||||||
|
Config replies are preserved as received, including command byte and signature,
|
||||||
|
with SHA-256 and identity/version metadata. Their fields are not decoded without
|
||||||
|
the exact firmware schema. The backups are not XML files importable into Tool,
|
||||||
|
and this profile provides no restore action. Motor and application reads are
|
||||||
|
sequential, not an atomic snapshot against other controller interfaces.
|
||||||
|
|
||||||
|
Read receipts and backups live privately in `/var/lib/mission-core-vesc`.
|
||||||
|
Operation IDs bind the exact request; retries retrieve receipts. An interrupted
|
||||||
|
request remains unknown rather than being silently replayed. This service's
|
||||||
|
host storage is bounded and does not delete backups to make space. GUI telemetry
|
||||||
|
is an explicitly requested timestamped snapshot, not a control loop or a
|
||||||
|
waveform recorder. ERPM is not mechanical shaft RPM.
|
||||||
|
|
||||||
|
## Packaging and first preparation
|
||||||
|
|
||||||
|
Node 0.8.22-1 carries this plugin from `packaging/payload.py`; no ad-hoc files,
|
||||||
|
global Python packages or separate Qt installation are required. The source
|
||||||
|
build pin is Design Guideline `8dd9190573d6616024ef01b9b34bf90b72960f44`.
|
||||||
|
The existing versioned owner installer installs Node. Its local Ubuntu sudo
|
||||||
|
prompt belongs to the owner; no password is handled by Core or an agent.
|
||||||
|
|
||||||
|
The device's Prepare action starts only
|
||||||
|
`mission-core-node-vesc-prepare.service`, via the existing Node polkit rule.
|
||||||
|
The shipped profile creates the dedicated account, installs the exact-candidate
|
||||||
|
udev rule, applies it to matching attached ttys and starts the read service.
|
||||||
|
It does not add the operator or Node to dialout or disable ModemManager globally.
|
||||||
|
Node retains PrivateDevices. The adapter has no capabilities, private network,
|
||||||
|
read-only system files, bounded memory/tasks and a cdc_acm device cgroup rule.
|
||||||
|
`modprobe@cdc_acm` ensures the named ttyACM group exists before cgroup resolution
|
||||||
|
on cold boot; [systemd DeviceAllow](https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html#DeviceAllow=)
|
||||||
|
uses group names from `/proc/devices`, not `char-<major-number>`.
|
||||||
|
|
||||||
|
Before package replacement, the installer refuses an active preparation and
|
||||||
|
stops the reader. A prepared profile restarts through its shipped job afterward.
|
||||||
|
Removal stops/disables the service and removes only a byte-matching owned udev
|
||||||
|
rule. Private backups and account identity are retained. Rolling Node back to
|
||||||
|
0.8.21-3 disables VESC support; do not claim an older package can restore the new
|
||||||
|
UI or firmware configuration. No VESC firmware was changed by this profile.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
Synthetic tests cover every denied transmit byte, fragmented/coalesced/corrupt
|
||||||
|
frames, missing identities, scales, duplicate IDs, two independent attachments,
|
||||||
|
replug sessions, receipts, config hashes and denied write actions. Node tests
|
||||||
|
cover UUID promotion and preservation of camera guards. Full Node and Core
|
||||||
|
checks are required alongside Linux package qualification and actual hardware
|
||||||
|
reads. Test success is not clean-Ubuntu or motor calibration acceptance.
|
||||||
|
|
||||||
|
On the Mini, `qmake`, `qmake6` and `cmake` were absent in the read-only build
|
||||||
|
inventory. This first increment therefore uses the bounded reader fallback from
|
||||||
|
the implementation plan. Headless extraction of the full upstream engine has
|
||||||
|
not been demonstrated; it remains a separate build/compatibility task for the
|
||||||
|
remaining Tool feature matrix.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.2.1 — per-controller identification pulse and immutable archive
|
||||||
|
|
||||||
|
Node 0.8.24-1 adds a fixed 2 A pulse on one selected VESC, with a duration
|
||||||
|
chosen from 1.5, 5 or 10 seconds. Discovery and session validation accept up
|
||||||
|
to 128 directly attached controllers per board; there is no two-motor role enum.
|
||||||
|
Names are the existing UUID-bound device names, scoped to their board.
|
||||||
|
Synthetic 1/6/10-controller tests do not establish physical USB capacity.
|
||||||
|
This is not a vehicle drive controller, completed RC arbiter, or calibration.
|
||||||
|
All attached UUID sessions, firmware 5.02 / 75_300_R2, FOC, PPM Duty Cycle,
|
||||||
|
neutral input for one second and a zero-current failsafe are mandatory.
|
||||||
|
Official 5.02 schemas are included unchanged with their upstream license.
|
||||||
|
Before any torque, motor and application configurations of every attached
|
||||||
|
controller are archived durably, and each CAN segment is checked for unmanaged
|
||||||
|
peers. Attachments are rechecked throughout the pulse. Each
|
||||||
|
controller receives its own 250 ms volatile app-output lease (CAN-forward
|
||||||
|
flag false). Other controllers receive zero current; the target receives only 2 A.
|
||||||
|
A receiver command, serial fault, telemetry limit or local Stop ends the test.
|
||||||
|
No config/firmware write, arbitrary current, arbitrary packet or CAN broadcast
|
||||||
|
is exposed. Replaying an operation ID never repeats physical work.
|
||||||
|
|
||||||
|
Firmware PPM pulses reset the global timeout even during app-output pause;
|
||||||
|
therefore the design relies on the expiring local app-output lease returning
|
||||||
|
to the existing PPM neutral/missing-pulse behavior, not solely on USB timeout.
|
||||||
|
RC activity latches further test requests until an explicit neutral release.
|
||||||
|
This is a test-session guard, not continuous production RC takeover monitoring.
|
||||||
|
Neutral PPM alone cannot prove transmitter/link availability. Host-independent
|
||||||
|
lease behavior follows the pinned firmware source; real stop/failsafe
|
||||||
|
qualification is still required and must not be claimed from synthetic tests.
|
||||||
|
|
||||||
|
Versions live in a private SQLite archive, are replicated via existing pairing
|
||||||
|
with ACK after durable Core storage, and remain downloadable when a controller
|
||||||
|
is offline. The Node and Core use the same detail component. The native VESC
|
||||||
|
Tool 7.00 engineering build is separate: it has not acquired serial ownership
|
||||||
|
or been integrated for calibration.
|
||||||
|
|
||||||
|
Package upgrades remove only generated bytecode below the installed VESC
|
||||||
|
payload before preparation starts. Deterministic source mtimes can otherwise
|
||||||
|
validate stale same-size `.pyc` files even with `python -B`; a regression test
|
||||||
|
reproduces the failed 0.8.23-1 upgrade and verifies this installer-owned fix.
|
||||||
|
|
||||||
|
## 0.2.2 — entered test values and drive positions
|
||||||
|
|
||||||
|
Node 0.8.25-1 carries numeric current and duration fields. The board advertises
|
||||||
|
and independently enforces 0.5–5 A and 0.5–10 seconds for this identification
|
||||||
|
mode. These are software bounds, not controller or motor nameplate
|
||||||
|
ratings. Existing 60 A motor / 55 A battery configuration is not evidence that
|
||||||
|
the rig can safely sustain those currents. Above 2 A the test coasts at
|
||||||
|
400 electrical RPM and resumes current below 250, with a separate 800 ERPM
|
||||||
|
abort threshold. Requested current is also bounded by the read configuration.
|
||||||
|
Receipts distinguish successfully sent current commands from sampled cycles
|
||||||
|
that ended before transmission. These bounds do not admit maximum-power tests.
|
||||||
|
|
||||||
|
The existing VESC detail offers one board-wide drive profile: 1×1 means left
|
||||||
|
and right (two motors); 2×2 means left front, left rear, right front and right
|
||||||
|
rear (four motors). Directions are relative to forward vehicle motion. Position
|
||||||
|
is manually assigned after physical identification and persists by controller
|
||||||
|
UUID, not USB address. The same component is used on Node and paired Core.
|
||||||
|
Revision checks prevent stale updates; an occupied position cannot be stolen,
|
||||||
|
and rear assignments must be explicitly removed before shrinking to 1×1.
|
||||||
|
Changing profile/position is local metadata and sends no VESC command.
|
||||||
|
Discovery itself remains independent of these two admitted layout presets.
|
||||||
|
|
||||||
|
## 0.2.3 — explain blocked tests and retain stop evidence
|
||||||
|
|
||||||
|
Numeric fields show validation errors beside invalid values. The action area
|
||||||
|
explains why Start is unavailable, including confirmation reset after a test.
|
||||||
|
A telemetry-bound stop retains the triggering sample and its field, measured
|
||||||
|
value and unchanged bounds. A completed current pulse never proves physical
|
||||||
|
rotation; the owner must observe the motor before assigning its position.
|
||||||
|
|
||||||
|
## 0.2.4 — gradual current in identification mode
|
||||||
|
|
||||||
|
The confirmed right motor crossed800ERPM within0.245s of a5A step. The next
|
||||||
|
profile starts and resumes at0.5A, increasing by approximately1A/s toward the
|
||||||
|
entered ceiling. Soft coasting triggers at200ERPM or4%PWM and only resumes
|
||||||
|
below100ERPM and2%PWM. It applies to every allowed current, while the hard
|
||||||
|
abort thresholds remain unchanged. This is an identification pulse governor,
|
||||||
|
not a vehicle speed controller. Command receipts record actual requested
|
||||||
|
current per sample, including ramp/coasting;5A input is a ceiling.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.2.5 — entered current up to 30 A and time up to 30 seconds
|
||||||
|
|
||||||
|
At the owner's request the raised-rig test accepts 0.5–30 A and 0.5–30 s.
|
||||||
|
These are software admission bounds, not motor/controller nameplate ratings.
|
||||||
|
The selected controller's configured motor and input current limits still bind.
|
||||||
|
Positive current starts at0.5A and ramps by2A/s; the entered value is a current
|
||||||
|
ceiling, not a speed request. No configuration or firmware write is performed.
|
||||||
|
|
||||||
|
The former200ERPM/4%PWM coast/restart loop was causing the observed right-motor
|
||||||
|
steps and pauses. This increment removes that automatic cycling. Current is
|
||||||
|
maintained until time expires, Stop/RC/link interruption, or a telemetry limit.
|
||||||
|
The test ends at6000ERPM or25%PWM (or a lower configured speed/duty limit),
|
||||||
|
without automatic re-acceleration. A no-load motor can reach a speed limit
|
||||||
|
before the entered time:30seconds is the maximum duration, not a promise of
|
||||||
|
constant-speed rotation. Current feedback has bounded overshoot tolerance,
|
||||||
|
capped by the configured motor current limit. Fault, voltage, temperature,
|
||||||
|
identity/topology, neutral RC, expiring per-device leases and release checks
|
||||||
|
remain active.
|
||||||
|
|
||||||
|
Above5A, a2-second interval without at least three net electrical tachometer
|
||||||
|
steps at60ERPM ends the test. This uses the existing Hall/FOC estimate; it is
|
||||||
|
not independent mechanical feedback or certified thermal protection, especially
|
||||||
|
with a damaged sensor connection. It prevents continuing to raise commanded
|
||||||
|
current while the reported rotor remains stationary. Sustained vehicle control,
|
||||||
|
calibration and native VESC Tool parity remain separate unfinished work.
|
||||||
|
|
||||||
|
The owner clarified the diagnostic roles: LEFT is the problematic motor;
|
||||||
|
RIGHT works normally from RC and is physically assigned right.1. Short steps
|
||||||
|
in the earlier Core test must not be recorded as a right-motor defect. Every
|
||||||
|
powered engineering experiment is coordinated with the owner at launch time.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.3.0 — measured speed hold and native Hall measurement
|
||||||
|
|
||||||
|
`vesc.motor.run` calls firmware speed PID (`COMM_SET_RPM`), ramps the setpoint
|
||||||
|
at 600 ERPM/s and counts time only after one second within 15% of the requested
|
||||||
|
speed with a changing VESC tachometer. The UI exposes speed 300–3000 ERPM,
|
||||||
|
motor-current ceiling 0.5–30 A and **rotation time** 0.5–30 s. Startup is bounded
|
||||||
|
by 15 s; losing speed for 2 s ends the run. No automatic restart. FOC telemetry
|
||||||
|
is not an independent physical encoder; the operator compares visible motion.
|
||||||
|
The old `vesc.motor.pulse` action remains compatible but is not used by this UI.
|
||||||
|
|
||||||
|
Before a speed command, `COMM_SET_MCCONF_TEMP` applies current scaling to both
|
||||||
|
positive and braking motor current. Store/CAN/divide flags are false. ACK and
|
||||||
|
full configuration readback precede torque. A durable UUID-bound journal exists
|
||||||
|
before the first write. Cleanup restores the exact original configuration,
|
||||||
|
verified byte-for-byte. A lost ACK, disconnection or process interruption leaves
|
||||||
|
the journal pending; discovery retries restoration only with a matching identity,
|
||||||
|
zero current, neutral receiver and unchanged unrelated configuration. An external
|
||||||
|
configuration change is never overwritten. No flash or application write is used.
|
||||||
|
The operation transport permits 90 s, including preflight and acceleration.
|
||||||
|
|
||||||
|
`vesc.hall.measure` is the native FW 5.02 `COMM_DETECT_HALL_FOC` procedure also
|
||||||
|
used by VESC Tool. It uses fixed 5 A, sweeps three electrical turns forward and
|
||||||
|
three backwards, returns the observed table and restores its prior configuration.
|
||||||
|
The table is **not automatically applied**. The firmware locks `mc_interface`
|
||||||
|
during this approximately 12 s cycle; USB current-zero and receiver input cannot
|
||||||
|
interrupt it. The UI requires a separate observed-rig/physical-power-cut
|
||||||
|
acknowledgement and explicitly describes this limitation. Unknown completion
|
||||||
|
latches authority and blocks another powered operation. Measurement, samples and
|
||||||
|
backup references remain in the receipt.
|
||||||
|
|
||||||
|
These operations do not constitute the full VESC Tool desktop UI. The product
|
||||||
|
entry is labelled “Настройка VESC” until complete native application session
|
||||||
|
integration is shipped. Full R/L/flux calibration, table application and
|
||||||
|
configuration restore remain separate unimplemented work.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {useEffect,useState} from 'react';
|
||||||
|
import {Button,LoadingRegion,ResourceList,ResourceRow,SettingsCard} from '@nodedc/ui-react';
|
||||||
|
import type {ConfigurationVersion,SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||||
|
|
||||||
|
export function VescBackups({deviceId,archive,revision}: {deviceId:string;archive:SensorTransport['configurationArchive'];revision:string|null}) {
|
||||||
|
const [reload,setReload]=useState(0);
|
||||||
|
const [items,setItems]=useState<ConfigurationVersion[]>([]);
|
||||||
|
const [next,setNext]=useState<string|null>(null);
|
||||||
|
const [loading,setLoading]=useState(false);
|
||||||
|
const [error,setError]=useState<string|null>(null);
|
||||||
|
const [download,setDownload]=useState<string|null>(null);
|
||||||
|
useEffect(()=>{
|
||||||
|
let active=true;
|
||||||
|
setItems([]);setNext(null);setError(null);
|
||||||
|
if(!archive)return;
|
||||||
|
setLoading(true);
|
||||||
|
archive.list(deviceId).then(result=>{if(active){setItems(result.items);setNext(result.next);}})
|
||||||
|
.catch(()=>{if(active)setError('Не удалось загрузить историю конфигураций.');})
|
||||||
|
.finally(()=>{if(active)setLoading(false);});
|
||||||
|
return()=>{active=false;};
|
||||||
|
},[deviceId,archive,revision,reload]);
|
||||||
|
async function more(){
|
||||||
|
if(!archive||!next||loading)return;
|
||||||
|
setLoading(true);setError(null);
|
||||||
|
try{const result=await archive.list(deviceId,next);setItems(current=>[...current,...result.items.filter(item=>!current.some(old=>old.id===item.id))]);setNext(result.next);}
|
||||||
|
catch{setError('Не удалось загрузить следующие версии.');}
|
||||||
|
finally{setLoading(false);}
|
||||||
|
}
|
||||||
|
async function save(id:string){
|
||||||
|
if(!archive||download)return;
|
||||||
|
setDownload(id);setError(null);
|
||||||
|
try{
|
||||||
|
const value=await archive.read(deviceId,id);
|
||||||
|
const url=URL.createObjectURL(new Blob([JSON.stringify(value,null,2)+'\n'],{type:'application/json'}));
|
||||||
|
const link=document.createElement('a');link.href=url;link.download=`${deviceId}-${id}.json`;link.click();
|
||||||
|
setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||||
|
}catch{setError('Не удалось скачать выбранную версию.');}
|
||||||
|
finally{setDownload(null);}
|
||||||
|
}
|
||||||
|
return <SettingsCard title="История конфигураций" description="Сохранённые версии остаются на борту и передаются в Core при подключении." actions={<Button disabled={loading||!archive} onClick={()=>setReload(value=>value+1)}>Обновить историю</Button>}>
|
||||||
|
<LoadingRegion loading={loading&&!items.length} label="Загрузка версий конфигурации">
|
||||||
|
{error&&<p role="alert">{error}</p>}
|
||||||
|
{!loading&&!items.length&&!error&&<p>Сохранённых версий пока нет.</p>}
|
||||||
|
<ResourceList aria-label="Версии конфигурации VESC">{items.map(item=><li key={item.id}>
|
||||||
|
<ResourceRow title={new Date(item.observed_at).toLocaleString()} description={`Прошивка ${item.firmware} · мотор и входы`}
|
||||||
|
actions={<Button disabled={download!==null} loading={download===item.id} onClick={()=>void save(item.id)}>Скачать</Button>}/>
|
||||||
|
</li>)}</ResourceList>
|
||||||
|
</LoadingRegion>
|
||||||
|
{next&&<Button loading={loading} disabled={loading} onClick={()=>void more()}>Ещё версии</Button>}
|
||||||
|
</SettingsCard>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {useRef,useState} from 'react';
|
||||||
|
import {Button,InspectorSelectField,ResourceList,ResourceRow,SettingsCard} from '@nodedc/ui-react';
|
||||||
|
import type {SensorBoardSettingsProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||||
|
import {perform,type Sensor} from '../../../../packages/sensor-ui/src/contracts';
|
||||||
|
import {drivePositions,vescStatus,type DriveProfile} from './model';
|
||||||
|
import {VescLimits} from './VescLimits';
|
||||||
|
|
||||||
|
export function VescBoardSettings({inventory,transport,enabled,refresh,failure,openDevice}:SensorBoardSettingsProps){
|
||||||
|
const controllers=inventory?.items.filter(device=>device.kind==='vesc.controller')??[];
|
||||||
|
const profile=controllers.map(device=>vescStatus(device).drive_profile).filter((value):value is DriveProfile=>!!value).sort((a,b)=>b.revision-a.revision)[0];
|
||||||
|
const anchor=controllers.find(device=>device.online&&device.verified&&vescStatus(device).board_settings_supported);
|
||||||
|
const [busy,setBusy]=useState(false);const running=useRef(false);
|
||||||
|
const active=inventory?.operations?.some(value=>['queued','running'].includes(value.state)&&controllers.some(device=>device.id===value.device_id));
|
||||||
|
const blocked=!enabled||!anchor||busy||active||!profile;
|
||||||
|
const positions=drivePositions(profile?.layout??null);
|
||||||
|
async function change(device:Sensor,action:string,parameters:Record<string,unknown>){
|
||||||
|
if(blocked||running.current||!profile)return;
|
||||||
|
running.current=true;setBusy(true);failure(null);
|
||||||
|
try{await perform(transport,device,action,{revision:profile.revision,...parameters});await refresh();}
|
||||||
|
catch(error){failure(error);await refresh();}finally{running.current=false;setBusy(false);}
|
||||||
|
}
|
||||||
|
return <div className="sensor-content">
|
||||||
|
<SettingsCard title="Привод" description="Профиль аппарата и расположение его моторов.">
|
||||||
|
<InspectorSelectField label="Профиль привода" value={profile?.layout??''} disabled={blocked} options={[
|
||||||
|
{value:'',label:'Выберите профиль',disabled:true},
|
||||||
|
{value:'1x1',label:'1×1 · 2 мотора',disabled:!!profile?.bindings['left.2']||!!profile?.bindings['right.2']},
|
||||||
|
{value:'2x2',label:'2×2 · 4 мотора'},
|
||||||
|
]} onChange={layout=>{if(anchor)void change(anchor,'vesc.drive.layout',{layout});}}/>
|
||||||
|
{!profile&&<p>{inventory?'Подключите VESC, чтобы получить профиль привода с борта.':'Получение профиля привода…'}</p>}
|
||||||
|
{profile&&!anchor&&<p>Профиль показан по последним сведениям с борта. Для изменения нужна связь с VESC и актуальное бортовое приложение.</p>}
|
||||||
|
{profile?.layout&&<>
|
||||||
|
<p>Стороны — по направлению движения вперёд. Назначения сохраняются автоматически и остаются с контроллером при смене USB-порта.</p>
|
||||||
|
{Object.entries(positions).map(([slot,label])=>{
|
||||||
|
const bound=profile.bindings[slot];
|
||||||
|
const missing=bound&&!controllers.some(device=>device.id===bound.device_id);
|
||||||
|
return <InspectorSelectField key={slot} label={label} value={bound?.device_id??''} disabled={blocked} options={[
|
||||||
|
{value:'',label:'Не назначен'},
|
||||||
|
...controllers.map(device=>({value:device.id,label:device.name+(!device.online?' · нет связи':''),disabled:!device.online||!device.verified||Object.entries(profile.bindings).some(([other,binding])=>other!==slot&&binding.device_id===device.id)})),
|
||||||
|
...(missing?[{value:bound.device_id,label:`VESC ${bound.uuid.slice(0,6).toUpperCase()} · нет связи`,disabled:true}]:[]),
|
||||||
|
]} onChange={id=>{
|
||||||
|
const target=id?controllers.find(device=>device.id===id):anchor;
|
||||||
|
if(!target)return;
|
||||||
|
void change(target,id?'vesc.drive.assign':'vesc.drive.unassign',id?{layout:profile.layout,slot}:{slot});
|
||||||
|
}}/>;
|
||||||
|
})}
|
||||||
|
<ResourceList aria-label="Настройка назначенных моторов">{Object.entries(profile.bindings).map(([slot,binding])=>{
|
||||||
|
const target=controllers.find(device=>device.id===binding.device_id);
|
||||||
|
return <li key={slot}><ResourceRow title={positions[slot]??slot} description={target?.name??`VESC ${binding.uuid.slice(0,6).toUpperCase()}`} actions={<Button disabled={!target?.prepared||busy||active} onClick={()=>openDevice(binding.device_id)}>Настройка мотора</Button>}/></li>;
|
||||||
|
})}</ResourceList>
|
||||||
|
</>}
|
||||||
|
</SettingsCard>
|
||||||
|
<VescLimits controllers={controllers} transport={transport} enabled={enabled&&!busy&&!active} failure={failure}/>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {useRef,useState} from 'react';
|
||||||
|
import {Button,Checker,ResourceList,ResourceRow,SettingsCard,TextField} from '@nodedc/ui-react';
|
||||||
|
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||||
|
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||||
|
import {vescStatus} from './model';
|
||||||
|
|
||||||
|
interface CalibrationResult {
|
||||||
|
completed:boolean;success:boolean;configuration_verified:boolean;release_confirmed:boolean;
|
||||||
|
native:{success?:boolean;code?:number;sensor_mode?:number;parameters?:Record<string,number>};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VescCalibration({device,transport,enabled,refresh,failure,blocked,onBusyChange}:SensorDetailProps&{blocked:boolean;onBusyChange:(busy:boolean)=>void}){
|
||||||
|
const [loss,setLoss]=useState('50');
|
||||||
|
const [confirmed,setConfirmed]=useState(false);
|
||||||
|
const [busy,setBusy]=useState(false);
|
||||||
|
const [result,setResult]=useState<CalibrationResult|null>(null);
|
||||||
|
const running=useRef(false);
|
||||||
|
const limits=vescStatus(device).foc_calibration;
|
||||||
|
const power=Number(loss);
|
||||||
|
const valid=!!limits&&loss.trim()!==''&&Number.isFinite(power)&&power>=limits.min_power_loss_w&&power<=limits.max_power_loss_w;
|
||||||
|
const available=enabled&&device.online&&device.verified&&device.vesc_status?.test_supported===true&&!!limits;
|
||||||
|
async function calibrate(){
|
||||||
|
if(running.current||blocked||!available||!confirmed||!valid)return;
|
||||||
|
running.current=true;setBusy(true);onBusyChange(true);setResult(null);failure(null);
|
||||||
|
try{
|
||||||
|
const inventory=await transport.inventory();
|
||||||
|
const controllers=inventory.items.filter(item=>item.kind==='vesc.controller'&&item.online);
|
||||||
|
const target=controllers.find(item=>item.id===device.id);
|
||||||
|
if(inventory.fresh===false||!target||controllers.some(item=>!item.verified))throw new Error('Нужна свежая связь со всеми VESC борта.');
|
||||||
|
setResult(await perform<CalibrationResult>(transport,target,'vesc.foc.calibrate',{
|
||||||
|
rig_clear:true,native_cycle_confirmed:true,max_power_loss_w:power,
|
||||||
|
sessions:Object.fromEntries(controllers.map(item=>[item.id,item.snapshot.context.session_id])),
|
||||||
|
},300000));
|
||||||
|
}catch(error){failure(error);}
|
||||||
|
finally{running.current=false;setBusy(false);onBusyChange(false);setConfirmed(false);await refresh();}
|
||||||
|
}
|
||||||
|
const parameters=result?.native.parameters;
|
||||||
|
const calibrated=result?.native.success===true&&result.configuration_verified;
|
||||||
|
return <SettingsCard title="Калибровка мотора" description={`${device.name} · штатное автоопределение FOC в VESC Tool.`}>
|
||||||
|
<p>Мастер измеряет сопротивление, индуктивность и магнитный поток, определяет датчики и записывает параметры выбранного мотора. Версии до и после сохраняются в истории; настройки аккумулятора и пульта сохраняются.</p>
|
||||||
|
<TextField type="number" label="Допустимые потери в моторе, Вт" value={loss} onChange={event=>setLoss(event.target.value)} disabled={busy||blocked||!limits} min={limits?.min_power_loss_w} max={limits?.max_power_loss_w} step="5" aria-invalid={!valid} description="Параметр нагрева для мастера VESC Tool, не номинальная мощность мотора. По нему мастер выбирает токи измерения; предел тока проверки вращения здесь не применяется."/>
|
||||||
|
<p>Мотор будет двигаться и разгоняться. На прошивке 5.02 процедуру нельзя прервать кнопкой или пультом — только отключением силового питания. Оставьте пульт выключенным и приводы вывешенными до завершения; цикл может занять до трёх минут.</p>
|
||||||
|
<Checker checked={confirmed} onChange={setConfirmed} disabled={busy||blocked} label="Наблюдаю мотор, питание могу отключить"/>
|
||||||
|
<Button disabled={!available||blocked||!confirmed||!valid||busy} loading={busy} onClick={()=>void calibrate()}>Откалибровать мотор</Button>
|
||||||
|
{busy&&<p role="status">Подготовка и калибровка VESC Tool. Дождитесь результата и снятия тока.</p>}
|
||||||
|
{result&&<>
|
||||||
|
<p role="status">{calibrated?'Параметры мотора измерены, записаны и проверены.':result.completed?`Калибровка не принята. Код VESC: ${result.native.code??'не получен'}.`:'Завершение калибровки не подтверждено. Проверьте состояние мотора и питание.'} {result.release_confirmed?'Снятие тока подтверждено.':'Снятие тока пока не подтверждено. Перед следующей проверкой верните управление после нейтрали.'} {!result.configuration_verified&&' Проверка конфигурации не завершена; новое движение заблокировано.'}</p>
|
||||||
|
{calibrated&&<>
|
||||||
|
<p>{result.native.sensor_mode===0?'Выбран режим без датчиков. Холлы не определились; качество запуска нужно проверить вращением.':result.native.sensor_mode===2?'Определены датчики Холла.':'Определён энкодер.'}</p>
|
||||||
|
{parameters&&<ResourceList aria-label="Результат калибровки">
|
||||||
|
{([['foc_motor_r','Сопротивление',1000,'мОм'],['foc_motor_l','Индуктивность',1e6,'мкГн'],['foc_motor_flux_linkage','Магнитный поток',1000,'мВб'],['l_current_max','Предел тока мотора',1,'А']] as const).map(([key,title,scale,unit])=><li key={key}><ResourceRow title={title} description={`${(parameters[key]*scale).toLocaleString('ru-RU',{maximumFractionDigits:3})} ${unit}`}/></li>)}
|
||||||
|
</ResourceList>}
|
||||||
|
</>}
|
||||||
|
</>}
|
||||||
|
</SettingsCard>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import {useEffect,useRef,useState} from 'react';
|
||||||
|
import {Button,LoadingRegion,ResourceList,ResourceRow,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||||
|
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||||
|
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||||
|
import {vescLabel,vescStatus,type VescTelemetry} from './model';
|
||||||
|
import {VescBackups} from './VescBackups';
|
||||||
|
import {VescMotor} from './VescMotor';
|
||||||
|
import {VescHall} from './VescHall';
|
||||||
|
import {VescCalibration} from './VescCalibration';
|
||||||
|
import {VescLink} from './VescLink';
|
||||||
|
|
||||||
|
const fields=[['input_voltage_v','Напряжение питания','В'],['input_current_a','Ток питания','А'],
|
||||||
|
['motor_current_a','Ток мотора','А'],['erpm','Электрические обороты','ERPM'],['duty','Заполнение PWM',''],
|
||||||
|
['mos_temperature_c','Температура контроллера','°C'],['motor_temperature_c','Температура мотора','°C'],
|
||||||
|
['fault_code','Код ошибки',''],['can_id','CAN ID',''],['timeout','Тайм-аут управления',''],
|
||||||
|
['kill_switch','Вход аварийного останова','']] as const;
|
||||||
|
|
||||||
|
export function VescDetail(props:SensorDetailProps){
|
||||||
|
const {device,transport,enabled,back,refresh,failure}=props;
|
||||||
|
const status=vescStatus(device);const label=vescLabel(device,enabled);
|
||||||
|
const [telemetry,setTelemetry]=useState<VescTelemetry|null>(status.telemetry);
|
||||||
|
const [pending,setPending]=useState<string|null>(null);
|
||||||
|
const [saved,setSaved]=useState<string|null>(null);
|
||||||
|
const [motorBusy,setMotorBusy]=useState(false);
|
||||||
|
const [hallBusy,setHallBusy]=useState(false);
|
||||||
|
const [calibrationBusy,setCalibrationBusy]=useState(false);
|
||||||
|
const [linkBusy,setLinkBusy]=useState(false);
|
||||||
|
const poweredBusy=motorBusy||hallBusy||calibrationBusy||linkBusy;
|
||||||
|
const running=useRef(false);const mounted=useRef(true);
|
||||||
|
const available=enabled&&device.online&&device.verified&&status.readable;
|
||||||
|
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]);
|
||||||
|
async function read(action:'vesc.telemetry.read'|'vesc.config.backup'){
|
||||||
|
if(running.current||poweredBusy||!available)return;
|
||||||
|
running.current=true;setPending(action);failure(null);
|
||||||
|
try{
|
||||||
|
const result=await perform<Record<string,unknown>>(transport,device,action);
|
||||||
|
if(!mounted.current)return;
|
||||||
|
if(action==='vesc.telemetry.read')setTelemetry(result as unknown as VescTelemetry);
|
||||||
|
else {
|
||||||
|
setSaved(String(result.observed_at));
|
||||||
|
}
|
||||||
|
await refresh();
|
||||||
|
}catch(error){if(mounted.current)failure(error);}
|
||||||
|
finally{running.current=false;if(mounted.current)setPending(null);}
|
||||||
|
}
|
||||||
|
const backupAt=saved??status.backup?.observed_at;
|
||||||
|
return <div className="sensor-content">
|
||||||
|
<div><Button onClick={back}>К устройствам</Button></div>
|
||||||
|
<SettingsCard title={`${device.name} · Настройка VESC`} description={`${device.model} · ${device.connection_label??'USB'}`}
|
||||||
|
actions={<StatusBadge tone={label.tone}>{label.label}</StatusBadge>}>
|
||||||
|
{!enabled||!device.online?<p>Нет свежей связи с контроллером.</p>:status.message?<p>{status.message}</p>:null}
|
||||||
|
{status.identity?<ResourceList aria-label="Контроллер VESC">
|
||||||
|
<li><ResourceRow title="Прошивка" description={status.identity.version}/></li>
|
||||||
|
<li><ResourceRow title="Аппаратная версия" description={status.identity.hardware}/></li>
|
||||||
|
<li><ResourceRow title="UUID" description={status.identity.uuid.toUpperCase()}/></li>
|
||||||
|
{status.identity.test_firmware!==null&&status.identity.test_firmware>0&&<li><ResourceRow title="Тестовая прошивка" description={String(status.identity.test_firmware)}/></li>}
|
||||||
|
</ResourceList>:<p>Аппаратный идентификатор ещё не подтверждён.</p>}
|
||||||
|
</SettingsCard>
|
||||||
|
<SettingsCard title="Показания контроллера" description={telemetry?`Снимок: ${new Date(telemetry.observed_at).toLocaleString()}`:'Получите текущие значения и код ошибки.'}
|
||||||
|
actions={<Button disabled={!available||pending!==null||poweredBusy} loading={pending==='vesc.telemetry.read'} onClick={()=>void read('vesc.telemetry.read')}>Обновить показания</Button>}>
|
||||||
|
<LoadingRegion loading={pending==='vesc.telemetry.read'&&!telemetry} label="Чтение показаний VESC">
|
||||||
|
{telemetry?<ResourceList aria-label="Показания VESC">{fields.map(([key,title,unit])=>{
|
||||||
|
const value=telemetry.values[key];if(value===undefined)return null;
|
||||||
|
return <li key={key}><ResourceRow title={title} description={typeof value==='boolean'?(value?'Активен':'Не активен'):`${Number(value).toLocaleString(undefined,{maximumFractionDigits:3})}${unit?' '+unit:''}`}/></li>;
|
||||||
|
})}</ResourceList>:<p>Показания ещё не прочитаны.</p>}
|
||||||
|
</LoadingRegion>
|
||||||
|
<p>ERPM — электрические обороты. Обороты вала зависят от числа пар полюсов мотора.</p>
|
||||||
|
</SettingsCard>
|
||||||
|
<SettingsCard title="Резервная копия конфигурации" description="Сохраните текущие параметры мотора и входов новой версией."
|
||||||
|
actions={<Button disabled={!available||pending!==null||poweredBusy} loading={pending==='vesc.config.backup'} onClick={()=>void read('vesc.config.backup')}>Сохранить версию</Button>}>
|
||||||
|
{backupAt&&<p>Последняя копия: {new Date(backupAt).toLocaleString()}</p>}
|
||||||
|
<p>Копия привязана к UUID и прошивке. Версии до и после калибровки остаются в истории.</p>
|
||||||
|
</SettingsCard>
|
||||||
|
<VescLink {...props} blocked={motorBusy||hallBusy||calibrationBusy||pending!==null} onBusyChange={setLinkBusy}/>
|
||||||
|
<VescCalibration {...props} blocked={linkBusy||motorBusy||hallBusy||pending!==null} onBusyChange={setCalibrationBusy}/>
|
||||||
|
<VescHall {...props} blocked={linkBusy||motorBusy||calibrationBusy||pending!==null} onBusyChange={setHallBusy}/>
|
||||||
|
<VescMotor {...props} blocked={linkBusy||hallBusy||calibrationBusy||pending!==null} onBusyChange={setMotorBusy}/>
|
||||||
|
<VescBackups deviceId={device.id} archive={transport.configurationArchive} revision={backupAt??null}/>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {useRef,useState} from 'react';
|
||||||
|
import {Button,Checker,SettingsCard} from '@nodedc/ui-react';
|
||||||
|
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||||
|
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||||
|
import {vescStatus} from './model';
|
||||||
|
|
||||||
|
interface HallResult {
|
||||||
|
completed:boolean;configuration_restored:boolean;release_confirmed:boolean;
|
||||||
|
measurement:null|{valid_six_states:boolean;observed_states:number[];hall_table:number[]};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VescHall({device,transport,enabled,refresh,failure,blocked,onBusyChange}:SensorDetailProps&{blocked:boolean;onBusyChange:(busy:boolean)=>void}){
|
||||||
|
const [confirmed,setConfirmed]=useState(false);
|
||||||
|
const [busy,setBusy]=useState(false);
|
||||||
|
const [result,setResult]=useState<HallResult|null>(null);
|
||||||
|
const running=useRef(false);
|
||||||
|
const available=enabled&&device.online&&device.verified&&device.vesc_status?.test_supported===true&&!!vescStatus(device).hall_measurement;
|
||||||
|
async function measure(){
|
||||||
|
if(running.current||blocked||!available||!confirmed)return;
|
||||||
|
running.current=true;setBusy(true);onBusyChange(true);setResult(null);failure(null);
|
||||||
|
try{
|
||||||
|
const inventory=await transport.inventory();
|
||||||
|
const controllers=inventory.items.filter(item=>item.kind==='vesc.controller'&&item.online);
|
||||||
|
const target=controllers.find(item=>item.id===device.id);
|
||||||
|
if(inventory.fresh===false||!target||controllers.some(item=>!item.verified))throw new Error('Нужна свежая связь со всеми VESC борта.');
|
||||||
|
setResult(await perform<HallResult>(transport,target,'vesc.hall.measure',{
|
||||||
|
rig_clear:true,native_cycle_confirmed:true,
|
||||||
|
...(vescStatus(target).hall_measurement?.standstill_confirmation_required?{standstill_confirmed:true}:{}),
|
||||||
|
sessions:Object.fromEntries(controllers.map(item=>[item.id,item.snapshot.context.session_id])),
|
||||||
|
},60000));
|
||||||
|
}catch(error){failure(error);}
|
||||||
|
finally{running.current=false;setBusy(false);onBusyChange(false);setConfirmed(false);await refresh();}
|
||||||
|
}
|
||||||
|
return <SettingsCard title="Датчики Холла" description={`${device.name} · штатное измерение VESC Tool, 5 А.`}>
|
||||||
|
<p>Мотор медленно смещается в обе стороны около 12 секунд. Измерение проверяет состояния датчиков и получает таблицу их положения; новая таблица автоматически не записывается.</p>
|
||||||
|
<p>На прошивке 5.02 этот цикл нельзя прервать кнопкой или пультом. Для немедленной остановки нужно отключить силовое питание. Пульт должен оставаться выключенным, все приводы — вывешенными. Перед запуском убедитесь, что все моторы полностью остановились: в бессенсорном режиме показание оборотов на остановленном моторе может быть ненулевым.</p>
|
||||||
|
<Checker checked={confirmed} onChange={setConfirmed} disabled={busy||blocked} label="Все моторы остановлены, наблюдаю"/>
|
||||||
|
<Button disabled={!available||blocked||!confirmed||busy} loading={busy} onClick={()=>void measure()}>Измерить датчики Холла</Button>
|
||||||
|
{busy&&<p role="status">Подготовка и штатное измерение датчиков. Дождитесь результата.</p>}
|
||||||
|
{result&&<p role="status">{!result.completed?'Завершение измерения не подтверждено. Проверьте мотор и питание.':result.measurement?.valid_six_states?'Измерение получило шесть состояний Холла.':'Не удалось получить полную таблицу Холла. Возможны отсутствие движения или неисправность датчиков/соединения.'} {result.release_confirmed?'Снятие тока подтверждено.':'Снятие тока не подтверждено.'} {result.configuration_restored?'Исходная конфигурация сохранена.':'Возврат исходной конфигурации не подтверждён.'}</p>}
|
||||||
|
</SettingsCard>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {useRef,useState} from 'react';
|
||||||
|
import {Button,ResourceList,ResourceRow,SettingsCard} from '@nodedc/ui-react';
|
||||||
|
import {perform,type Sensor,type SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||||
|
import {vescStatus} from './model';
|
||||||
|
|
||||||
|
interface Limits {observed_at:string;identity?:{uuid:string};parameters:Record<string,number>}
|
||||||
|
const number=(value:number|undefined)=>value===undefined?'—':value.toLocaleString('ru-RU',{maximumFractionDigits:2});
|
||||||
|
export function VescLimits({controllers,transport,enabled,failure}:{controllers:Sensor[];transport:SensorTransport;enabled:boolean;failure:(error:unknown)=>void}){
|
||||||
|
const [values,setValues]=useState<Record<string,Limits>>({});
|
||||||
|
const [busy,setBusy]=useState(false);const running=useRef(false);
|
||||||
|
const readable=controllers.filter(device=>device.online&&device.verified&&vescStatus(device).readable&&vescStatus(device).board_settings_supported);
|
||||||
|
async function read(){
|
||||||
|
if(!enabled||running.current||!readable.length)return;
|
||||||
|
running.current=true;setBusy(true);failure(null);setValues({});
|
||||||
|
try{for(const device of readable){const value=await perform<Limits>(transport,device,'vesc.limits.read');setValues(current=>({...current,[device.id]:value}));}}
|
||||||
|
catch(error){failure(error);}finally{running.current=false;setBusy(false);}
|
||||||
|
}
|
||||||
|
return <SettingsCard title="Ограничения контроллеров" description="Текущие настройки VESC. Чтение не запускает моторы и не меняет конфигурацию." actions={<Button disabled={!enabled||busy||!readable.length} loading={busy} onClick={()=>void read()}>Прочитать ограничения</Button>}>
|
||||||
|
<p>Ток мотора задаёт тягу; ток батареи ограничивает потребление. Эти настройки действуют и при управлении с пульта. Паспортные пределы моторов, контроллеров и батареи проверяются отдельно.</p>
|
||||||
|
{controllers.map(device=>{
|
||||||
|
const value=values[device.id];if(!value)return null;
|
||||||
|
const p=value.parameters;
|
||||||
|
const fields=[
|
||||||
|
['Ток мотора · разгон / торможение',`${number(p.l_current_max)} / ${number(p.l_current_min)} А`],
|
||||||
|
['Масштаб тока · разгон / торможение',`${number(p.l_current_max_scale*100)} / ${number(p.l_current_min_scale*100)} %`],
|
||||||
|
['Ток батареи · потребление / рекуперация',`${number(p.l_in_current_max)} / ${number(p.l_in_current_min)} А`],
|
||||||
|
['Диапазон электрических оборотов',`${number(p.l_min_erpm)} … ${number(p.l_max_erpm)} ERPM`],
|
||||||
|
['Максимальная мощность',p.l_watt_max>=1500000?'Отдельный предел не задан':`${number(p.l_watt_max)} Вт`],
|
||||||
|
['Максимальный duty cycle',`${number(p.l_max_duty*100)} %`],
|
||||||
|
];
|
||||||
|
return <SettingsCard key={device.id} title={device.name} description={`Прочитано ${new Date(value.observed_at).toLocaleString('ru-RU')}`}><ResourceList aria-label={`Ограничения ${device.name}`}>{fields.map(([title,description])=><li key={title}><ResourceRow title={title} description={description}/></li>)}</ResourceList></SettingsCard>;
|
||||||
|
})}
|
||||||
|
{!Object.keys(values).length&&<p>Прочитайте значения для подключённых контроллеров.</p>}
|
||||||
|
</SettingsCard>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {useEffect,useRef,useState} from 'react';
|
||||||
|
import {Button,ResourceList,ResourceRow,SettingsCard} from '@nodedc/ui-react';
|
||||||
|
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||||
|
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||||
|
import {vescStatus} from './model';
|
||||||
|
|
||||||
|
interface LinkResult {
|
||||||
|
outcome:string;duration_s:number;
|
||||||
|
devices:Record<string,{name:string;summary:{replies:number;p95_ms?:number;max_ms?:number;over_60_ms?:number}}>;
|
||||||
|
}
|
||||||
|
export function VescLink({device,transport,enabled,failure,refresh,blocked,onBusyChange}:SensorDetailProps&{blocked:boolean;onBusyChange:(busy:boolean)=>void}){
|
||||||
|
const [busy,setBusy]=useState(false);const [result,setResult]=useState<LinkResult|null>(null);
|
||||||
|
const running=useRef(false);const mounted=useRef(true);
|
||||||
|
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]);
|
||||||
|
const available=enabled&&device.online&&device.verified&&vescStatus(device).link_check_supported;
|
||||||
|
async function check(){
|
||||||
|
if(running.current||blocked||!available)return;
|
||||||
|
running.current=true;setBusy(true);onBusyChange(true);failure(null);setResult(null);
|
||||||
|
try{
|
||||||
|
const inventory=await transport.inventory();
|
||||||
|
const peers=inventory.items.filter(item=>item.kind==='vesc.controller');
|
||||||
|
const selected=peers.find(item=>item.id===device.id);
|
||||||
|
if(inventory.fresh===false||!selected||peers.some(item=>!item.online||!item.verified))throw new Error('Обновите связь со всеми VESC борта.');
|
||||||
|
const sessions=Object.fromEntries(peers.map(item=>[item.id,item.snapshot.context.session_id]));
|
||||||
|
const value=await perform<LinkResult>(transport,selected,'vesc.link.check',{sessions},45000);
|
||||||
|
if(mounted.current)setResult(value);
|
||||||
|
await refresh();
|
||||||
|
}catch(error){if(mounted.current)failure(error);}
|
||||||
|
finally{running.current=false;if(mounted.current){setBusy(false);onBusyChange(false);}}
|
||||||
|
}
|
||||||
|
if(!vescStatus(device).link_check_supported)return null;
|
||||||
|
const text=result?.outcome==='complete'?'Все ответы получены. Эта проверка не подтверждает связь во время вращения.':
|
||||||
|
result?.outcome==='not_idle'?'Измерение прекращено: есть команда с пульта или движение мотора.':
|
||||||
|
result?.outcome==='read_failed'?'Ответ контроллера не получен. Проверка прервана.':
|
||||||
|
'Измерение не завершено.';
|
||||||
|
return <SettingsCard title="Связь с контроллерами" description="Проверяет ответы всех VESC борта около 10 секунд. Моторы должны стоять; команды вращения и изменения настроек не отправляются."
|
||||||
|
actions={<Button disabled={!available||blocked||busy} loading={busy} onClick={()=>void check()}>Проверить связь</Button>}>
|
||||||
|
{busy&&<p role="status">Измеряется время ответа контроллеров.</p>}
|
||||||
|
{result&&<><p role="status">{text}</p><ResourceList aria-label="Связь VESC">{Object.entries(result.devices).map(([id,item])=><li key={id}><ResourceRow title={item.name}
|
||||||
|
description={`${item.summary.replies} ответов${item.summary.max_ms===undefined?'':` · 95% не дольше ${Math.ceil(item.summary.p95_ms??0)} мс · максимум ${Math.ceil(item.summary.max_ms)} мс`}`}/></li>)}</ResourceList></>}
|
||||||
|
</SettingsCard>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import {useEffect,useRef,useState} from 'react';
|
||||||
|
import {Button,Checker,Select,TextField,SettingsCard} from '@nodedc/ui-react';
|
||||||
|
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||||
|
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||||
|
import {testInput,speedInput,rotationResult,vescStatus,driveTestIds,drivePositions} from './model';
|
||||||
|
|
||||||
|
export function VescMotor({device,transport,enabled,refresh,failure,blocked=false,onBusyChange}:SensorDetailProps&{blocked?:boolean;onBusyChange?:(busy:boolean)=>void}){
|
||||||
|
const [duration,setDuration]=useState('30');
|
||||||
|
const [current,setCurrent]=useState('30');
|
||||||
|
const [speed,setSpeed]=useState('2000');
|
||||||
|
const [direction,setDirection]=useState('forward');
|
||||||
|
const [scope,setScope]=useState('single');
|
||||||
|
const status=vescStatus(device),profile=status.drive_profile;
|
||||||
|
const driveIds=driveTestIds(profile,device.id);
|
||||||
|
const group=scope==='profile';
|
||||||
|
const groupAvailable=status.group_test_supported===true&&driveIds.length>1;
|
||||||
|
const [clear,setClear]=useState(false);const [busy,setBusy]=useState(false);
|
||||||
|
const [result,setResult]=useState<string|null>(null);const [stopping,setStopping]=useState(false);
|
||||||
|
const [rc,setRc]=useState(device.vesc_status?.rc_latched===true);
|
||||||
|
const running=useRef(false);const mounted=useRef(true);
|
||||||
|
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]);
|
||||||
|
const limits=vescStatus(device).test_limits;
|
||||||
|
const speedLimits=vescStatus(device).speed_limits;
|
||||||
|
const speedValue=speedInput(speed,speedLimits);
|
||||||
|
const input=testInput(current,duration,limits);
|
||||||
|
const available=!!limits&&!!speedLimits&&enabled&&!blocked&&device.online&&device.verified&&device.vesc_status?.test_supported===true;
|
||||||
|
const valid=input.valid&&!speedValue.error&&(direction==='forward'||speedLimits?.reverse_supported===true)&&(!group||groupAvailable);
|
||||||
|
const blockedReason=busy?'Подготовка и проверка выполняются. Дождитесь результата или остановите проверку.':
|
||||||
|
!enabled||!device.online?'Для запуска нужна свежая связь с бортом и VESC.':
|
||||||
|
!device.verified?'Контроллер ещё не определён. Обновите устройства.':
|
||||||
|
!available?'Проверка вращения для этого VESC сейчас недоступна на борту.':
|
||||||
|
!valid?'Исправьте значения в отмеченных полях.':
|
||||||
|
!clear?'Для запуска подтвердите, что все приводы остановлены, вывешены и вращение свободно.':null;
|
||||||
|
async function execute(release=false){
|
||||||
|
if(running.current||!available||!clear||!valid)return;
|
||||||
|
running.current=true;setBusy(true);onBusyChange?.(true);setResult(null);failure(null);
|
||||||
|
try{
|
||||||
|
const inventory=await transport.inventory();
|
||||||
|
const controllers=inventory.items.filter(item=>item.kind==='vesc.controller'&&item.online);
|
||||||
|
const target=controllers.find(item=>item.id===device.id);
|
||||||
|
if(inventory.fresh===false||!target||controllers.some(item=>!item.verified))throw new Error('Нужна свежая связь со всеми подключёнными VESC этого борта.');
|
||||||
|
if(group&&!release&&(vescStatus(target).drive_profile?.revision!==profile?.revision||driveIds.some(id=>!controllers.some(item=>item.id===id))))throw new Error('Профиль или состав моторов изменился. Обновите карточку.');
|
||||||
|
const value=await perform<{outcome?:string;rotation_s?:number;release_confirmed?:boolean;limits_restored?:boolean}>(transport,target,release?'vesc.control.release':group?'vesc.drive.run':'vesc.motor.run',{
|
||||||
|
rig_clear:true,duration_s:input.durationS,current_a:input.currentA,
|
||||||
|
...(release?{}:{erpm:speedValue.erpm*(direction==='reverse'?-1:1)}),
|
||||||
|
...(vescStatus(target).speed_limits?.standstill_confirmation_required?{standstill_confirmed:true}:{}),
|
||||||
|
...(!release&&group?{profile_revision:profile?.revision,device_ids:driveIds}:{}),
|
||||||
|
sessions:Object.fromEntries(controllers.map(item=>[item.id,item.snapshot.context.session_id])),
|
||||||
|
},group&&!release?120000:90000);
|
||||||
|
if(mounted.current){
|
||||||
|
setResult(release?'Управление возвращено для проверки.':rotationResult(value));
|
||||||
|
if(release)setRc(false);
|
||||||
|
}
|
||||||
|
}catch(error){if(mounted.current)failure(error);}
|
||||||
|
finally{
|
||||||
|
running.current=false;
|
||||||
|
onBusyChange?.(false);
|
||||||
|
if(mounted.current){setBusy(false);setClear(false);await refresh();}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function stop(){
|
||||||
|
if(stopping)return;setStopping(true);
|
||||||
|
try{const value=await perform<{interruptible?:boolean}>(transport,device,'vesc.motor.stop',{},15000);if(mounted.current)setResult(value.interruptible===false?'Идёт штатный цикл измерения VESC. Немедленная остановка возможна отключением питания.':'Остановка запрошена. Ожидаем результат проверки.');}
|
||||||
|
catch(error){if(mounted.current)failure(error);}
|
||||||
|
finally{if(mounted.current)setStopping(false);}
|
||||||
|
}
|
||||||
|
useEffect(()=>setRc(device.vesc_status?.rc_latched===true),[device.vesc_status?.rc_latched]);
|
||||||
|
return <SettingsCard title="Проверка вращения" description={`${device.name} · ${device.connection_label??'USB'}. Конфигурации подключённых контроллеров сохраняются автоматически.`}>
|
||||||
|
<p>Для вывешенных колёс без нагрузки. При команде с приёмника проверка прекращается; повторный запуск требует явного возврата управления.</p>
|
||||||
|
<Select label="Проверяемые моторы" value={scope} disabled={busy||blocked} options={[{value:'single',label:`Только ${device.name}`},{value:'profile',label:'Все моторы профиля одновременно',disabled:!groupAvailable}]} onChange={setScope}/>
|
||||||
|
{group&&profile&&<p>{Object.keys(drivePositions(profile.layout)).map(slot=>`${drivePositions(profile.layout)[slot]} · VESC ${profile.bindings[slot]?.uuid.slice(0,6).toUpperCase()??'не назначен'}`).join('; ')}. Общий отсчёт начинается, когда все моторы удерживают скорость. Остановка любого завершает всю проверку. Предел тока применяется к каждому мотору.</p>}
|
||||||
|
<Select label="Направление вращения" value={direction} disabled={busy||blocked} options={[{value:'forward',label:'Прямое'},{value:'reverse',label:'Обратное',disabled:speedLimits?.reverse_supported!==true}]} onChange={value=>{setDirection(value);setClear(false);}}/>
|
||||||
|
<p>Направление относительно настроек VESC. Перед обратным запуском дождитесь полной остановки всех моторов и подтвердите её.</p>
|
||||||
|
<TextField type="number" inputMode="decimal" label="Скорость, ERPM" value={speed} onChange={event=>setSpeed(event.target.value)} disabled={busy||!speedLimits} min={speedLimits?.min_erpm} max={speedLimits?.max_erpm} step="100" aria-invalid={!!speedValue.error} description={speedValue.error??'Электрические обороты в минуту. VESC плавно разгоняет мотор и удерживает заданную скорость.'}/>
|
||||||
|
<TextField type="number" inputMode="decimal" label="Предел тока мотора, А" value={current} onChange={event=>setCurrent(event.target.value)} disabled={busy||!limits} min={limits?.min_current_a} max={limits?.max_current_a} step="0.1" aria-invalid={!!input.currentError} hint={limits?`${limits.min_current_a}–${limits.max_current_a} А`:undefined} description={input.currentError??'Максимальный ток разгона и удержания скорости. Прежние пределы сохраняются перед тестом и восстанавливаются после него.'}/>
|
||||||
|
<TextField type="number" inputMode="decimal" label="Длительность вращения, с" value={duration} onChange={event=>setDuration(event.target.value)} disabled={busy||!limits} min={limits?.min_duration_s} max={limits?.max_duration_s} step="0.1" aria-invalid={!!input.durationError} hint={limits?`${limits.min_duration_s}–${limits.max_duration_s} с`:undefined} description={input.durationError??'Отсчёт начинается после разгона и стабилизации скорости. Подготовка, разгон и остановки не входят в это время.'}/>
|
||||||
|
<p>Время считается по скорости и тахометру VESC. Если мотор не разгонится за 15 секунд, потеряет скорость или сработает ограничение, результат покажет фактическое время и причину остановки. Показания контроллера нужно сопоставить с видимым вращением.</p>
|
||||||
|
{limits?.stall_timeout_s&&<p>При токе выше {limits.stall_current_a} А и отсутствии подтверждённого движения в течение {limits.stall_timeout_s} секунд проверка остановится.</p>}
|
||||||
|
<p>Все приводы должны быть вывешены, вращение свободно. Подтверждение требуется перед каждым запуском.</p>
|
||||||
|
<Checker checked={clear} onChange={setClear} disabled={busy} label="Все моторы остановлены, наблюдаю"/>
|
||||||
|
{blockedReason&&<p role="status">{blockedReason}</p>}
|
||||||
|
<div className="sensor-actions">
|
||||||
|
{rc?<Button disabled={!available||!clear||busy||!valid} loading={busy} onClick={()=>void execute(true)}>Вернуть управление после нейтрали</Button>:
|
||||||
|
<Button disabled={!available||!clear||busy||!valid} loading={busy} onClick={()=>void execute()}>Проверить вращение</Button>}
|
||||||
|
<Button disabled={!enabled||!device.online||blocked} loading={stopping} onClick={()=>void stop()}>Остановить проверку</Button>
|
||||||
|
</div>
|
||||||
|
{result&&<p role="status">{result}</p>}
|
||||||
|
</SettingsCard>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type {Sensor} from '../../../../packages/sensor-ui/src/contracts';
|
||||||
|
|
||||||
|
export interface VescIdentity {uuid:string;hardware:string;version:string;test_firmware:number|null;hardware_type:number|null}
|
||||||
|
export interface VescTelemetry {observed_at:string;values:Record<string,number|boolean>}
|
||||||
|
export interface DriveProfile {layout:null|'1x1'|'2x2';revision:number;bindings:Record<string,{device_id:string;uuid:string}>}
|
||||||
|
export function drivePositions(layout:DriveProfile['layout']):Record<string,string> {
|
||||||
|
return layout==='2x2'
|
||||||
|
? {'left.1':'Левый передний','left.2':'Левый задний','right.1':'Правый передний','right.2':'Правый задний'}
|
||||||
|
: {'left.1':'Левый','right.1':'Правый'};
|
||||||
|
}
|
||||||
|
export interface VescStatus {
|
||||||
|
board_settings_supported?:boolean;
|
||||||
|
group_test_supported?:boolean;
|
||||||
|
link_check_supported?:boolean;
|
||||||
|
foc_calibration?:{min_power_loss_w:number;max_power_loss_w:number;interruptible:boolean};
|
||||||
|
speed_limits?:{min_erpm:number;max_erpm:number;duration_basis:string;reverse_supported?:boolean;standstill_confirmation_required?:boolean};
|
||||||
|
hall_measurement?:{current_a:number;interruptible:boolean;standstill_confirmation_required?:boolean};
|
||||||
|
drive_profile?:DriveProfile;
|
||||||
|
test_limits?:{min_current_a:number;max_current_a:number;min_duration_s:number;max_duration_s:number;current_ramp_a_per_s?:number;continuous_current?:boolean;max_erpm?:number;max_duty?:number;stall_current_a?:number;stall_timeout_s?:number};
|
||||||
|
identity:VescIdentity|null; readable:boolean; message:string|null; telemetry:VescTelemetry|null;
|
||||||
|
backup:{observed_at:string;operation_id:string;configs:Record<string,{bytes:number;sha256:string}>}|null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function driveTestIds(profile:DriveProfile|undefined,selected:string):string[] {
|
||||||
|
if(!profile?.layout)return [];
|
||||||
|
const slots=Object.keys(drivePositions(profile.layout));
|
||||||
|
if(Object.keys(profile.bindings).length!==slots.length||slots.some(slot=>!profile.bindings[slot]))return [];
|
||||||
|
const ids=slots.map(slot=>profile.bindings[slot].device_id);
|
||||||
|
return new Set(ids).size===ids.length&&ids.includes(selected)?ids:[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function speedInput(speed:string,limits:VescStatus['speed_limits']) {
|
||||||
|
const erpm=Number(speed);
|
||||||
|
const error=!limits?'Для удержания скорости требуется обновление профиля VESC на борту.':
|
||||||
|
speed.trim()===''||!Number.isFinite(erpm)?'Введите скорость.':
|
||||||
|
erpm<limits.min_erpm||erpm>limits.max_erpm?`Скорость должна быть от ${limits.min_erpm} до ${limits.max_erpm} ERPM.`:null;
|
||||||
|
return {erpm,error};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rotationResult(value:{outcome?:string;rotation_s?:number;release_confirmed?:boolean;limits_restored?:boolean}) {
|
||||||
|
const time=(value.rotation_s??0).toLocaleString('ru-RU',{maximumFractionDigits:1});
|
||||||
|
const outcome=value.outcome==='duration'?'Заданное время вращения набрано.':value.outcome==='stopped'?'Проверка остановлена.':String(value.outcome);
|
||||||
|
return `${outcome} Вращение на заданной скорости по данным VESC: ${time} с. ${value.release_confirmed?'Снятие тока подтверждено.':'Снятие тока не подтверждено.'}${value.limits_restored?' Исходные токовые пределы восстановлены.':''}`;
|
||||||
|
}
|
||||||
|
export function vescStatus(device:Sensor):VescStatus {
|
||||||
|
return {identity:null,readable:false,message:null,telemetry:null,backup:null,...device.vesc_status} as VescStatus;
|
||||||
|
}
|
||||||
|
export function vescLabel(device:Sensor,fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} {
|
||||||
|
const status=vescStatus(device);
|
||||||
|
if(!fresh||!device.online)return {label:'Нет связи',tone:'neutral'};
|
||||||
|
if(!device.prepared)return {label:'Требуется подготовка',tone:'neutral'};
|
||||||
|
if(!status.identity)return {label:'Не определён',tone:'warning'};
|
||||||
|
if(!status.readable)return {label:'Прошивка не поддерживается',tone:'warning'};
|
||||||
|
return {label:'Готов к чтению',tone:'success'};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function testInput(current:string,duration:string,limits:VescStatus['test_limits']) {
|
||||||
|
const currentA=Number(current),durationS=Number(duration);
|
||||||
|
const number=(value:number)=>value.toLocaleString('ru-RU');
|
||||||
|
const currentError=!limits?null:current.trim()===''||!Number.isFinite(currentA)
|
||||||
|
? 'Введите ток мотора.'
|
||||||
|
: currentA<limits.min_current_a||currentA>limits.max_current_a
|
||||||
|
? `Ток должен быть от ${number(limits.min_current_a)} до ${number(limits.max_current_a)} А.`:null;
|
||||||
|
const durationError=!limits?null:duration.trim()===''||!Number.isFinite(durationS)
|
||||||
|
? 'Введите длительность проверки.'
|
||||||
|
: durationS<limits.min_duration_s||durationS>limits.max_duration_s
|
||||||
|
? `Длительность должна быть от ${number(limits.min_duration_s)} до ${number(limits.max_duration_s)} с.`:null;
|
||||||
|
return {currentA,durationS,currentError,durationError,valid:!!limits&&!currentError&&!durationError};
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type {SensorUiContribution} from '../../../../packages/sensor-ui/src/extensions';
|
||||||
|
import {VescDetail} from './VescDetail';
|
||||||
|
import {VescBoardSettings} from './VescBoardSettings';
|
||||||
|
import {vescLabel} from './model';
|
||||||
|
|
||||||
|
export const vescSensorUi:SensorUiContribution={
|
||||||
|
kind:'vesc.controller',Detail:VescDetail,BoardSettings:VescBoardSettings,icon:'activity',retainOffline:true,
|
||||||
|
supportsPreparation:true,supportsRenaming:true,detailLabel:'Настройка VESC',status:vescLabel,
|
||||||
|
};
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
#pragma once
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QCryptographicHash>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QXmlStreamWriter>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include "vescinterface.h"
|
||||||
|
#include "utility.h"
|
||||||
|
|
||||||
|
static QString sha256(const QByteArray &data) {
|
||||||
|
return QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void require(bool condition, const char *message) {
|
||||||
|
if (!condition) throw std::runtime_error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
static QJsonObject exportConfig(ConfigParams *config, const QByteArray &packet,
|
||||||
|
const QString &xmlName) {
|
||||||
|
VByteArray roundTrip;
|
||||||
|
config->serialize(roundTrip);
|
||||||
|
require(roundTrip == packet.mid(1), "Native configuration round-trip differs from archive");
|
||||||
|
QJsonArray parameters;
|
||||||
|
for (const auto &name : config->getParamOrder()) {
|
||||||
|
const auto p = config->getParamCopy(name);
|
||||||
|
QJsonObject item{{"name", name}, {"title", p.longName},
|
||||||
|
{"description", p.description}, {"suffix", p.suffix},
|
||||||
|
{"transmittable", p.transmittable}, {"editor_scale", p.editorScale}};
|
||||||
|
switch (p.type) {
|
||||||
|
case CFG_T_DOUBLE:
|
||||||
|
item.insert("type", "number"); item.insert("value", p.valDouble);
|
||||||
|
item.insert("min", p.minDouble); item.insert("max", p.maxDouble);
|
||||||
|
item.insert("step", p.stepDouble); item.insert("decimals", p.editorDecimalsDouble);
|
||||||
|
break;
|
||||||
|
case CFG_T_INT: case CFG_T_BITFIELD:
|
||||||
|
item.insert("type", p.type == CFG_T_INT ? "integer" : "bitfield");
|
||||||
|
item.insert("value", p.valInt); item.insert("min", p.minInt);
|
||||||
|
item.insert("max", p.maxInt); item.insert("step", p.stepInt);
|
||||||
|
break;
|
||||||
|
case CFG_T_ENUM:
|
||||||
|
item.insert("type", "enum"); item.insert("value", p.valInt);
|
||||||
|
item.insert("options", QJsonArray::fromStringList(p.enumNames)); break;
|
||||||
|
case CFG_T_BOOL:
|
||||||
|
item.insert("type", "boolean"); item.insert("value", bool(p.valInt)); break;
|
||||||
|
case CFG_T_QSTRING:
|
||||||
|
item.insert("type", "string"); item.insert("value", p.valString);
|
||||||
|
item.insert("max_length", p.maxLen); break;
|
||||||
|
default: item.insert("type", "undefined");
|
||||||
|
}
|
||||||
|
parameters.append(item);
|
||||||
|
}
|
||||||
|
QJsonArray groups;
|
||||||
|
for (const auto &group : config->getParamGroups()) {
|
||||||
|
QJsonArray subgroups;
|
||||||
|
for (const auto &subgroup : config->getParamSubgroups(group)) {
|
||||||
|
subgroups.append(QJsonObject{{"name", subgroup}, {"parameters",
|
||||||
|
QJsonArray::fromStringList(config->getParamsFromSubgroup(group, subgroup))}});
|
||||||
|
}
|
||||||
|
groups.append(QJsonObject{{"name", group}, {"subgroups", subgroups}});
|
||||||
|
}
|
||||||
|
QString xml;
|
||||||
|
QXmlStreamWriter writer(&xml);
|
||||||
|
writer.setAutoFormatting(true);
|
||||||
|
config->getXML(writer, xmlName);
|
||||||
|
// Validate Tool's own XML load as well as its binary codec. No output file,
|
||||||
|
// motor configuration write, custom schema, or custom parameter parser.
|
||||||
|
ConfigParams loaded;
|
||||||
|
loaded = *config;
|
||||||
|
QXmlStreamReader reader(xml);
|
||||||
|
require(loaded.setXML(reader, xmlName), "Native XML round-trip failed");
|
||||||
|
VByteArray xmlRoundTrip; loaded.serialize(xmlRoundTrip);
|
||||||
|
const auto differences = config->checkDifference(&loaded);
|
||||||
|
require(differences.isEmpty(), "Native XML differs beyond upstream comparison tolerance");
|
||||||
|
return QJsonObject{{"parameters", parameters}, {"groups", groups}, {"xml", xml},
|
||||||
|
{"packet_sha256", sha256(packet)}, {"round_trip_exact", true},
|
||||||
|
{"xml_round_trip_exact", xmlRoundTrip == roundTrip},
|
||||||
|
{"xml_equivalent_by_upstream_comparison", true},
|
||||||
|
{"signature", double(config->getSignature())}};
|
||||||
|
}
|
||||||
|
|
||||||
|
static QByteArray archivePacket(const QJsonObject &archive, const QString &key, int command) {
|
||||||
|
auto entry = archive.value("configs").toObject().value(key).toObject();
|
||||||
|
auto encoded = entry.value("payload").toString().toLatin1();
|
||||||
|
auto packet = QByteArray::fromBase64(encoded, QByteArray::AbortOnBase64DecodingErrors);
|
||||||
|
require(entry.value("encoding") == "base64" && !packet.isEmpty() && packet.size() <= 16384,
|
||||||
|
"Invalid archived configuration encoding");
|
||||||
|
require(packet.toBase64() == encoded && packet.size() == entry.value("bytes").toInt(),
|
||||||
|
"Invalid archived configuration length");
|
||||||
|
require(quint8(packet.at(0)) == command && sha256(packet) == entry.value("sha256").toString(),
|
||||||
|
"Archived configuration hash or command mismatch");
|
||||||
|
return packet;
|
||||||
|
}
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// Private per-device JSON process adapter. Every wire operation is upstream.
|
||||||
|
#include "config_export.h"
|
||||||
|
#include <QEventLoop>
|
||||||
|
#include <QElapsedTimer>
|
||||||
|
#include <QSocketNotifier>
|
||||||
|
#include <QSerialPort>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <cmath>
|
||||||
|
#include <functional>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <sys/file.h>
|
||||||
|
#include <sys/ioctl.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/sysmacros.h>
|
||||||
|
#include <termios.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
class ExchangeFailure : public std::runtime_error {
|
||||||
|
public:
|
||||||
|
QJsonObject diagnostics;
|
||||||
|
explicit ExchangeFailure(const QJsonObject &value)
|
||||||
|
: std::runtime_error("Native query timed out or disconnected"), diagnostics(value) {}
|
||||||
|
};
|
||||||
|
|
||||||
|
class Engine {
|
||||||
|
public:
|
||||||
|
VescInterface vesc;
|
||||||
|
Packet *packet;
|
||||||
|
FW_RX_PARAMS identity;
|
||||||
|
bool procedureRunning = false;
|
||||||
|
bool procedureUncertain = false;
|
||||||
|
QJsonObject procedure;
|
||||||
|
bool queryRunning = false;
|
||||||
|
bool allowHardware = false;
|
||||||
|
QByteArray lastMotor, lastApplication, lastHall;
|
||||||
|
QTimer outputWatchdog;
|
||||||
|
|
||||||
|
Engine() {
|
||||||
|
outputWatchdog.setSingleShot(true);
|
||||||
|
QObject::connect(&outputWatchdog, &QTimer::timeout, [&] {
|
||||||
|
if (allowHardware && vesc.isPortConnected() && !procedureRunning) {
|
||||||
|
vesc.commands()->setCurrent(0);
|
||||||
|
try { flush(); } catch (...) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
require(Utility::configLoadLatest(&vesc), "Upstream resources missing");
|
||||||
|
packet = vesc.findChild<Packet *>();
|
||||||
|
require(packet, "Upstream packet transport missing");
|
||||||
|
QObject::connect(vesc.commands(), &Commands::fwVersionReceived,
|
||||||
|
[&](FW_RX_PARAMS value) { identity = value; });
|
||||||
|
QObject::connect(packet, &Packet::packetReceived, [&](QByteArray &raw) {
|
||||||
|
if (!raw.isEmpty() && quint8(raw[0]) == COMM_GET_MCCONF) lastMotor = raw;
|
||||||
|
if (!raw.isEmpty() && quint8(raw[0]) == COMM_GET_APPCONF) lastApplication = raw;
|
||||||
|
if (!raw.isEmpty() && quint8(raw[0]) == COMM_DETECT_HALL_FOC) lastHall = raw;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void open(const QString &port) {
|
||||||
|
require(QRegularExpression("^/dev/ttyACM[0-9]+$").match(port).hasMatch(), "Unsupported USB path");
|
||||||
|
struct stat st;
|
||||||
|
require(lstat(port.toLocal8Bit(), &st) == 0 && S_ISCHR(st.st_mode) && major(st.st_rdev) == 166,
|
||||||
|
"Not a CDC ACM device");
|
||||||
|
require(vesc.connectSerial(port, 115200), "Native serial connection failed");
|
||||||
|
auto serial = vesc.findChild<QSerialPort *>();
|
||||||
|
require(serial && serial->isOpen() && "/dev/" + serial->portName() == port,
|
||||||
|
"Native serial path mismatch");
|
||||||
|
require(flock(serial->handle(), LOCK_EX | LOCK_NB) == 0 && ioctl(serial->handle(), TIOCEXCL) == 0,
|
||||||
|
"Serial port is already owned");
|
||||||
|
allowHardware = true;
|
||||||
|
query(COMM_FW_VERSION, 3000);
|
||||||
|
require(identity.major == 5 && identity.minor == 2 && identity.hwType == HW_TYPE_VESC
|
||||||
|
&& identity.isTestFw == 0 && identity.customConfigNum == 0,
|
||||||
|
"Native hardware acceptance currently admits stable FW 5.02 only");
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray exchange(int command, int timeoutMs, const std::function<void()> &send) {
|
||||||
|
require(allowHardware && vesc.isPortConnected(), "Device disconnected");
|
||||||
|
require(!queryRunning, "A native query is already pending");
|
||||||
|
queryRunning = true;
|
||||||
|
struct PendingReset { bool &pending; ~PendingReset() { pending = false; } } reset{queryRunning};
|
||||||
|
QEventLoop loop;
|
||||||
|
QTimer timeout; timeout.setSingleShot(true);
|
||||||
|
QObject observer;
|
||||||
|
QElapsedTimer elapsed; elapsed.start();
|
||||||
|
QJsonArray events;
|
||||||
|
bool emitted = false;
|
||||||
|
int packetsSent = 0, packetsReceived = 0;
|
||||||
|
qint64 bytesWritten = 0;
|
||||||
|
auto serial = vesc.findChild<QSerialPort *>();
|
||||||
|
auto record = [&](const QString &kind, int code, qint64 bytes) {
|
||||||
|
if (events.size() == 24) events.removeFirst();
|
||||||
|
events.append(QJsonObject{{"event", kind}, {"command", code},
|
||||||
|
{"bytes", double(bytes)}, {"at_ms", elapsed.nsecsElapsed() / 1e6}});
|
||||||
|
};
|
||||||
|
QObject::connect(vesc.commands(), &Commands::dataToSend, &observer, [&](QByteArray &raw) {
|
||||||
|
const int code = raw.isEmpty() ? -1 : quint8(raw[0]);
|
||||||
|
if (code == command) emitted = true;
|
||||||
|
record("command_emitted", code, raw.size());
|
||||||
|
});
|
||||||
|
QObject::connect(packet, &Packet::dataToSend, &observer, [&](QByteArray &raw) {
|
||||||
|
++packetsSent; record("packet_sent", -1, raw.size());
|
||||||
|
});
|
||||||
|
if (serial) {
|
||||||
|
QObject::connect(serial, &QSerialPort::bytesWritten, &observer, [&](qint64 bytes) {
|
||||||
|
bytesWritten += bytes; record("serial_written", -1, bytes);
|
||||||
|
});
|
||||||
|
QObject::connect(serial, &QSerialPort::errorOccurred, &observer, [&](QSerialPort::SerialPortError error) {
|
||||||
|
if (error != QSerialPort::NoError) record("serial_error", int(error), 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
QByteArray answer;
|
||||||
|
QObject::connect(packet, &Packet::packetReceived, &observer, [&](QByteArray &raw) {
|
||||||
|
++packetsReceived;
|
||||||
|
record("packet_received", raw.isEmpty() ? -1 : quint8(raw[0]), raw.size());
|
||||||
|
if (!raw.isEmpty() && quint8(raw[0]) == command) { answer = raw; loop.quit(); }
|
||||||
|
});
|
||||||
|
QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
|
||||||
|
timeout.start(timeoutMs);
|
||||||
|
send();
|
||||||
|
if (answer.isEmpty()) loop.exec();
|
||||||
|
if (answer.isEmpty() || !vesc.isPortConnected()) {
|
||||||
|
throw ExchangeFailure({{"requested_command", command}, {"timeout_ms", timeoutMs},
|
||||||
|
{"elapsed_ms", elapsed.nsecsElapsed() / 1e6}, {"request_emitted", emitted},
|
||||||
|
{"packets_sent", packetsSent}, {"packets_received", packetsReceived},
|
||||||
|
{"serial_bytes_written", double(bytesWritten)}, {"port_connected", vesc.isPortConnected()},
|
||||||
|
{"serial_open", serial && serial->isOpen()}, {"serial_error", serial ? int(serial->error()) : -1},
|
||||||
|
{"serial_bytes_pending", serial ? double(serial->bytesToWrite()) : -1}, {"events", events}});
|
||||||
|
}
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray query(int code, int timeoutMs, bool internal = false) {
|
||||||
|
auto cmd = vesc.commands();
|
||||||
|
require(internal || !procedureRunning || code == COMM_GET_VALUES || code == COMM_GET_DECODED_PPM,
|
||||||
|
"Configuration reads are unavailable during native measurement");
|
||||||
|
std::function<void()> send;
|
||||||
|
switch (code) {
|
||||||
|
case COMM_FW_VERSION: send = [=] { cmd->getFwVersion(); }; break;
|
||||||
|
case COMM_GET_VALUES: send = [=] { cmd->getValues(); }; break;
|
||||||
|
case COMM_GET_MCCONF: send = [=] { cmd->getMcconf(); }; break;
|
||||||
|
case COMM_GET_APPCONF: send = [=] { cmd->getAppConf(); }; break;
|
||||||
|
case COMM_GET_DECODED_PPM: send = [=] { cmd->getDecodedPpm(); }; break;
|
||||||
|
case COMM_PING_CAN: send = [=] { cmd->pingCan(); }; break;
|
||||||
|
default: throw std::runtime_error("Native read command is not admitted");
|
||||||
|
}
|
||||||
|
auto raw = exchange(code, timeoutMs, send);
|
||||||
|
if (code == COMM_GET_MCCONF || code == COMM_GET_APPCONF) {
|
||||||
|
VByteArray serialized;
|
||||||
|
auto config = code == COMM_GET_MCCONF ? vesc.mcConfig() : vesc.appConfig();
|
||||||
|
config->serialize(serialized);
|
||||||
|
require(serialized == raw.mid(1), "Native configuration decode is not byte-exact");
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
QByteArray configurationPacket(ConfigParams *config, int code) {
|
||||||
|
VByteArray raw; raw.vbAppendInt8(code); config->serialize(raw); return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
void calibrate(double loss) {
|
||||||
|
ConfigParams beforeMotor, beforeApp;
|
||||||
|
beforeMotor = *vesc.mcConfig(); beforeApp = *vesc.appConfig();
|
||||||
|
bool received = false, validated = false;
|
||||||
|
int code = -1000;
|
||||||
|
QString report, error;
|
||||||
|
QJsonArray changed;
|
||||||
|
auto connection = QObject::connect(vesc.commands(), &Commands::detectAllFocReceived,
|
||||||
|
[&](int result) { received = true; code = result; });
|
||||||
|
try {
|
||||||
|
// The actual upstream wizard motor procedure, including its FW 5.02
|
||||||
|
// power-loss correction. Do not infer a battery profile from Ah/voltage.
|
||||||
|
report = Utility::detectAllFoc(&vesc, false, loss,
|
||||||
|
beforeMotor.getParamDouble("l_in_current_min"), beforeMotor.getParamDouble("l_in_current_max"),
|
||||||
|
beforeMotor.getParamDouble("foc_openloop_rpm"), beforeMotor.getParamDouble("foc_sl_erpm"));
|
||||||
|
require(received, "Native calibration completion was not received");
|
||||||
|
query(COMM_GET_MCCONF, 3000, true);
|
||||||
|
query(COMM_GET_APPCONF, 3000, true);
|
||||||
|
auto mc = vesc.mcConfig(); auto app = vesc.appConfig();
|
||||||
|
const QStringList admitted = {"l_current_max", "l_current_min", "motor_type", "foc_motor_r",
|
||||||
|
"foc_motor_l", "foc_motor_flux_linkage", "foc_current_kp", "foc_current_ki", "foc_observer_gain",
|
||||||
|
"foc_sensor_mode", "m_sensor_port_mode", "foc_encoder_offset", "foc_encoder_ratio", "foc_encoder_inverted",
|
||||||
|
"foc_hall_table__0", "foc_hall_table__1", "foc_hall_table__2", "foc_hall_table__3",
|
||||||
|
"foc_hall_table__4", "foc_hall_table__5", "foc_hall_table__6", "foc_hall_table__7"};
|
||||||
|
for (const auto &key : beforeMotor.checkDifference(mc)) {
|
||||||
|
require(admitted.contains(key), "Calibration changed a protected motor parameter");
|
||||||
|
changed.append(key);
|
||||||
|
}
|
||||||
|
for (const auto &key : beforeApp.checkDifference(app))
|
||||||
|
require(key == "send_can_status", "Calibration changed a protected receiver parameter");
|
||||||
|
// Native FW 5.02 enables CAN status as a side effect. Restore the
|
||||||
|
// original application exactly; retain existing PPM and CAN identity.
|
||||||
|
if (!beforeApp.checkDifference(app).isEmpty()) {
|
||||||
|
*app = beforeApp;
|
||||||
|
const auto expected = configurationPacket(app, COMM_GET_APPCONF);
|
||||||
|
require(exchange(COMM_SET_APPCONF, 3000, [&] { vesc.commands()->setAppConf(); }).size() == 1,
|
||||||
|
"Application write ACK invalid");
|
||||||
|
require(query(COMM_GET_APPCONF, 3000, true) == expected, "Application restore not byte-exact");
|
||||||
|
}
|
||||||
|
if (code < 0) {
|
||||||
|
// A completed failed detection can leave partial RAM changes.
|
||||||
|
// Only the known calibration fields passed the guard above.
|
||||||
|
*mc = beforeMotor;
|
||||||
|
} else {
|
||||||
|
for (const auto &key : {"foc_motor_r", "foc_motor_l", "foc_motor_flux_linkage"})
|
||||||
|
require(std::isfinite(mc->getParamDouble(key)) && mc->getParamDouble(key) > 0,
|
||||||
|
"Invalid detected motor parameter");
|
||||||
|
require(mc->getParamDouble("l_current_max") > 0 && mc->getParamDouble("l_current_min") < 0,
|
||||||
|
"Invalid detected current limits");
|
||||||
|
// Calibration must not silently increase the owner's existing
|
||||||
|
// current limits. The separate spin test applies its own 30 A cap.
|
||||||
|
mc->updateParamDouble("l_current_max", qMin(mc->getParamDouble("l_current_max"), beforeMotor.getParamDouble("l_current_max")));
|
||||||
|
mc->updateParamDouble("l_current_min", qMax(mc->getParamDouble("l_current_min"), beforeMotor.getParamDouble("l_current_min")));
|
||||||
|
}
|
||||||
|
const auto expected = configurationPacket(mc, COMM_GET_MCCONF);
|
||||||
|
if (expected != lastMotor) {
|
||||||
|
require(exchange(COMM_SET_MCCONF, 3000, [&] { vesc.commands()->setMcconf(false); }).size() == 1,
|
||||||
|
"Motor write ACK invalid");
|
||||||
|
require(query(COMM_GET_MCCONF, 3000, true) == expected, "Motor write not byte-exact");
|
||||||
|
}
|
||||||
|
validated = true;
|
||||||
|
} catch (const std::exception &e) { error = e.what(); }
|
||||||
|
QObject::disconnect(connection);
|
||||||
|
QJsonObject parameters;
|
||||||
|
for (const auto &key : {"l_current_max", "l_current_min", "foc_motor_r", "foc_motor_l", "foc_motor_flux_linkage"})
|
||||||
|
parameters.insert(key, vesc.mcConfig()->getParamDouble(key));
|
||||||
|
procedure = {{"kind", "foc"}, {"completed", received}, {"success", received && code >= 0 && validated},
|
||||||
|
{"validated", validated}, {"code", code}, {"report", report}, {"error", error},
|
||||||
|
{"sensor_mode", vesc.mcConfig()->getParamEnum("foc_sensor_mode")}, {"parameters", parameters},
|
||||||
|
{"changed", changed}, {"upstream", "Utility::detectAllFoc"}};
|
||||||
|
procedureUncertain = !validated;
|
||||||
|
procedureRunning = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
double number(const QJsonObject &request, const QString &name, double min, double max) {
|
||||||
|
auto v = request.value(name);
|
||||||
|
require(v.isDouble() && std::isfinite(v.toDouble()) && v.toDouble() >= min && v.toDouble() <= max,
|
||||||
|
"Numeric argument is outside operation bounds");
|
||||||
|
return v.toDouble();
|
||||||
|
}
|
||||||
|
|
||||||
|
void flush() {
|
||||||
|
auto serial = vesc.findChild<QSerialPort *>();
|
||||||
|
require(serial && serial->isOpen(), "Serial transport closed");
|
||||||
|
serial->flush();
|
||||||
|
if (serial->bytesToWrite() > 0) require(serial->waitForBytesWritten(40), "Serial write not confirmed");
|
||||||
|
require(serial->bytesToWrite() == 0, "Serial write is incomplete");
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject dispatch(const QJsonObject &request) {
|
||||||
|
const auto method = request.value("method").toString();
|
||||||
|
if (method == "engine") return {{"version", "7.00"}, {"commit", "01d5f10901116c311e3fb84d5a1541f663d3ce20"},
|
||||||
|
{"connected", vesc.isPortConnected()}, {"hardware_enabled", allowHardware},
|
||||||
|
{"legacy_power_loss_correction", vesc.commands()->getMaxPowerLossBug()}};
|
||||||
|
require(allowHardware && vesc.isPortConnected(), "Native device is not connected");
|
||||||
|
if (method == "query") return {{"payload", QString::fromLatin1(query(
|
||||||
|
int(number(request, "command", 0, 255)), int(number(request, "timeout_ms", 20, 8000))).toBase64())}};
|
||||||
|
if (method == "procedure_result") return {{"running", procedureRunning}, {"uncertain", procedureUncertain}, {"result", procedure}};
|
||||||
|
if (method == "lease") {
|
||||||
|
require(!procedureRunning && !procedureUncertain, "Native procedure owns this controller");
|
||||||
|
vesc.commands()->disableAppOutput(250, false); flush(); outputWatchdog.start(200); return {};
|
||||||
|
}
|
||||||
|
if (method == "release") { vesc.commands()->setCurrent(0); flush(); return {}; }
|
||||||
|
require(!procedureRunning && !procedureUncertain, "Native procedure owns this controller");
|
||||||
|
if (method == "current") {
|
||||||
|
require(outputWatchdog.isActive(), "Output lease expired");
|
||||||
|
auto current = number(request, "current_a", 0, 30);
|
||||||
|
require(current <= vesc.mcConfig()->getParamDouble("l_current_max"), "Configured current limit exceeded");
|
||||||
|
vesc.commands()->setCurrent(current); flush(); return {};
|
||||||
|
}
|
||||||
|
if (method == "rpm") { require(outputWatchdog.isActive(), "Output lease expired"); vesc.commands()->setRpm(int(number(request, "erpm", -3000, 3000))); flush(); return {}; }
|
||||||
|
if (method == "limits") {
|
||||||
|
auto p = request.value("parameters").toObject();
|
||||||
|
MCCONF_TEMP conf;
|
||||||
|
conf.current_min_scale = number(p, "l_current_min_scale", 0, 1);
|
||||||
|
conf.current_max_scale = number(p, "l_current_max_scale", 0, 1);
|
||||||
|
// Restore/application may only change current scales. All other
|
||||||
|
// values must equal the last native read, including battery limits.
|
||||||
|
auto mc = vesc.mcConfig();
|
||||||
|
for (const auto &key : {"l_min_erpm", "l_max_erpm", "l_min_duty", "l_max_duty", "l_watt_min", "l_watt_max", "l_in_current_min", "l_in_current_max"})
|
||||||
|
require(p.value(key).isDouble() && p.value(key).toDouble() == mc->getParamDouble(key), "Only volatile current scales may change");
|
||||||
|
conf.erpm_or_speed_min = mc->getParamDouble("l_min_erpm");
|
||||||
|
conf.erpm_or_speed_max = mc->getParamDouble("l_max_erpm");
|
||||||
|
conf.duty_min = mc->getParamDouble("l_min_duty"); conf.duty_max = mc->getParamDouble("l_max_duty");
|
||||||
|
conf.watt_min = mc->getParamDouble("l_watt_min"); conf.watt_max = mc->getParamDouble("l_watt_max");
|
||||||
|
auto ack = exchange(COMM_SET_MCCONF_TEMP, 2000, [&] {
|
||||||
|
vesc.commands()->setMcconfTemp(conf, false, false, false, false, true);
|
||||||
|
});
|
||||||
|
require(ack.size() == 1, "Invalid native limits ACK"); return {};
|
||||||
|
}
|
||||||
|
if (method == "configuration") {
|
||||||
|
auto motor = query(COMM_GET_MCCONF, 2000);
|
||||||
|
auto application = query(COMM_GET_APPCONF, 2000);
|
||||||
|
return {{"motor", exportConfig(vesc.mcConfig(), motor, "MCConfiguration")},
|
||||||
|
{"application", exportConfig(vesc.appConfig(), application, "APPConfiguration")}};
|
||||||
|
}
|
||||||
|
if (method == "foc_start") {
|
||||||
|
const auto loss = number(request, "max_power_loss_w", 10, 150);
|
||||||
|
require(!lastMotor.isEmpty() && !lastApplication.isEmpty(), "Read configurations before calibration");
|
||||||
|
for (const auto &key : {"l_in_current_min", "l_in_current_max", "foc_openloop_rpm", "foc_sl_erpm"})
|
||||||
|
require(std::isfinite(vesc.mcConfig()->getParamDouble(key)) && std::abs(vesc.mcConfig()->getParamDouble(key)) > 0.001,
|
||||||
|
"Zero-valued detection inputs require an explicit equipment profile");
|
||||||
|
procedureRunning = true; procedure = {{"kind", "foc"}};
|
||||||
|
QTimer::singleShot(0, [this, loss] { calibrate(loss); });
|
||||||
|
return {{"started", true}, {"interruptible", false}};
|
||||||
|
}
|
||||||
|
if (method == "hall_start") {
|
||||||
|
require(request.value("current_a") == 5, "This Hall profile uses 5 A");
|
||||||
|
procedureRunning = true; procedure = {}; lastHall.clear();
|
||||||
|
QTimer::singleShot(0, [&] {
|
||||||
|
auto measured = Utility::measureHallFocBlocking(&vesc, 5.0);
|
||||||
|
QJsonArray table;
|
||||||
|
for (int i = 1; i < measured.size(); ++i) table.append(measured[i]);
|
||||||
|
const bool completed = measured.size() == 9 && measured.first() != -10;
|
||||||
|
procedure = {{"kind", "hall"}, {"completed", completed},
|
||||||
|
{"status", measured.isEmpty() ? -10 : measured.first()}, {"table", table},
|
||||||
|
{"payload", QString::fromLatin1(lastHall.toBase64())},
|
||||||
|
{"upstream", "Utility::measureHallFocBlocking"}};
|
||||||
|
procedureUncertain = !completed;
|
||||||
|
procedureRunning = false;
|
||||||
|
});
|
||||||
|
return {{"started", true}, {"interruptible", false}};
|
||||||
|
}
|
||||||
|
throw std::runtime_error("Native operation is not admitted");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
qputenv("QT_QPA_PLATFORM", "offscreen");
|
||||||
|
QApplication application(argc, argv);
|
||||||
|
QCoreApplication::setOrganizationName("MissionCore");
|
||||||
|
QCoreApplication::setApplicationName("VescToolEngine");
|
||||||
|
try {
|
||||||
|
require(argc == 2, "One exact serial port or --offline argument is required");
|
||||||
|
Engine engine;
|
||||||
|
const QString port = QString::fromLocal8Bit(argv[1]);
|
||||||
|
if (port != "--offline") engine.open(port);
|
||||||
|
QFile output; output.open(stdout, QIODevice::WriteOnly);
|
||||||
|
auto write = [&](const QJsonObject &value) {
|
||||||
|
output.write(QJsonDocument(value).toJson(QJsonDocument::Compact) + '\n'); output.flush();
|
||||||
|
};
|
||||||
|
write({{"ready", true}, {"engine", engine.dispatch({{"method", "engine"}})}});
|
||||||
|
QByteArray buffer;
|
||||||
|
fcntl(STDIN_FILENO, F_SETFL, fcntl(STDIN_FILENO, F_GETFL) | O_NONBLOCK);
|
||||||
|
QSocketNotifier input(STDIN_FILENO, QSocketNotifier::Read);
|
||||||
|
QObject::connect(&input, &QSocketNotifier::activated, [&] {
|
||||||
|
char chunk[4096]; const auto size = ::read(STDIN_FILENO, chunk, sizeof(chunk));
|
||||||
|
if (size == 0) { application.quit(); return; }
|
||||||
|
if (size < 0) return;
|
||||||
|
buffer.append(chunk, int(size));
|
||||||
|
if (buffer.size() > 65536) { application.exit(2); return; }
|
||||||
|
int end;
|
||||||
|
while ((end = buffer.indexOf('\n')) >= 0) {
|
||||||
|
auto raw = buffer.left(end); buffer.remove(0, end + 1);
|
||||||
|
QJsonParseError error;
|
||||||
|
auto document = QJsonDocument::fromJson(raw, &error);
|
||||||
|
auto request = document.object();
|
||||||
|
QJsonObject response{{"id", request.value("id")}};
|
||||||
|
try {
|
||||||
|
require(error.error == QJsonParseError::NoError && document.isObject(), "Invalid JSON request");
|
||||||
|
response.insert("result", engine.dispatch(request)); response.insert("ok", true);
|
||||||
|
} catch (const ExchangeFailure &e) {
|
||||||
|
response.insert("ok", false); response.insert("error", e.what());
|
||||||
|
response.insert("diagnostics", e.diagnostics);
|
||||||
|
} catch (const std::exception &e) { response.insert("ok", false); response.insert("error", e.what()); }
|
||||||
|
write(response);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return application.exec();
|
||||||
|
} catch (const std::exception &error) {
|
||||||
|
fprintf(stderr, "Native engine startup failed: %s\n", error.what());
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
// Mission Core adapter to the unmodified VESC Tool engine. No hardware transport
|
||||||
|
// is admitted by this executable. Input is one archived snapshot on stdin.
|
||||||
|
#include "config_export.h"
|
||||||
|
|
||||||
|
int main(int argc, char **argv) {
|
||||||
|
// This application never calls connectSerial/connectTcp/connectBle or starts
|
||||||
|
// a Qt event loop. Upstream timers therefore cannot reconnect or poll.
|
||||||
|
qputenv("QT_QPA_PLATFORM", "offscreen");
|
||||||
|
QApplication app(argc, argv);
|
||||||
|
QCoreApplication::setOrganizationName("MissionCore");
|
||||||
|
QCoreApplication::setApplicationName("VescToolOfflineAdapter");
|
||||||
|
QJsonObject result{{"schema", "missioncore.vesc.native-offline/v1"},
|
||||||
|
{"upstream_commit", "01d5f10901116c311e3fb84d5a1541f663d3ce20"},
|
||||||
|
{"upstream_version", QString::number(VT_VERSION, 'f', 2)},
|
||||||
|
{"hardware_access", false}};
|
||||||
|
try {
|
||||||
|
require(argc == 1, "No command-line transport or operation arguments are accepted");
|
||||||
|
QFile input; require(input.open(stdin, QIODevice::ReadOnly), "Cannot read input");
|
||||||
|
const auto raw = input.read(1024 * 1024 + 1);
|
||||||
|
require(!raw.isEmpty() && raw.size() <= 1024 * 1024, "Archive input is empty or too large");
|
||||||
|
QJsonParseError parse;
|
||||||
|
auto document = QJsonDocument::fromJson(raw, &parse);
|
||||||
|
require(parse.error == QJsonParseError::NoError && document.isObject(), "Invalid archive JSON");
|
||||||
|
const auto archive = document.object();
|
||||||
|
const auto identity = archive.value("identity").toObject();
|
||||||
|
FW_RX_PARAMS fw;
|
||||||
|
fw.major = identity.value("major").toInt(-1);
|
||||||
|
fw.minor = identity.value("minor").toInt(-1);
|
||||||
|
fw.hw = identity.value("hardware").toString();
|
||||||
|
fw.isTestFw = identity.value("test_firmware").toInt(-1);
|
||||||
|
fw.customConfigNum = identity.value("custom_configs").toInt(-1);
|
||||||
|
const auto uuid = identity.value("uuid").toString();
|
||||||
|
require(QRegularExpression("^[0-9a-fA-F]{24}$").match(uuid).hasMatch(), "Invalid archived UUID");
|
||||||
|
fw.uuid = QByteArray::fromHex(uuid.toLatin1());
|
||||||
|
require(fw.major == 5 && fw.minor == 2 && fw.isTestFw == 0 && fw.customConfigNum == 0
|
||||||
|
&& identity.value("hardware_type").toInt(-1) == 0,
|
||||||
|
"Offline acceptance currently admits stable firmware 5.02 only");
|
||||||
|
VescInterface vesc;
|
||||||
|
require(Utility::configLoadLatest(&vesc), "Bundled upstream configuration resources missing");
|
||||||
|
auto *commands = vesc.commands();
|
||||||
|
// Replay the archived identity through the real firmware negotiation
|
||||||
|
// signal. The real engine selects bundled schemas and compatibility.
|
||||||
|
commands->fwVersionReceived(fw);
|
||||||
|
require(!vesc.isPortConnected(), "Offline adapter unexpectedly connected");
|
||||||
|
require(vesc.mcConfig()->getSerializeOrder().size() > 0, "Native schema not selected");
|
||||||
|
bool failed = false;
|
||||||
|
QObject::connect(commands, &Commands::deserializeConfigFailed,
|
||||||
|
[&](bool, bool) { failed = true; });
|
||||||
|
auto motor = archivePacket(archive, "motor", COMM_GET_MCCONF);
|
||||||
|
auto application = archivePacket(archive, "application", COMM_GET_APPCONF);
|
||||||
|
// FW 5.02 has fixed-size configurations. Obtain their size through the
|
||||||
|
// public native serializer; do not reproduce the private schema walker.
|
||||||
|
VByteArray motorShape, applicationShape;
|
||||||
|
vesc.mcConfig()->serialize(motorShape);
|
||||||
|
vesc.appConfig()->serialize(applicationShape);
|
||||||
|
require(motor.size() == motorShape.size() + 1 && application.size() == applicationShape.size() + 1,
|
||||||
|
"Archive length differs from the native firmware schema");
|
||||||
|
commands->processPacket(motor);
|
||||||
|
commands->processPacket(application);
|
||||||
|
require(!failed, "Upstream rejected archived configuration");
|
||||||
|
result.insert("motor", exportConfig(vesc.mcConfig(), motor, "MCConfiguration"));
|
||||||
|
result.insert("application", exportConfig(vesc.appConfig(), application, "APPConfiguration"));
|
||||||
|
// A disconnected serializer-only probe demonstrates that FW-specific
|
||||||
|
// detect corrections execute upstream. This byte array goes nowhere.
|
||||||
|
QByteArray encodedDetect;
|
||||||
|
QObject::connect(commands, &Commands::dataToSend, [&](QByteArray &data) {
|
||||||
|
if (!data.isEmpty() && quint8(data.at(0)) == COMM_DETECT_APPLY_ALL_FOC) encodedDetect = data;
|
||||||
|
});
|
||||||
|
commands->detectAllFoc(false, 100.0, 0.0, 0.0, 0.0, 0.0);
|
||||||
|
require(!encodedDetect.isEmpty() && !vesc.isPortConnected(), "Offline serialization failed");
|
||||||
|
result.insert("compatibility", QJsonObject{
|
||||||
|
{"legacy_power_loss_correction", commands->getMaxPowerLossBug()},
|
||||||
|
{"offline_detect_example_base64", QString::fromLatin1(encodedDetect.toBase64())},
|
||||||
|
{"example_requested_power_loss_w", 100.0}, {"transmitted_to_hardware", false}});
|
||||||
|
QJsonArray rpmExamples;
|
||||||
|
QObject::connect(commands, &Commands::dataToSend, [&](QByteArray &data) {
|
||||||
|
if (!data.isEmpty() && quint8(data.at(0)) == COMM_SET_RPM)
|
||||||
|
rpmExamples.append(QString::fromLatin1(data.toBase64()));
|
||||||
|
});
|
||||||
|
commands->setRpm(3000);
|
||||||
|
commands->setRpm(-3000);
|
||||||
|
require(rpmExamples.size() == 2 && !vesc.isPortConnected(), "Offline signed RPM serialization failed");
|
||||||
|
result.insert("offline_signed_rpm_examples_base64", rpmExamples);
|
||||||
|
result.insert("archive_sha256", sha256(raw));
|
||||||
|
result.insert("ok", true);
|
||||||
|
} catch (const std::exception &error) {
|
||||||
|
result = QJsonObject{{"schema", "missioncore.vesc.native-offline/v1"},
|
||||||
|
{"ok", false}, {"hardware_access", false}, {"error", error.what()}};
|
||||||
|
}
|
||||||
|
QFile output; output.open(stdout, QIODevice::WriteOnly);
|
||||||
|
output.write(QJsonDocument(result).toJson(QJsonDocument::Compact) + '\n');
|
||||||
|
return result.value("ok").toBool() ? 0 : 1;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Exact observed candidate descriptor. Firmware identity is verified by the reader.
|
||||||
|
SUBSYSTEM=="tty", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5740", ATTRS{product}=="ChibiOS/RT Virtual COM Port", GROUP="mission-core-vesc", MODE="0660", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Create the deterministic, self-contained native-engine qualification job."""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
|
||||||
|
def build(output):
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
files = {"__main__.py": (root / "packaging/native_probe.py").read_bytes(),
|
||||||
|
"offline_main.cpp": (root / "native/offline_main.cpp").read_bytes(),
|
||||||
|
"config_export.h": (root / "native/config_export.h").read_bytes(),
|
||||||
|
"engine_main.cpp": (root / "native/engine_main.cpp").read_bytes(),
|
||||||
|
"native_bundle.py": (root / "packaging/native_bundle.py").read_bytes()}
|
||||||
|
identity = hashlib.sha256(b"".join(k.encode() + v for k, v in sorted(files.items()))).hexdigest()[:24]
|
||||||
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = output / ("mission-core-vesc-native-probe-" + identity + ".pyz")
|
||||||
|
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as bundle:
|
||||||
|
for name, data in files.items():
|
||||||
|
entry = zipfile.ZipInfo(name, (2026, 9, 23, 0, 0, 0))
|
||||||
|
entry.external_attr = 0o600 << 16
|
||||||
|
bundle.writestr(entry, data)
|
||||||
|
result = {"id": identity, "artifact": str(path.resolve()),
|
||||||
|
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "bytes": path.stat().st_size}
|
||||||
|
(output / "current-artifact.json").write_text(json.dumps(result, indent=2) + "\n")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
print(json.dumps(build(parser.parse_args().output)))
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Discard only generated VESC bytecode before starting an upgraded profile.
|
||||||
|
|
||||||
|
Deterministic packages reuse file mtimes; a same-size Python source update
|
||||||
|
can otherwise validate an old timestamp-based pyc, even with python -B.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def clear(root):
|
||||||
|
for cache in root.rglob("__pycache__"):
|
||||||
|
if cache.is_symlink():
|
||||||
|
cache.unlink()
|
||||||
|
elif cache.is_dir():
|
||||||
|
shutil.rmtree(cache)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if sys.argv[1:]:
|
||||||
|
raise ValueError("This installer step accepts no paths or arguments")
|
||||||
|
clear(Path("/usr/lib/mission-core-vesc"))
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Prepare the versioned VESC read profile
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/bin/python3 -I -B /usr/lib/mission-core-vesc/prepare.py
|
||||||
|
TimeoutStartSec=90
|
||||||
|
UMask=0022
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Mission Core VESC Tool service
|
||||||
|
Wants=modprobe@cdc_acm.service
|
||||||
|
After=systemd-udev-settle.service modprobe@cdc_acm.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=mission-core-vesc
|
||||||
|
Group=mission-core-node
|
||||||
|
SupplementaryGroups=mission-core-vesc
|
||||||
|
WorkingDirectory=/usr/lib/mission-core-vesc
|
||||||
|
ExecStart=/usr/bin/python3 -B -m runtime.server
|
||||||
|
RuntimeDirectory=mission-core-vesc
|
||||||
|
RuntimeDirectoryMode=0750
|
||||||
|
StateDirectory=mission-core-vesc
|
||||||
|
StateDirectoryMode=0700
|
||||||
|
UMask=0007
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
AmbientCapabilities=
|
||||||
|
PrivateNetwork=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
RestrictAddressFamilies=AF_UNIX
|
||||||
|
DevicePolicy=closed
|
||||||
|
DeviceAllow=char-ttyACM rw
|
||||||
|
MemoryMax=512M
|
||||||
|
TasksMax=64
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,676 @@
|
|||||||
|
{
|
||||||
|
"schema": "missioncore.vesc.native-runtime/v1",
|
||||||
|
"upstream_version": "7.00",
|
||||||
|
"upstream_commit": "01d5f10901116c311e3fb84d5a1541f663d3ce20",
|
||||||
|
"os": "ubuntu-24.04-amd64",
|
||||||
|
"file": "mission-core-vesc-native-runtime.tar.gz",
|
||||||
|
"bytes": 50354000,
|
||||||
|
"sha256": "4473819387682089a814a6c558065e3e6a4c2fe67266c0ed82ad49cea1444aad",
|
||||||
|
"engine_sha256": "10cfe14cd0eff561a06f0da33c940861b314de077de489908c90204904e4e590",
|
||||||
|
"files": {
|
||||||
|
"bin/mission-core-vesc-engine": {
|
||||||
|
"sha256": "10cfe14cd0eff561a06f0da33c940861b314de077de489908c90204904e4e590",
|
||||||
|
"bytes": 24801840
|
||||||
|
},
|
||||||
|
"lib/libGL.so.1": {
|
||||||
|
"sha256": "67f471213576d225d38347a0b6d2a08a231980685301ff6461bd74d3994e5027",
|
||||||
|
"bytes": 547136
|
||||||
|
},
|
||||||
|
"lib/libGLX.so.0": {
|
||||||
|
"sha256": "16fc8a37eea9210dc83c57eeff5aedc10ab4c6673f2f97e8bb6ee103df657b40",
|
||||||
|
"bytes": 137792
|
||||||
|
},
|
||||||
|
"lib/libGLdispatch.so.0": {
|
||||||
|
"sha256": "ca01a91104c8887b3d8e59499b58cbb8f604cc285666b50d9ec888eb0c915182",
|
||||||
|
"bytes": 719304
|
||||||
|
},
|
||||||
|
"lib/libQt5Bluetooth.so.5": {
|
||||||
|
"sha256": "7e409c8ba1153801671343a454a3aee4943504f2d7a9d1077c67ff46dc7a9831",
|
||||||
|
"bytes": 915320
|
||||||
|
},
|
||||||
|
"lib/libQt5Core.so.5": {
|
||||||
|
"sha256": "f3a46b3517fdcd82d70b9a2c01ba707499860e2eab328bb98445d8afce59ca37",
|
||||||
|
"bytes": 5699976
|
||||||
|
},
|
||||||
|
"lib/libQt5DBus.so.5": {
|
||||||
|
"sha256": "1122d2c92c03fca880e2e8e7de4eaf15fc577f75012362e8f3a76a38a3ce07ab",
|
||||||
|
"bytes": 595528
|
||||||
|
},
|
||||||
|
"lib/libQt5Gamepad.so.5": {
|
||||||
|
"sha256": "364e27bb1a7dd64ff974ab6442a844beb729006da53912581674c478ac5dc4d7",
|
||||||
|
"bytes": 130144
|
||||||
|
},
|
||||||
|
"lib/libQt5Gui.so.5": {
|
||||||
|
"sha256": "75b66a3edcbc7f3013b4180b76919b49c22ce652372eba45ead8aac9121625e0",
|
||||||
|
"bytes": 7256408
|
||||||
|
},
|
||||||
|
"lib/libQt5Network.so.5": {
|
||||||
|
"sha256": "8b3062321c31ddb2c0d96f2c7a7761e086fc4ef2d0d52dbf1119a03d0079ec64",
|
||||||
|
"bytes": 1749216
|
||||||
|
},
|
||||||
|
"lib/libQt5Positioning.so.5": {
|
||||||
|
"sha256": "267929ff49f2ecfbfed7951f3a2cd50985dc2d96f1d8f5ca689615bb6ee5f2b6",
|
||||||
|
"bytes": 590448
|
||||||
|
},
|
||||||
|
"lib/libQt5PrintSupport.so.5": {
|
||||||
|
"sha256": "818b07d6b46ede7e935f97c523231c97b79b44004723b8c1a17e40739337767d",
|
||||||
|
"bytes": 486696
|
||||||
|
},
|
||||||
|
"lib/libQt5Qml.so.5": {
|
||||||
|
"sha256": "e57364d9d0d366824064539a49f4cb3d8fdd284a15db9e3d164ca1ac9a1f64f3",
|
||||||
|
"bytes": 4729856
|
||||||
|
},
|
||||||
|
"lib/libQt5QmlModels.so.5": {
|
||||||
|
"sha256": "68bebbb6db95909aa2262aa2fa6cfd23547565faaa5bb72c49cfe027ac9caf0b",
|
||||||
|
"bytes": 567600
|
||||||
|
},
|
||||||
|
"lib/libQt5Quick.so.5": {
|
||||||
|
"sha256": "a2d2e45f415baa2c9a251844dd45f67d1d10cf6f15c6270dd49c0d348a431260",
|
||||||
|
"bytes": 5737928
|
||||||
|
},
|
||||||
|
"lib/libQt5QuickWidgets.so.5": {
|
||||||
|
"sha256": "8e3947729ac17448ed59a0a0a74960d9f09244a2d084c976b1c4af342f87d82e",
|
||||||
|
"bytes": 92384
|
||||||
|
},
|
||||||
|
"lib/libQt5SerialPort.so.5": {
|
||||||
|
"sha256": "67305251921c0a1d737b90d72b365cc937ca665c6ee218575e2555e85c63dcdb",
|
||||||
|
"bytes": 97680
|
||||||
|
},
|
||||||
|
"lib/libQt5Widgets.so.5": {
|
||||||
|
"sha256": "8e03815f581781fc8b0ef931335493f8f2fcb48ee855f0be2258bbfbd06e62ee",
|
||||||
|
"bytes": 7057536
|
||||||
|
},
|
||||||
|
"lib/libX11.so.6": {
|
||||||
|
"sha256": "c5b5d782bd9cab3420a62df88f5c991507edf3331a89f98464ddbc538c37b879",
|
||||||
|
"bytes": 1298088
|
||||||
|
},
|
||||||
|
"lib/libXau.so.6": {
|
||||||
|
"sha256": "8040da3f8516c1acfe39f04ff022480cdb273705ba5e7a4ca70bbdb1527cd67a",
|
||||||
|
"bytes": 18696
|
||||||
|
},
|
||||||
|
"lib/libXdmcp.so.6": {
|
||||||
|
"sha256": "667d97d6da16016400ab10de9f83ef4ab209ceb6adf68ff9650225ec13e723b0",
|
||||||
|
"bytes": 26776
|
||||||
|
},
|
||||||
|
"lib/libbrotlicommon.so.1": {
|
||||||
|
"sha256": "a91ead095d2c80520c55a89057bbe10b031a075340442e63f44b310f93883a1b",
|
||||||
|
"bytes": 141640
|
||||||
|
},
|
||||||
|
"lib/libbrotlidec.so.1": {
|
||||||
|
"sha256": "64d8a5019d4c294b89fde1193343ea324bbd8603652554e5545f0a01595fa2c5",
|
||||||
|
"bytes": 51512
|
||||||
|
},
|
||||||
|
"lib/libbsd.so.0": {
|
||||||
|
"sha256": "e86cd4f0019f42c2ba5e60602e0edc8694d2948377d98c3654d0f6bddb5254bb",
|
||||||
|
"bytes": 80888
|
||||||
|
},
|
||||||
|
"lib/libbz2.so.1.0": {
|
||||||
|
"sha256": "cc08c9f50a8009ffd6391e0116a100369b11ca9238fa52392c019e6645b122a1",
|
||||||
|
"bytes": 78944
|
||||||
|
},
|
||||||
|
"lib/libcap.so.2": {
|
||||||
|
"sha256": "6ac6abc86ac891c6e13486470e26f1d939f47fda9e6b5d5508a7f5ec881adc84",
|
||||||
|
"bytes": 51536
|
||||||
|
},
|
||||||
|
"lib/libcom_err.so.2": {
|
||||||
|
"sha256": "022943b3b11c860b049bce41342f1c2594941b7b401d95dfdf235521099fee08",
|
||||||
|
"bytes": 18504
|
||||||
|
},
|
||||||
|
"lib/libdbus-1.so.3": {
|
||||||
|
"sha256": "a6ae7b4ef48562b40d7b9ba8efd2e49f6528b7cc6364bea382dcee4b473b5413",
|
||||||
|
"bytes": 317752
|
||||||
|
},
|
||||||
|
"lib/libdouble-conversion.so.3": {
|
||||||
|
"sha256": "d1c9583dc7c1fce6f0a0701dd4356448425e45afe15c0946c23edeaf93d9397c",
|
||||||
|
"bytes": 79952
|
||||||
|
},
|
||||||
|
"lib/libexpat.so.1": {
|
||||||
|
"sha256": "ec6c12d33bb8f9d0e90804121adf19930f36b1b2a4aeb6e1a454b89c7a50c801",
|
||||||
|
"bytes": 186624
|
||||||
|
},
|
||||||
|
"lib/libfontconfig.so.1": {
|
||||||
|
"sha256": "a94b4059b27766f563894c8f7e61762b6f6b2e25c59ef36b730164d4b75c6c98",
|
||||||
|
"bytes": 325712
|
||||||
|
},
|
||||||
|
"lib/libfreetype.so.6": {
|
||||||
|
"sha256": "c14c53c5baff12afafb610c6312fb879e9bb77e80dd42e27504d52d6d8bcd059",
|
||||||
|
"bytes": 833608
|
||||||
|
},
|
||||||
|
"lib/libgcc_s.so.1": {
|
||||||
|
"sha256": "d93224d2b0dab4247598be683adca02f5cf00586f99c187579cd7e92058fb7cb",
|
||||||
|
"bytes": 183024
|
||||||
|
},
|
||||||
|
"lib/libgcrypt.so.20": {
|
||||||
|
"sha256": "6ad6d7007ee1ad8319eb18ba9a512cf09dda49396a303774c68b47477020bad4",
|
||||||
|
"bytes": 1345072
|
||||||
|
},
|
||||||
|
"lib/libglib-2.0.so.0": {
|
||||||
|
"sha256": "96ef9163aee942bdc09e6f4a1acd2fd6b178c03af824c569741440d63ac9f4f4",
|
||||||
|
"bytes": 1343056
|
||||||
|
},
|
||||||
|
"lib/libgpg-error.so.0": {
|
||||||
|
"sha256": "6cb18a007bfcb623029f4528a36b46578fea7dc34c2b505b8e6e0d99e6348cd1",
|
||||||
|
"bytes": 149760
|
||||||
|
},
|
||||||
|
"lib/libgraphite2.so.3": {
|
||||||
|
"sha256": "fcfaf843b25b58b88319ced52f826fc8213a661f3693c916f88641eddffe05b8",
|
||||||
|
"bytes": 149776
|
||||||
|
},
|
||||||
|
"lib/libgssapi_krb5.so.2": {
|
||||||
|
"sha256": "6c1b81696044d79a47d6f0f494ee60aaf641c525c46a11da0c6e4a041a782d3c",
|
||||||
|
"bytes": 338696
|
||||||
|
},
|
||||||
|
"lib/libharfbuzz.so.0": {
|
||||||
|
"sha256": "4562cfcfd18935324ba3ac74a944898b867ab87a56e925ab3e351ba773f1513e",
|
||||||
|
"bytes": 1101752
|
||||||
|
},
|
||||||
|
"lib/libicudata.so.74": {
|
||||||
|
"sha256": "ddbb3718b8bd9cbd780e5ab08b4503c30a6c4fa0706ebe5d074ed6b596c1714e",
|
||||||
|
"bytes": 30795392
|
||||||
|
},
|
||||||
|
"lib/libicui18n.so.74": {
|
||||||
|
"sha256": "3550b194eb2cf2e6f798f033eb9ca279d498c21296b4a18790ce158d2023e47b",
|
||||||
|
"bytes": 3455304
|
||||||
|
},
|
||||||
|
"lib/libicuuc.so.74": {
|
||||||
|
"sha256": "7560aadde38e5f4237a47a1ddd5891f9b36768a77a60faae30beee003ac01901",
|
||||||
|
"bytes": 2140336
|
||||||
|
},
|
||||||
|
"lib/libk5crypto.so.3": {
|
||||||
|
"sha256": "73bc9d72c0c684d6149a3c38f96aab891d178375415c05954fdda587685936f5",
|
||||||
|
"bytes": 178648
|
||||||
|
},
|
||||||
|
"lib/libkeyutils.so.1": {
|
||||||
|
"sha256": "f48214417757f18793ed6e180cc14ee1d6f04252a518fc7270e8ca1d0b4260fe",
|
||||||
|
"bytes": 22600
|
||||||
|
},
|
||||||
|
"lib/libkrb5.so.3": {
|
||||||
|
"sha256": "9615a2841f0783c410eec7fae005a951282551cfedd377737ad8431c8c8cde64",
|
||||||
|
"bytes": 823488
|
||||||
|
},
|
||||||
|
"lib/libkrb5support.so.0": {
|
||||||
|
"sha256": "0aa43578471faecbd642ed2ee6ab92b6682a2b676644193e956fc59d18ca24bb",
|
||||||
|
"bytes": 47904
|
||||||
|
},
|
||||||
|
"lib/liblz4.so.1": {
|
||||||
|
"sha256": "40bffd0a098387368b16b992abd5f7cf43c0fa2f05cabe5a6d483719554adfda",
|
||||||
|
"bytes": 137440
|
||||||
|
},
|
||||||
|
"lib/liblzma.so.5": {
|
||||||
|
"sha256": "696e868dd0700a19a6d65fc01608ec2d70d3cb91f65710e89180cd2e688f30cb",
|
||||||
|
"bytes": 202904
|
||||||
|
},
|
||||||
|
"lib/libmd.so.0": {
|
||||||
|
"sha256": "423e18586b6ea740f4465afd64f7a9a4cb7264ed979c8e7256f9085af5345fef",
|
||||||
|
"bytes": 55536
|
||||||
|
},
|
||||||
|
"lib/libmd4c.so.0": {
|
||||||
|
"sha256": "d5f418d0ea9aec6b41efbb924580a1fb61e9113bcae36d5ad3f322269822d1b3",
|
||||||
|
"bytes": 67656
|
||||||
|
},
|
||||||
|
"lib/libpcre2-16.so.0": {
|
||||||
|
"sha256": "4dfa8a4023270763b8ca1654dc4f59f835393b1ae78085a19369228f8109a671",
|
||||||
|
"bytes": 572064
|
||||||
|
},
|
||||||
|
"lib/libpcre2-8.so.0": {
|
||||||
|
"sha256": "e00576d71d81d3ba0cfa4903c835a44a8723aac96f72f79ff75200b4cff9071b",
|
||||||
|
"bytes": 625344
|
||||||
|
},
|
||||||
|
"lib/libpng16.so.16": {
|
||||||
|
"sha256": "eac265b3506df0d9110dd9143e1d0503e2daabafe1d98b23fac8ca17b71d1f6b",
|
||||||
|
"bytes": 223304
|
||||||
|
},
|
||||||
|
"lib/libstdc++.so.6": {
|
||||||
|
"sha256": "1fd75fe70354a416d75aef22bcae68c47bd25d20e2d0568c30b1a9838cf62f11",
|
||||||
|
"bytes": 2592224
|
||||||
|
},
|
||||||
|
"lib/libsystemd.so.0": {
|
||||||
|
"sha256": "bdf59c828b547bcdbe7b3576c0810d9d9d2e982d8b61b365812542da6a4c4a99",
|
||||||
|
"bytes": 910592
|
||||||
|
},
|
||||||
|
"lib/libudev.so.1": {
|
||||||
|
"sha256": "4298228175fa62a36af88b1afd4406cdd8b1bf166621f957d0dbbde45a698fe5",
|
||||||
|
"bytes": 207288
|
||||||
|
},
|
||||||
|
"lib/libxcb.so.1": {
|
||||||
|
"sha256": "7958a0136b121bdc4c708968569ad152a9ed208ab026e2537b1005dde64ca440",
|
||||||
|
"bytes": 162392
|
||||||
|
},
|
||||||
|
"lib/libz.so.1": {
|
||||||
|
"sha256": "86200da370f20476a2507e9097a789b5ef97269b4ca8d5e164ad82dab9d99892",
|
||||||
|
"bytes": 113000
|
||||||
|
},
|
||||||
|
"lib/libzstd.so.1": {
|
||||||
|
"sha256": "0a2128bc10841fb29e76d08d945864dfb0b6a66da5df6df5d8299197439e54bb",
|
||||||
|
"bytes": 755864
|
||||||
|
},
|
||||||
|
"licenses/UPSTREAM-SOURCE.txt": {
|
||||||
|
"sha256": "ff2fbc975e47bffd1e3c488e14f1fbe417c83db82fb5ab41e16a9c1da96edb2a",
|
||||||
|
"bytes": 293
|
||||||
|
},
|
||||||
|
"licenses/VESC-Tool-LICENSE": {
|
||||||
|
"sha256": "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986",
|
||||||
|
"bytes": 35149
|
||||||
|
},
|
||||||
|
"licenses/config_export.h": {
|
||||||
|
"sha256": "f0c483aa27b9910bab1bff4f7baf66fe4077461e65a1d3e11169c4a722212756",
|
||||||
|
"bytes": 4724
|
||||||
|
},
|
||||||
|
"licenses/engine_main.cpp": {
|
||||||
|
"sha256": "e4b32dc5c9def71208d8daad830d5c47852f39f85a321713abc45dc15173b4fc",
|
||||||
|
"bytes": 21539
|
||||||
|
},
|
||||||
|
"licenses/gir1.2-glib-2.0.copyright": {
|
||||||
|
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||||
|
"bytes": 54957
|
||||||
|
},
|
||||||
|
"licenses/gir1.2-gudev-1.0.copyright": {
|
||||||
|
"sha256": "e8208610819fd05fca6ea4776156858bb934e29c408ae9bdac4a8266a53178e3",
|
||||||
|
"bytes": 1056
|
||||||
|
},
|
||||||
|
"licenses/libblkid-dev.copyright": {
|
||||||
|
"sha256": "3492fb92cb56cf517d9a899828b4f989038421a38736e49e5525ffbd33196eab",
|
||||||
|
"bytes": 23160
|
||||||
|
},
|
||||||
|
"licenses/libbrotli-dev.copyright": {
|
||||||
|
"sha256": "24a64e5bb83d0960d1835696a1e23c0896ad6055b0ca47c66ab0eb9a766324b1",
|
||||||
|
"bytes": 1354
|
||||||
|
},
|
||||||
|
"licenses/libdouble-conversion3.copyright": {
|
||||||
|
"sha256": "1cc0b36cdfe5a674e11cb9907a88291c7602d3805bddd334cc07d57231a0cd00",
|
||||||
|
"bytes": 1999
|
||||||
|
},
|
||||||
|
"licenses/libegl-dev.copyright": {
|
||||||
|
"sha256": "37920f219f60efa2fde5d003c51be0608b2159cc74c5eb6d70d630907db2d9a3",
|
||||||
|
"bytes": 4422
|
||||||
|
},
|
||||||
|
"licenses/libevdev-dev.copyright": {
|
||||||
|
"sha256": "01d0da4919f92dd06e7ec73fee6cf5dccac3c6e475917b9dc57cb22800b732de",
|
||||||
|
"bytes": 5460
|
||||||
|
},
|
||||||
|
"licenses/libexpat1-dev.copyright": {
|
||||||
|
"sha256": "60919fe1a156395ff14511bb6ff79756c50ade2fff59ac60c9bae1d8a7fe6292",
|
||||||
|
"bytes": 1756
|
||||||
|
},
|
||||||
|
"licenses/libexpat1.copyright": {
|
||||||
|
"sha256": "60919fe1a156395ff14511bb6ff79756c50ade2fff59ac60c9bae1d8a7fe6292",
|
||||||
|
"bytes": 1756
|
||||||
|
},
|
||||||
|
"licenses/libfontconfig-dev.copyright": {
|
||||||
|
"sha256": "b215a61cdd3e62b5b17cc28b1852c78acb3dd38be0fb30706f7efc050dba91db",
|
||||||
|
"bytes": 1301
|
||||||
|
},
|
||||||
|
"licenses/libfreetype-dev.copyright": {
|
||||||
|
"sha256": "ce6d766883ea111e7f47acc09e9d49be8827daa6a03f2c2707243a425d41f0e9",
|
||||||
|
"bytes": 31209
|
||||||
|
},
|
||||||
|
"licenses/libgirepository-2.0-0.copyright": {
|
||||||
|
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||||
|
"bytes": 54957
|
||||||
|
},
|
||||||
|
"licenses/libgl-dev.copyright": {
|
||||||
|
"sha256": "37920f219f60efa2fde5d003c51be0608b2159cc74c5eb6d70d630907db2d9a3",
|
||||||
|
"bytes": 4422
|
||||||
|
},
|
||||||
|
"licenses/libglib2.0-0t64.copyright": {
|
||||||
|
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||||
|
"bytes": 54957
|
||||||
|
},
|
||||||
|
"licenses/libglib2.0-bin.copyright": {
|
||||||
|
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||||
|
"bytes": 54957
|
||||||
|
},
|
||||||
|
"licenses/libglib2.0-data.copyright": {
|
||||||
|
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||||
|
"bytes": 54957
|
||||||
|
},
|
||||||
|
"licenses/libglib2.0-dev-bin.copyright": {
|
||||||
|
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||||
|
"bytes": 54957
|
||||||
|
},
|
||||||
|
"licenses/libglib2.0-dev.copyright": {
|
||||||
|
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||||
|
"bytes": 54957
|
||||||
|
},
|
||||||
|
"licenses/libglu1-mesa-dev.copyright": {
|
||||||
|
"sha256": "7802232600641c113e2948fbc2feae6de45f26af59a43c28836e0ef846f8dd94",
|
||||||
|
"bytes": 4055
|
||||||
|
},
|
||||||
|
"licenses/libglx-dev.copyright": {
|
||||||
|
"sha256": "37920f219f60efa2fde5d003c51be0608b2159cc74c5eb6d70d630907db2d9a3",
|
||||||
|
"bytes": 4422
|
||||||
|
},
|
||||||
|
"licenses/libgudev-1.0-dev.copyright": {
|
||||||
|
"sha256": "e8208610819fd05fca6ea4776156858bb934e29c408ae9bdac4a8266a53178e3",
|
||||||
|
"bytes": 1056
|
||||||
|
},
|
||||||
|
"licenses/libinput-bin.copyright": {
|
||||||
|
"sha256": "f075af6e7319a471289bc1908aae04e44ba954ea53b11fcf42ee8d5422f40363",
|
||||||
|
"bytes": 2292
|
||||||
|
},
|
||||||
|
"licenses/libinput-dev.copyright": {
|
||||||
|
"sha256": "f075af6e7319a471289bc1908aae04e44ba954ea53b11fcf42ee8d5422f40363",
|
||||||
|
"bytes": 2292
|
||||||
|
},
|
||||||
|
"licenses/libinput10.copyright": {
|
||||||
|
"sha256": "f075af6e7319a471289bc1908aae04e44ba954ea53b11fcf42ee8d5422f40363",
|
||||||
|
"bytes": 2292
|
||||||
|
},
|
||||||
|
"licenses/libmd4c0.copyright": {
|
||||||
|
"sha256": "68e5ce452a6fc2bee44279ca61a7064950d146481e325eeaece6c9bea095fd2f",
|
||||||
|
"bytes": 23273
|
||||||
|
},
|
||||||
|
"licenses/libmount-dev.copyright": {
|
||||||
|
"sha256": "3492fb92cb56cf517d9a899828b4f989038421a38736e49e5525ffbd33196eab",
|
||||||
|
"bytes": 23160
|
||||||
|
},
|
||||||
|
"licenses/libmtdev-dev.copyright": {
|
||||||
|
"sha256": "7ca89f7e6e0ab15b9941aa80def7c50b22a7bd6356dd1eacf28ca4382f72a6de",
|
||||||
|
"bytes": 1628
|
||||||
|
},
|
||||||
|
"licenses/libopengl-dev.copyright": {
|
||||||
|
"sha256": "37920f219f60efa2fde5d003c51be0608b2159cc74c5eb6d70d630907db2d9a3",
|
||||||
|
"bytes": 4422
|
||||||
|
},
|
||||||
|
"licenses/libpcre2-16-0.copyright": {
|
||||||
|
"sha256": "030511beb4d9d620ad09914c369c36ec0528dcf301d1923cc643c948ee7c6a38",
|
||||||
|
"bytes": 6626
|
||||||
|
},
|
||||||
|
"licenses/libpcre2-dev.copyright": {
|
||||||
|
"sha256": "030511beb4d9d620ad09914c369c36ec0528dcf301d1923cc643c948ee7c6a38",
|
||||||
|
"bytes": 6626
|
||||||
|
},
|
||||||
|
"licenses/libpcre2-posix3.copyright": {
|
||||||
|
"sha256": "030511beb4d9d620ad09914c369c36ec0528dcf301d1923cc643c948ee7c6a38",
|
||||||
|
"bytes": 6626
|
||||||
|
},
|
||||||
|
"licenses/libpkgconf3.copyright": {
|
||||||
|
"sha256": "676c75e54ff7e3b9892940fbf1b31763cb6f2a6527a5607a3640ac59a2441fba",
|
||||||
|
"bytes": 7501
|
||||||
|
},
|
||||||
|
"licenses/libpng-dev.copyright": {
|
||||||
|
"sha256": "4620d402b97601a910946acccbbe2e15bdffac11615bf9046520990d3b00f2b9",
|
||||||
|
"bytes": 13051
|
||||||
|
},
|
||||||
|
"licenses/libpthread-stubs0-dev.copyright": {
|
||||||
|
"sha256": "e45b85577d0f6883300ccfb004ab79e1a4f2cf3777b64eb989525115af400b5a",
|
||||||
|
"bytes": 1849
|
||||||
|
},
|
||||||
|
"licenses/libqt5bluetooth5-bin.copyright": {
|
||||||
|
"sha256": "937c1479a92f32fbd29957b9339b35e9edb7c85be3be6543c8c7495ea799933e",
|
||||||
|
"bytes": 7982
|
||||||
|
},
|
||||||
|
"licenses/libqt5bluetooth5.copyright": {
|
||||||
|
"sha256": "937c1479a92f32fbd29957b9339b35e9edb7c85be3be6543c8c7495ea799933e",
|
||||||
|
"bytes": 7982
|
||||||
|
},
|
||||||
|
"licenses/libqt5concurrent5t64.copyright": {
|
||||||
|
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||||
|
"bytes": 131975
|
||||||
|
},
|
||||||
|
"licenses/libqt5core5t64.copyright": {
|
||||||
|
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||||
|
"bytes": 131975
|
||||||
|
},
|
||||||
|
"licenses/libqt5dbus5t64.copyright": {
|
||||||
|
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||||
|
"bytes": 131975
|
||||||
|
},
|
||||||
|
"licenses/libqt5gamepad5-dev.copyright": {
|
||||||
|
"sha256": "eb1b57f89c6b2c7e25472e1e59c796ed6bc7ff5c5eb08845a95fe9653f781c5b",
|
||||||
|
"bytes": 3446
|
||||||
|
},
|
||||||
|
"licenses/libqt5gamepad5.copyright": {
|
||||||
|
"sha256": "eb1b57f89c6b2c7e25472e1e59c796ed6bc7ff5c5eb08845a95fe9653f781c5b",
|
||||||
|
"bytes": 3446
|
||||||
|
},
|
||||||
|
"licenses/libqt5gui5t64.copyright": {
|
||||||
|
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||||
|
"bytes": 131975
|
||||||
|
},
|
||||||
|
"licenses/libqt5network5t64.copyright": {
|
||||||
|
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||||
|
"bytes": 131975
|
||||||
|
},
|
||||||
|
"licenses/libqt5nfc5.copyright": {
|
||||||
|
"sha256": "937c1479a92f32fbd29957b9339b35e9edb7c85be3be6543c8c7495ea799933e",
|
||||||
|
"bytes": 7982
|
||||||
|
},
|
||||||
|
"licenses/libqt5positioning5-plugins.copyright": {
|
||||||
|
"sha256": "2d54a4f412d1a2c1f5138b06a94a6cdd445e267227fb0526fc376ab2e7d9b63f",
|
||||||
|
"bytes": 18782
|
||||||
|
},
|
||||||
|
"licenses/libqt5positioning5.copyright": {
|
||||||
|
"sha256": "2d54a4f412d1a2c1f5138b06a94a6cdd445e267227fb0526fc376ab2e7d9b63f",
|
||||||
|
"bytes": 18782
|
||||||
|
},
|
||||||
|
"licenses/libqt5positioningquick5.copyright": {
|
||||||
|
"sha256": "2d54a4f412d1a2c1f5138b06a94a6cdd445e267227fb0526fc376ab2e7d9b63f",
|
||||||
|
"bytes": 18782
|
||||||
|
},
|
||||||
|
"licenses/libqt5printsupport5t64.copyright": {
|
||||||
|
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||||
|
"bytes": 131975
|
||||||
|
},
|
||||||
|
"licenses/libqt5qml5.copyright": {
|
||||||
|
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||||
|
"bytes": 44601
|
||||||
|
},
|
||||||
|
"licenses/libqt5qmlmodels5.copyright": {
|
||||||
|
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||||
|
"bytes": 44601
|
||||||
|
},
|
||||||
|
"licenses/libqt5qmlworkerscript5.copyright": {
|
||||||
|
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||||
|
"bytes": 44601
|
||||||
|
},
|
||||||
|
"licenses/libqt5quick5.copyright": {
|
||||||
|
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||||
|
"bytes": 44601
|
||||||
|
},
|
||||||
|
"licenses/libqt5quickcontrols2-5.copyright": {
|
||||||
|
"sha256": "334854f9488a33d1211ded54478a35071369a4addaf86d2581820025b244ae03",
|
||||||
|
"bytes": 10979
|
||||||
|
},
|
||||||
|
"licenses/libqt5quickparticles5.copyright": {
|
||||||
|
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||||
|
"bytes": 44601
|
||||||
|
},
|
||||||
|
"licenses/libqt5quickshapes5.copyright": {
|
||||||
|
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||||
|
"bytes": 44601
|
||||||
|
},
|
||||||
|
"licenses/libqt5quicktemplates2-5.copyright": {
|
||||||
|
"sha256": "334854f9488a33d1211ded54478a35071369a4addaf86d2581820025b244ae03",
|
||||||
|
"bytes": 10979
|
||||||
|
},
|
||||||
|
"licenses/libqt5quicktest5.copyright": {
|
||||||
|
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||||
|
"bytes": 44601
|
||||||
|
},
|
||||||
|
"licenses/libqt5quickwidgets5.copyright": {
|
||||||
|
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||||
|
"bytes": 44601
|
||||||
|
},
|
||||||
|
"licenses/libqt5serialport5-dev.copyright": {
|
||||||
|
"sha256": "679e7d434baaf5b3b80e3f7764d6e57a7a971f11693f8d0420f1de5dc7696a6b",
|
||||||
|
"bytes": 6220
|
||||||
|
},
|
||||||
|
"licenses/libqt5serialport5.copyright": {
|
||||||
|
"sha256": "679e7d434baaf5b3b80e3f7764d6e57a7a971f11693f8d0420f1de5dc7696a6b",
|
||||||
|
"bytes": 6220
|
||||||
|
},
|
||||||
|
"licenses/libqt5sql5t64.copyright": {
|
||||||
|
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||||
|
"bytes": 131975
|
||||||
|
},
|
||||||
|
"licenses/libqt5svg5-dev.copyright": {
|
||||||
|
"sha256": "71bc825205f218db925d601e2c4cee8f1cdd36ae07ece4e63736bfb0afe0453f",
|
||||||
|
"bytes": 8476
|
||||||
|
},
|
||||||
|
"licenses/libqt5svg5.copyright": {
|
||||||
|
"sha256": "71bc825205f218db925d601e2c4cee8f1cdd36ae07ece4e63736bfb0afe0453f",
|
||||||
|
"bytes": 8476
|
||||||
|
},
|
||||||
|
"licenses/libqt5test5t64.copyright": {
|
||||||
|
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||||
|
"bytes": 131975
|
||||||
|
},
|
||||||
|
"licenses/libqt5widgets5t64.copyright": {
|
||||||
|
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||||
|
"bytes": 131975
|
||||||
|
},
|
||||||
|
"licenses/libqt5xml5t64.copyright": {
|
||||||
|
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||||
|
"bytes": 131975
|
||||||
|
},
|
||||||
|
"licenses/libselinux1-dev.copyright": {
|
||||||
|
"sha256": "864f1bb189f609075d580b8c4aada16d85a44546884f1687989e1cacc8c56751",
|
||||||
|
"bytes": 1957
|
||||||
|
},
|
||||||
|
"licenses/libsepol-dev.copyright": {
|
||||||
|
"sha256": "78d2a34606a0302057ec499a3b3c07bcbc43ca334064ce4c80891a0f69c8f6c6",
|
||||||
|
"bytes": 3750
|
||||||
|
},
|
||||||
|
"licenses/libudev-dev.copyright": {
|
||||||
|
"sha256": "a7d06854714a1ca99f6dbd1a1641dde5bcf28635be149f6554449618f8f427f3",
|
||||||
|
"bytes": 12776
|
||||||
|
},
|
||||||
|
"licenses/libvulkan-dev.copyright": {
|
||||||
|
"sha256": "c579213e28f67944a7e407816b8a8e1d2d2406b3d820e420aa923807c450dc07",
|
||||||
|
"bytes": 1964
|
||||||
|
},
|
||||||
|
"licenses/libwacom-dev.copyright": {
|
||||||
|
"sha256": "5026eb61394922e821cfea069fe9740141b1d6abd3ea900e79655ad45ea1cb8a",
|
||||||
|
"bytes": 1624
|
||||||
|
},
|
||||||
|
"licenses/libx11-dev.copyright": {
|
||||||
|
"sha256": "0b380a7fd5b2228f26e9585e56f14812efd3350f3df307507d2bc055dfd8de3e",
|
||||||
|
"bytes": 47102
|
||||||
|
},
|
||||||
|
"licenses/libxau-dev.copyright": {
|
||||||
|
"sha256": "118dd263a7b91c8f21c489f949bf13281dff9e766deea92b829dac4dce66601a",
|
||||||
|
"bytes": 1224
|
||||||
|
},
|
||||||
|
"licenses/libxcb-xinerama0.copyright": {
|
||||||
|
"sha256": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80",
|
||||||
|
"bytes": 1781
|
||||||
|
},
|
||||||
|
"licenses/libxcb-xinput0.copyright": {
|
||||||
|
"sha256": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80",
|
||||||
|
"bytes": 1781
|
||||||
|
},
|
||||||
|
"licenses/libxcb1-dev.copyright": {
|
||||||
|
"sha256": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80",
|
||||||
|
"bytes": 1781
|
||||||
|
},
|
||||||
|
"licenses/libxdmcp-dev.copyright": {
|
||||||
|
"sha256": "1bcbb50f8603fe8b86d330bfa460b772ea04fbd3c70e4499f0768b5340b9fd6e",
|
||||||
|
"bytes": 1265
|
||||||
|
},
|
||||||
|
"licenses/libxext-dev.copyright": {
|
||||||
|
"sha256": "bc57e445ca1d9fe082c8d54189dd411ff26caa8552c9c63d44ea06a982f32124",
|
||||||
|
"bytes": 10421
|
||||||
|
},
|
||||||
|
"licenses/libxkbcommon-dev.copyright": {
|
||||||
|
"sha256": "5eeaeb1b6e029a0274e1573765bb0bae2926ef96a3679203faa4fd00fdaeaa88",
|
||||||
|
"bytes": 3566
|
||||||
|
},
|
||||||
|
"licenses/pkgconf-bin.copyright": {
|
||||||
|
"sha256": "676c75e54ff7e3b9892940fbf1b31763cb6f2a6527a5607a3640ac59a2441fba",
|
||||||
|
"bytes": 7501
|
||||||
|
},
|
||||||
|
"licenses/pkgconf.copyright": {
|
||||||
|
"sha256": "676c75e54ff7e3b9892940fbf1b31763cb6f2a6527a5607a3640ac59a2441fba",
|
||||||
|
"bytes": 7501
|
||||||
|
},
|
||||||
|
"licenses/python3-packaging.copyright": {
|
||||||
|
"sha256": "51fe4bbadf841c4e4d02ad97ba375bcde0ae11a51da62ae16fdcbe723d3cdad2",
|
||||||
|
"bytes": 2444
|
||||||
|
},
|
||||||
|
"licenses/qt5-qmake-bin.copyright": {
|
||||||
|
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||||
|
"bytes": 131975
|
||||||
|
},
|
||||||
|
"licenses/qt5-qmake.copyright": {
|
||||||
|
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||||
|
"bytes": 131975
|
||||||
|
},
|
||||||
|
"licenses/qt5-qmltooling-plugins.copyright": {
|
||||||
|
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||||
|
"bytes": 44601
|
||||||
|
},
|
||||||
|
"licenses/qtbase5-dev-tools.copyright": {
|
||||||
|
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||||
|
"bytes": 131975
|
||||||
|
},
|
||||||
|
"licenses/qtbase5-dev.copyright": {
|
||||||
|
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||||
|
"bytes": 131975
|
||||||
|
},
|
||||||
|
"licenses/qtbase5-private-dev.copyright": {
|
||||||
|
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||||
|
"bytes": 131975
|
||||||
|
},
|
||||||
|
"licenses/qtchooser.copyright": {
|
||||||
|
"sha256": "0b3fa692b33acfbb5b9539335c66938c957350b2a24d2fec35de89e09198738e",
|
||||||
|
"bytes": 5193
|
||||||
|
},
|
||||||
|
"licenses/qtconnectivity5-dev.copyright": {
|
||||||
|
"sha256": "937c1479a92f32fbd29957b9339b35e9edb7c85be3be6543c8c7495ea799933e",
|
||||||
|
"bytes": 7982
|
||||||
|
},
|
||||||
|
"licenses/qtdeclarative5-dev-tools.copyright": {
|
||||||
|
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||||
|
"bytes": 44601
|
||||||
|
},
|
||||||
|
"licenses/qtdeclarative5-dev.copyright": {
|
||||||
|
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||||
|
"bytes": 44601
|
||||||
|
},
|
||||||
|
"licenses/qtpositioning5-dev.copyright": {
|
||||||
|
"sha256": "2d54a4f412d1a2c1f5138b06a94a6cdd445e267227fb0526fc376ab2e7d9b63f",
|
||||||
|
"bytes": 18782
|
||||||
|
},
|
||||||
|
"licenses/qtquickcontrols2-5-dev.copyright": {
|
||||||
|
"sha256": "334854f9488a33d1211ded54478a35071369a4addaf86d2581820025b244ae03",
|
||||||
|
"bytes": 10979
|
||||||
|
},
|
||||||
|
"licenses/uuid-dev.copyright": {
|
||||||
|
"sha256": "3492fb92cb56cf517d9a899828b4f989038421a38736e49e5525ffbd33196eab",
|
||||||
|
"bytes": 23160
|
||||||
|
},
|
||||||
|
"licenses/x11proto-dev.copyright": {
|
||||||
|
"sha256": "7b40446cf2035abc6836c7a7f411ec79153dbf4530bce209e26aa2fd7c4dd55a",
|
||||||
|
"bytes": 3963
|
||||||
|
},
|
||||||
|
"licenses/xorg-sgml-doctools.copyright": {
|
||||||
|
"sha256": "f8f02d5cfd7d4ed0eb6c46deacb3a64c1fa5bc60e06db10ecbf202e6fe1d5a89",
|
||||||
|
"bytes": 2271
|
||||||
|
},
|
||||||
|
"licenses/xtrans-dev.copyright": {
|
||||||
|
"sha256": "29e6f06b1dcd85f1bc4b3e9374b92967cb2a274abde00169784f1dd1c7c95431",
|
||||||
|
"bytes": 6364
|
||||||
|
},
|
||||||
|
"licenses/zlib1g-dev.copyright": {
|
||||||
|
"sha256": "9e5b96d63773a5d177ba264254390f792be07e41748ebd94730981c6cac31cc6",
|
||||||
|
"bytes": 2927
|
||||||
|
},
|
||||||
|
"plugins/platforms/libqoffscreen.so": {
|
||||||
|
"sha256": "f2f19a29e816c7e5c60fd52b9d3c1a214634c38e06cd2ffdf9fc88e9f0c49ffd",
|
||||||
|
"bytes": 193880
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"host_libraries": [
|
||||||
|
"ld-linux-x86-64.so.2",
|
||||||
|
"libc.so.6",
|
||||||
|
"libdl.so.2",
|
||||||
|
"libm.so.6",
|
||||||
|
"libpthread.so.0",
|
||||||
|
"libresolv.so.2",
|
||||||
|
"librt.so.1"
|
||||||
|
],
|
||||||
|
"offline_verified": true,
|
||||||
|
"hardware_qualified": false,
|
||||||
|
"clean_os_qualified": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Bundle an admitted native engine and the actual ELF dependency closure.
|
||||||
|
|
||||||
|
Private, installer-owned Qt runtime; never installs packages on the build host.
|
||||||
|
The target is Ubuntu 24.04 amd64. Only its glibc family stays a host prerequisite.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
SYSTEM = {"libc.so.6", "libm.so.6", "libpthread.so.0", "libdl.so.2", "librt.so.1", "libresolv.so.2", "ld-linux-x86-64.so.2"}
|
||||||
|
|
||||||
|
|
||||||
|
def build(engine, sysroot, source, output):
|
||||||
|
output.mkdir(mode=0o700, exist_ok=False)
|
||||||
|
staging = output / "payload"
|
||||||
|
(staging / "bin").mkdir(parents=True)
|
||||||
|
(staging / "lib").mkdir()
|
||||||
|
(staging / "plugins/platforms").mkdir(parents=True)
|
||||||
|
binary = staging / "bin/mission-core-vesc-engine"
|
||||||
|
shutil.copyfile(engine, binary); binary.chmod(0o755)
|
||||||
|
qtlib = sysroot / "usr/lib/x86_64-linux-gnu"
|
||||||
|
plugin = qtlib / "qt5/plugins/platforms/libqoffscreen.so"
|
||||||
|
shutil.copyfile(plugin, staging / "plugins/platforms/libqoffscreen.so")
|
||||||
|
env = dict(os.environ, LD_LIBRARY_PATH=str(qtlib), LC_ALL="C")
|
||||||
|
sources = {}
|
||||||
|
for executable in (engine, plugin):
|
||||||
|
result = subprocess.run(["/usr/bin/ldd", str(executable)], env=env, capture_output=True, text=True, check=True).stdout
|
||||||
|
if "not found" in result: raise RuntimeError("Native runtime dependency missing")
|
||||||
|
for name, path in re.findall(r"^\s*(\S+) => (/[\S]+) \(", result, re.MULTILINE):
|
||||||
|
if name in SYSTEM: continue
|
||||||
|
library = Path(path)
|
||||||
|
if name in sources and sources[name] != library: raise RuntimeError("Conflicting dependency")
|
||||||
|
sources[name] = library
|
||||||
|
for name, library in sources.items(): shutil.copyfile(library, staging / "lib" / name)
|
||||||
|
env.update(LD_LIBRARY_PATH=str(staging / "lib"), QT_PLUGIN_PATH=str(staging / "plugins"),
|
||||||
|
QT_QPA_PLATFORM="offscreen", XDG_CONFIG_HOME=str(output / "config"), XDG_CACHE_HOME=str(output / "cache"))
|
||||||
|
# All non-glibc ELF dependencies must now resolve inside the shipped payload.
|
||||||
|
for executable in (binary, staging / "plugins/platforms/libqoffscreen.so"):
|
||||||
|
result = subprocess.run(["/usr/bin/ldd", str(executable)], env=env, capture_output=True, text=True, check=True).stdout
|
||||||
|
if "not found" in result: raise RuntimeError("Bundled native closure incomplete")
|
||||||
|
for name, path in re.findall(r"^\s*(\S+) => (/[\S]+) \(", result, re.MULTILINE):
|
||||||
|
if name not in SYSTEM and not Path(path).resolve().is_relative_to(staging):
|
||||||
|
raise RuntimeError("Undeclared host dependency: " + name)
|
||||||
|
proc = subprocess.run([str(binary), "--offline"], env=env, input=b'{"id":1,"method":"engine"}\n',
|
||||||
|
capture_output=True, timeout=15, check=True)
|
||||||
|
responses = [json.loads(line) for line in proc.stdout.splitlines()]
|
||||||
|
if len(responses) != 2 or not responses[0]["ready"] or responses[1]["result"]["connected"]:
|
||||||
|
raise RuntimeError("Bundled engine acceptance failed")
|
||||||
|
(output / "offline.stdout").write_bytes(proc.stdout)
|
||||||
|
(output / "offline.stderr").write_bytes(proc.stderr)
|
||||||
|
(staging / "licenses").mkdir()
|
||||||
|
shutil.copyfile(source / "LICENSE", staging / "licenses/VESC-Tool-LICENSE")
|
||||||
|
(staging / "licenses/UPSTREAM-SOURCE.txt").write_text(
|
||||||
|
"VESC Tool 7.00, unmodified upstream sources and resources:\n"
|
||||||
|
"https://github.com/vedderb/vesc_tool/tree/01d5f10901116c311e3fb84d5a1541f663d3ce20\n"
|
||||||
|
"Source archive SHA-256: 4565ecec4e41e094bcc43127a51970e810aa9080a9d7c025af64632bfd4c0189\n"
|
||||||
|
"The process adapter source is included alongside this notice.\n")
|
||||||
|
for name in ("engine_main.cpp", "config_export.h"):
|
||||||
|
shutil.copyfile(engine.parent / name, staging / "licenses" / name)
|
||||||
|
# Preserve dependency notices available in the private signed-package sysroot.
|
||||||
|
for index, path in enumerate(sorted((sysroot / "usr/share/doc").glob("*/copyright"))):
|
||||||
|
shutil.copyfile(path, staging / "licenses" / (path.parent.name + ".copyright"))
|
||||||
|
# Host libraries copied into the closure retain their distribution notices.
|
||||||
|
for library in sources.values():
|
||||||
|
if library.resolve().is_relative_to(sysroot): continue
|
||||||
|
owner = subprocess.run(["dpkg-query", "-S", str(library)], capture_output=True, text=True)
|
||||||
|
if owner.returncode: continue
|
||||||
|
package = owner.stdout.split(": ", 1)[0].split(":", 1)[0]
|
||||||
|
notice = Path("/usr/share/doc") / package / "copyright"
|
||||||
|
if notice.is_file(): shutil.copyfile(notice, staging / "licenses" / (package + ".copyright"))
|
||||||
|
metadata = {str(p.relative_to(staging)): {"sha256": hashlib.sha256(p.read_bytes()).hexdigest(), "bytes":p.stat().st_size}
|
||||||
|
for p in sorted(staging.rglob("*")) if p.is_file()}
|
||||||
|
archive = output / "mission-core-vesc-native-runtime.tar.gz"
|
||||||
|
with tarfile.open(archive, "w:gz") as stream:
|
||||||
|
for path in sorted(staging.rglob("*")):
|
||||||
|
if path.is_file(): stream.add(path, arcname=str(path.relative_to(staging)))
|
||||||
|
report = {"schema":"missioncore.vesc.native-runtime/v1", "upstream_version":"7.00",
|
||||||
|
"upstream_commit":"01d5f10901116c311e3fb84d5a1541f663d3ce20", "os":"ubuntu-24.04-amd64",
|
||||||
|
"file":archive.name, "bytes":archive.stat().st_size, "sha256":hashlib.sha256(archive.read_bytes()).hexdigest(),
|
||||||
|
"engine_sha256":hashlib.sha256(binary.read_bytes()).hexdigest(), "files":metadata,
|
||||||
|
"host_libraries":sorted(SYSTEM), "offline_verified":True, "hardware_qualified":False,
|
||||||
|
"clean_os_qualified":False}
|
||||||
|
(output / "bundle.json").write_text(json.dumps(report,indent=2)+"\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser=argparse.ArgumentParser()
|
||||||
|
for name in ("engine","sysroot","source","output"):parser.add_argument("--"+name,type=Path,required=True)
|
||||||
|
args=parser.parse_args();build(args.engine,args.sysroot,args.source,args.output)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Installer acceptance under the service account; never opens a USB device."""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
|
def check(root):
|
||||||
|
manifest = json.loads((root / "manifest.json").read_text())
|
||||||
|
for name, expected in manifest["files"].items():
|
||||||
|
path = root / name
|
||||||
|
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
|
||||||
|
raise RuntimeError("Untrusted native runtime path")
|
||||||
|
if path.stat().st_size != expected["bytes"] or hashlib.sha256(path.read_bytes()).hexdigest() != expected["sha256"]:
|
||||||
|
raise RuntimeError("Native runtime integrity check failed")
|
||||||
|
with tempfile.TemporaryDirectory(prefix="mission-core-vesc-check-") as temporary:
|
||||||
|
env = {"PATH":"/usr/bin:/bin", "LANG":"C.UTF-8", "QT_QPA_PLATFORM":"offscreen",
|
||||||
|
"LD_LIBRARY_PATH":str(root / "lib"), "QT_PLUGIN_PATH":str(root / "plugins"),
|
||||||
|
"XDG_CONFIG_HOME":temporary, "XDG_CACHE_HOME":temporary}
|
||||||
|
result = subprocess.run([str(root / "bin/mission-core-vesc-engine"), "--offline"],
|
||||||
|
env=env, input=b'{"id":1,"method":"engine"}\n', capture_output=True, timeout=15, check=True)
|
||||||
|
replies = [json.loads(line) for line in result.stdout.splitlines()]
|
||||||
|
if (len(replies) != 2 or not replies[0]["ready"] or not replies[1]["ok"]
|
||||||
|
or replies[1]["result"]["hardware_enabled"] or replies[1]["result"]["connected"]
|
||||||
|
or replies[1]["result"]["commit"] != manifest["upstream_commit"]):
|
||||||
|
raise RuntimeError("Native engine offline check failed")
|
||||||
|
print(json.dumps({"ok":True, "upstream_commit":manifest["upstream_commit"], "hardware_access":False}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if os.geteuid() == 0: raise RuntimeError("Run as the VESC service account")
|
||||||
|
check(Path(__file__).resolve().parent / "native")
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Versioned offline qualification artifact, not a runtime installer.
|
||||||
|
|
||||||
|
Reuse an attested upstream Tool build's object files unchanged. Only the adapter
|
||||||
|
entry point is compiled. The previous staging, packages and services are untouched.
|
||||||
|
All inputs, link objects, outputs and checks are hashed in the private report.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
COMMIT = "01d5f10901116c311e3fb84d5a1541f663d3ce20"
|
||||||
|
UPSTREAM_SHA256 = "4565ecec4e41e094bcc43127a51970e810aa9080a9d7c025af64632bfd4c0189"
|
||||||
|
|
||||||
|
|
||||||
|
def digest(path):
|
||||||
|
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def run(args):
|
||||||
|
release = platform.freedesktop_os_release()
|
||||||
|
if os.geteuid() == 0 or (release.get("ID"), release.get("VERSION_ID"), platform.machine()) != ("ubuntu", "24.04", "x86_64"):
|
||||||
|
raise RuntimeError("Unprivileged Ubuntu 24.04 amd64 required")
|
||||||
|
group = Path("/sys/fs/cgroup") / Path("/proc/self/cgroup").read_text().strip().split("::", 1)[1].lstrip("/")
|
||||||
|
limit = (group / "memory.max").read_text().strip()
|
||||||
|
if limit == "max" or int(limit) > 3 * 1024**3:
|
||||||
|
raise RuntimeError("A bounded user scope with MemoryMax <= 3G is required")
|
||||||
|
previous = json.loads(args.upstream_report.read_text())
|
||||||
|
if previous.get("state") != "complete" or previous.get("source_sha256") != UPSTREAM_SHA256:
|
||||||
|
raise RuntimeError("Unqualified upstream build")
|
||||||
|
upstream = Path(previous["binary"])
|
||||||
|
if digest(upstream) != previous["binary_sha256"]:
|
||||||
|
raise RuntimeError("Upstream binary changed")
|
||||||
|
source = upstream.parents[2]
|
||||||
|
if source.name != "vesc_tool-" + COMMIT:
|
||||||
|
raise RuntimeError("Upstream source path mismatch")
|
||||||
|
os.umask(0o077)
|
||||||
|
root = args.output.resolve()
|
||||||
|
root.mkdir(parents=True, mode=0o700, exist_ok=False)
|
||||||
|
# Paths enter a generated makefile, never a shell command assembled from JSON.
|
||||||
|
if any(not re.fullmatch(r"[A-Za-z0-9_./-]+", str(p)) for p in (root, source)):
|
||||||
|
raise RuntimeError("Build paths must be make-safe")
|
||||||
|
with zipfile.ZipFile(args.artifact) as bundle:
|
||||||
|
for name in ("offline_main.cpp", "config_export.h", "engine_main.cpp", "native_bundle.py"):
|
||||||
|
(root / name).write_bytes(bundle.read(name))
|
||||||
|
report = {"schema": "missioncore.vesc.native-probe/v1", "state": "running",
|
||||||
|
"started_at": datetime.now(timezone.utc).isoformat(), "monotonic_started": time.monotonic(),
|
||||||
|
"source_commit": COMMIT, "artifact_sha256": digest(args.artifact),
|
||||||
|
"upstream_report_sha256": digest(args.upstream_report),
|
||||||
|
"adapter_sha256": digest(root / "offline_main.cpp"),
|
||||||
|
"hardware_access": False, "runtime_installed": False, "system_packages_installed": False,
|
||||||
|
"jobs": [], "checks": []}
|
||||||
|
|
||||||
|
def publish():
|
||||||
|
(root / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||||
|
|
||||||
|
staging = source.parents[1]
|
||||||
|
qtbase = staging / "sysroot/usr"
|
||||||
|
env = dict(os.environ, LC_ALL="C", QT_QPA_PLATFORM="offscreen",
|
||||||
|
LD_LIBRARY_PATH=str(qtbase / "lib/x86_64-linux-gnu"),
|
||||||
|
QT_PLUGIN_PATH=str(qtbase / "lib/x86_64-linux-gnu/qt5/plugins"),
|
||||||
|
XDG_CONFIG_HOME=str(root / "config"), XDG_CACHE_HOME=str(root / "cache"))
|
||||||
|
|
||||||
|
def execute(name, command, data=None, expected=0, timeout=60):
|
||||||
|
start = time.monotonic()
|
||||||
|
proc = subprocess.run(command, cwd=source, env=env, input=data,
|
||||||
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
|
||||||
|
(root / (name + ".stdout")).write_bytes(proc.stdout)
|
||||||
|
(root / (name + ".stderr")).write_bytes(proc.stderr)
|
||||||
|
report["jobs"].append({"id": name, "exit_code": proc.returncode,
|
||||||
|
"duration_seconds": time.monotonic() - start,
|
||||||
|
"stdout_sha256": hashlib.sha256(proc.stdout).hexdigest(),
|
||||||
|
"stderr_sha256": hashlib.sha256(proc.stderr).hexdigest()})
|
||||||
|
publish()
|
||||||
|
if proc.returncode != expected:
|
||||||
|
raise RuntimeError("Native probe step failed: " + name)
|
||||||
|
return proc.stdout
|
||||||
|
|
||||||
|
try:
|
||||||
|
makefile = (source / "Makefile").read_text().replace("\\\n", " ")
|
||||||
|
match = re.search(r"^OBJECTS\s*=\s*(.+)$", makefile, re.MULTILINE)
|
||||||
|
if not match:
|
||||||
|
raise RuntimeError("Upstream link objects missing")
|
||||||
|
objects = [source / name for name in match.group(1).split() if name != "build/lin/obj/main.o"]
|
||||||
|
if not 100 < len(objects) < 1000 or any(not p.is_file() for p in objects):
|
||||||
|
raise RuntimeError("Upstream object inventory incomplete")
|
||||||
|
report["link_objects"] = [{"file": str(p.relative_to(source)), "sha256": digest(p)} for p in objects]
|
||||||
|
report["upstream_makefile_sha256"] = digest(source / "Makefile")
|
||||||
|
target = root / "mission-core-vesc-offline"
|
||||||
|
wrapper = root / "Makefile.native"
|
||||||
|
wrapper.write_text(
|
||||||
|
"include " + str(source / "Makefile") + "\n"
|
||||||
|
".PHONY: mission-core-native-probe\n"
|
||||||
|
"mission-core-native-probe:\n"
|
||||||
|
"\t$(CXX) -c $(CXXFLAGS) $(INCPATH) -o " + str(root / "offline_main.o") + " " + str(root / "offline_main.cpp") + "\n"
|
||||||
|
"\t$(LINK) $(LFLAGS) -o " + str(target) + " " + str(root / "offline_main.o") +
|
||||||
|
" $(filter-out build/lin/obj/main.o,$(OBJECTS)) $(OBJCOMP) $(LIBS)\n")
|
||||||
|
execute("compile-link", ["/usr/bin/make", "-f", str(wrapper), "mission-core-native-probe"], timeout=180)
|
||||||
|
report["binary_sha256"] = digest(target)
|
||||||
|
for index, archive in enumerate(args.archives):
|
||||||
|
raw = archive.read_bytes()
|
||||||
|
native = json.loads(execute("archive-%d" % index, [str(target)], raw))
|
||||||
|
if not native["ok"] or not native["compatibility"]["legacy_power_loss_correction"]:
|
||||||
|
raise RuntimeError("Upstream compatibility check failed")
|
||||||
|
import base64
|
||||||
|
packet = base64.b64decode(native["compatibility"]["offline_detect_example_base64"])
|
||||||
|
if packet[:2] != bytes([58, 0]) or int.from_bytes(packet[2:6], "big", signed=True) != 50000:
|
||||||
|
raise RuntimeError("Expected native 5.02 detect correction was not applied")
|
||||||
|
rpm_packets = [base64.b64decode(p) for p in native["offline_signed_rpm_examples_base64"]]
|
||||||
|
if (len(rpm_packets) != 2 or any(len(p) != 5 or p[0] != 8 for p in rpm_packets)
|
||||||
|
or [int.from_bytes(p[1:], "big", signed=True) for p in rpm_packets] != [3000, -3000]):
|
||||||
|
raise RuntimeError("Upstream signed RPM serialization mismatch")
|
||||||
|
report["checks"].append({"id": "signed-rpm-serialization-%d" % index, "ok": True})
|
||||||
|
report["checks"].append({"id": "native-archive-%d" % index, "ok": True,
|
||||||
|
"archive_sha256": hashlib.sha256(raw).hexdigest(),
|
||||||
|
"motor_parameter_count": len(native["motor"]["parameters"]),
|
||||||
|
"application_parameter_count": len(native["application"]["parameters"]),
|
||||||
|
"binary_round_trip_exact": True,
|
||||||
|
"xml_equivalent_by_upstream_comparison": True,
|
||||||
|
"xml_binary_exact": all(native[k]["xml_round_trip_exact"] for k in ("motor", "application")),
|
||||||
|
"legacy_power_loss_correction": True})
|
||||||
|
# Fail closed on archive corruption and unsupported firmware instead
|
||||||
|
# of silently presenting the bundled defaults as actual settings.
|
||||||
|
original = json.loads(raw)
|
||||||
|
damaged = json.loads(raw); damaged["configs"]["motor"]["sha256"] = "0" * 64
|
||||||
|
unknown = json.loads(raw); unknown["identity"]["major"] = 99
|
||||||
|
wrong_signature = json.loads(raw)
|
||||||
|
payload = bytearray(base64.b64decode(original["configs"]["motor"]["payload"])); payload[1] ^= 1
|
||||||
|
wrong_signature["configs"]["motor"]["payload"] = base64.b64encode(payload).decode()
|
||||||
|
wrong_signature["configs"]["motor"]["sha256"] = hashlib.sha256(payload).hexdigest()
|
||||||
|
truncated = json.loads(raw)
|
||||||
|
payload = base64.b64decode(original["configs"]["motor"]["payload"])[:-1]
|
||||||
|
truncated["configs"]["motor"].update(payload=base64.b64encode(payload).decode(),
|
||||||
|
sha256=hashlib.sha256(payload).hexdigest(), bytes=len(payload))
|
||||||
|
for name, value in (("corrupt", damaged), ("unsupported", unknown), ("signature", wrong_signature), ("truncated", truncated)):
|
||||||
|
rejected = json.loads(execute("%s-%d" % (name, index), [str(target)], json.dumps(value).encode(), expected=1))
|
||||||
|
if rejected["ok"] or rejected["hardware_access"]:
|
||||||
|
raise RuntimeError("Invalid archive was not rejected")
|
||||||
|
report["checks"].append({"id": "%s-%d" % (name, index), "ok": True})
|
||||||
|
for item in report["link_objects"]:
|
||||||
|
if digest(source / item["file"]) != item["sha256"]:
|
||||||
|
raise RuntimeError("Upstream objects were modified")
|
||||||
|
engine = root / "mission-core-vesc-engine"
|
||||||
|
wrapper.write_text(wrapper.read_text() +
|
||||||
|
"\n.PHONY: mission-core-native-engine\nmission-core-native-engine:\n"
|
||||||
|
"\t$(CXX) -c $(CXXFLAGS) $(INCPATH) -o " + str(root / "engine_main.o") + " " + str(root / "engine_main.cpp") + "\n"
|
||||||
|
"\t$(LINK) $(LFLAGS) -o " + str(engine) + " " + str(root / "engine_main.o") +
|
||||||
|
" $(filter-out build/lin/obj/main.o,$(OBJECTS)) $(OBJCOMP) $(LIBS)\n")
|
||||||
|
execute("engine-compile", ["/usr/bin/make", "-f", str(wrapper), "mission-core-native-engine"], timeout=180)
|
||||||
|
requests = [{"id": 1, "method": "engine"}, {"id": 2, "method": "current", "current_a": 30},
|
||||||
|
{"id": 3, "method": "hall_start", "current_a": 5}, {"id": 4, "method": "arbitrary_packet"}]
|
||||||
|
responses = [json.loads(line) for line in execute("engine-offline", [str(engine), "--offline"],
|
||||||
|
b"".join(json.dumps(r).encode()+b"\n" for r in requests)).splitlines()]
|
||||||
|
if (len(responses) != 5 or not responses[0]["ready"] or not responses[1]["ok"]
|
||||||
|
or any(r["ok"] for r in responses[2:]) or responses[1]["result"]["hardware_enabled"]):
|
||||||
|
raise RuntimeError("Native engine offline boundary failed")
|
||||||
|
report["checks"].append({"id": "engine-offline-denies-hardware", "ok": True})
|
||||||
|
execute("runtime-bundle", ["/usr/bin/python3", str(root / "native_bundle.py"),
|
||||||
|
"--engine", str(engine), "--sysroot", str(staging / "sysroot"), "--source", str(source), "--output", str(root / "runtime")], timeout=180)
|
||||||
|
report["native_runtime"] = json.loads((root / "runtime/bundle.json").read_text())
|
||||||
|
report.update(state="complete", binary=str(target), upstream_objects_unchanged=True)
|
||||||
|
except Exception as error:
|
||||||
|
report.update(state="error", error=str(error))
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
report["finished_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
report["duration_seconds"] = time.monotonic() - report["monotonic_started"]
|
||||||
|
publish()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--artifact", type=Path, required=True)
|
||||||
|
parser.add_argument("--upstream-report", type=Path, required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
parser.add_argument("--archives", type=Path, nargs="+", required=True)
|
||||||
|
run(parser.parse_args())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""The Node release carries this model profile from the first hardware use."""
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
|
||||||
|
def payload():
|
||||||
|
root = Path(__file__).resolve().parents[1]
|
||||||
|
files = [("usr/lib/mission-core-vesc/runtime/" + p.name, p.read_bytes(), 0o644)
|
||||||
|
for p in sorted((root / "runtime").glob("*.py"))]
|
||||||
|
files.extend(("usr/lib/mission-core-vesc/runtime/" + str(p.relative_to(root / "runtime")), p.read_bytes(), 0o644)
|
||||||
|
for p in sorted((root / "runtime/schemas").rglob("*")) if p.is_file())
|
||||||
|
files.append(("usr/lib/mission-core-vesc/runtime/archive.py",
|
||||||
|
(root.parents[1] / "src/k1link/device_plugins/vesc/archive.py").read_bytes(), 0o644))
|
||||||
|
for name in ("mission-core-vesc.service", "mission-core-node-vesc-prepare.service"):
|
||||||
|
files.append(("usr/lib/systemd/system/" + name, (root / "packaging" / name).read_bytes(), 0o644))
|
||||||
|
files.append(("usr/lib/mission-core-vesc/prepare.py", (root / "packaging/prepare.py").read_bytes(), 0o644))
|
||||||
|
files.append(("usr/lib/mission-core-vesc/clear_runtime_cache.py", (root / "packaging/clear_runtime_cache.py").read_bytes(), 0o644))
|
||||||
|
files.append(("usr/share/mission-core-node/profiles/vesc/70-mission-core-vesc.rules",
|
||||||
|
(root / "packaging/70-mission-core-vesc.rules").read_bytes(), 0o644))
|
||||||
|
manifest = json.loads((root / "packaging/native-runtime.json").read_text())
|
||||||
|
bundle = root / "build/native-runtime" / manifest["file"]
|
||||||
|
data = bundle.read_bytes()
|
||||||
|
if len(data) != manifest["bytes"] or hashlib.sha256(data).hexdigest() != manifest["sha256"]:
|
||||||
|
raise ValueError("Native VESC Tool bundle changed")
|
||||||
|
seen = set()
|
||||||
|
with tarfile.open(bundle, "r:gz") as archive:
|
||||||
|
for member in archive.getmembers():
|
||||||
|
path = PurePosixPath(member.name)
|
||||||
|
if (not member.isfile() or path.is_absolute() or ".." in path.parts
|
||||||
|
or path.as_posix() != member.name or member.name in seen
|
||||||
|
or member.name not in manifest["files"]):
|
||||||
|
raise ValueError("Unexpected native payload member")
|
||||||
|
seen.add(member.name)
|
||||||
|
expected = manifest["files"][member.name]
|
||||||
|
if member.size != expected["bytes"] or member.size > 128 * 1024**2:
|
||||||
|
raise ValueError("Native member size mismatch")
|
||||||
|
content = archive.extractfile(member).read()
|
||||||
|
if hashlib.sha256(content).hexdigest() != expected["sha256"]:
|
||||||
|
raise ValueError("Native member hash mismatch")
|
||||||
|
mode = 0o755 if member.name.startswith("bin/") else 0o644
|
||||||
|
files.append(("usr/lib/mission-core-vesc/native/" + member.name, content, mode))
|
||||||
|
if seen != set(manifest["files"]): raise ValueError("Incomplete native payload")
|
||||||
|
files.append(("usr/lib/mission-core-vesc/native/manifest.json",
|
||||||
|
(root / "packaging/native-runtime.json").read_bytes(), 0o644))
|
||||||
|
files.append(("usr/lib/mission-core-vesc/native_check.py",
|
||||||
|
(root / "packaging/native_check.py").read_bytes(), 0o644))
|
||||||
|
return files
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""Versioned, idempotent VESC profile; no packages downloaded and no motor I/O."""
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||||
|
from runtime.serial import discover
|
||||||
|
from runtime.service import atomic
|
||||||
|
|
||||||
|
STATE = Path("/var/lib/mission-core-node-profiles/vesc")
|
||||||
|
|
||||||
|
|
||||||
|
def prepare():
|
||||||
|
if os.geteuid() != 0 or sys.argv[1:]:
|
||||||
|
raise RuntimeError("Fixed system profile only")
|
||||||
|
release = platform.freedesktop_os_release()
|
||||||
|
if (release.get("ID"), release.get("VERSION_ID"), platform.machine()) != ("ubuntu", "24.04", "x86_64"):
|
||||||
|
raise RuntimeError("Ubuntu 24.04 amd64 required")
|
||||||
|
STATE.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||||
|
for directory in (STATE.parent, STATE):
|
||||||
|
info = directory.lstat()
|
||||||
|
if directory.is_symlink() or info.st_uid != 0 or info.st_mode & 0o022:
|
||||||
|
raise RuntimeError("Untrusted profile state")
|
||||||
|
lock = os.open(STATE / "prepare.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||||
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
report = {"schema": "missioncore.node.device-preparation/v1", "model_id": "vesc.controller",
|
||||||
|
"version": "0.7.4", "run_id": uuid.uuid4().hex, "started_at": time.time(),
|
||||||
|
"monotonic_started": time.monotonic(), "state": "running", "steps": []}
|
||||||
|
|
||||||
|
def publish():
|
||||||
|
atomic(STATE / "preparation.json", report)
|
||||||
|
os.chmod(STATE / "preparation.json", 0o644)
|
||||||
|
|
||||||
|
def run(name, label, args):
|
||||||
|
step = {"id": name, "label": label, "state": "running"}
|
||||||
|
report["steps"].append(step)
|
||||||
|
publish()
|
||||||
|
result = subprocess.run(args, capture_output=True, timeout=45,
|
||||||
|
env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8"})
|
||||||
|
step["state"] = "complete" if result.returncode == 0 else "error"
|
||||||
|
publish()
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError("Не завершён этап: " + label)
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pwd
|
||||||
|
try:
|
||||||
|
pwd.getpwnam("mission-core-vesc")
|
||||||
|
except KeyError:
|
||||||
|
run("account", "Подготовка доступа", ["/usr/sbin/adduser", "--system", "--group", "--home",
|
||||||
|
"/var/lib/mission-core-vesc", "--no-create-home", "--disabled-login", "mission-core-vesc"])
|
||||||
|
run("native", "Проверка VESC Tool", ["/usr/sbin/runuser", "-u", "mission-core-vesc", "--",
|
||||||
|
"/usr/bin/python3", "-I", "/usr/lib/mission-core-vesc/native_check.py"])
|
||||||
|
source = Path("/usr/share/mission-core-node/profiles/vesc/70-mission-core-vesc.rules")
|
||||||
|
rules = source.read_bytes()
|
||||||
|
report["udev_sha256"] = hashlib.sha256(rules).hexdigest()
|
||||||
|
target = Path("/etc/udev/rules.d/70-mission-core-vesc.rules")
|
||||||
|
if target.is_symlink():
|
||||||
|
raise RuntimeError("Untrusted udev destination")
|
||||||
|
target.write_bytes(rules)
|
||||||
|
target.chmod(0o644)
|
||||||
|
run("rules", "Настройка USB-доступа", ["/usr/bin/udevadm", "control", "--reload-rules"])
|
||||||
|
for i, device in enumerate(discover()):
|
||||||
|
run("usb" + str(i), "Применение USB-доступа", ["/usr/bin/udevadm", "trigger", "--action=change",
|
||||||
|
"/sys/class/tty/" + device.tty])
|
||||||
|
run("settle", "Проверка USB-доступа", ["/usr/bin/udevadm", "settle", "--timeout=10"])
|
||||||
|
run("enable", "Подготовка службы", ["/usr/bin/systemctl", "enable", "mission-core-vesc.service"])
|
||||||
|
run("runtime", "Запуск чтения контроллеров", ["/usr/bin/systemctl", "restart", "mission-core-vesc.service"])
|
||||||
|
report["state"] = "complete"
|
||||||
|
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
|
||||||
|
report.update(state="error", message=str(error)[:300])
|
||||||
|
finally:
|
||||||
|
report["duration_seconds"] = time.monotonic() - report["monotonic_started"]
|
||||||
|
publish()
|
||||||
|
os.close(lock)
|
||||||
|
return report["state"] == "complete"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(0 if prepare() else 1)
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Unprivileged VESC Tool build spike; never installs packages or opens a device.
|
||||||
|
|
||||||
|
Run in a bounded user systemd scope on Ubuntu 24.04 amd64. APT resolves and
|
||||||
|
downloads signed Ubuntu packages into this job; dpkg-deb only extracts them.
|
||||||
|
The output is an engineering build, not an installed or qualified runtime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tarfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
COMMIT = "01d5f10901116c311e3fb84d5a1541f663d3ce20"
|
||||||
|
ARCHIVE_SHA256 = "4565ecec4e41e094bcc43127a51970e810aa9080a9d7c025af64632bfd4c0189"
|
||||||
|
DEPS = (
|
||||||
|
"qtbase5-dev", "qtbase5-private-dev", "qtdeclarative5-dev",
|
||||||
|
"qtquickcontrols2-5-dev", "libqt5serialport5-dev", "qtconnectivity5-dev",
|
||||||
|
"qtpositioning5-dev", "libqt5gamepad5-dev", "libqt5svg5-dev",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build(archive, root, dependency_cache=None, resume=None):
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
raise RuntimeError("This build must not run as root")
|
||||||
|
release = platform.freedesktop_os_release()
|
||||||
|
if (release.get("ID"), release.get("VERSION_ID"), platform.machine()) != ("ubuntu", "24.04", "x86_64"):
|
||||||
|
raise RuntimeError("Ubuntu 24.04 amd64 required")
|
||||||
|
assert hashlib.sha256(archive.read_bytes()).hexdigest() == ARCHIVE_SHA256
|
||||||
|
group = Path("/sys/fs/cgroup") / Path("/proc/self/cgroup").read_text().strip().split("::", 1)[1].lstrip("/")
|
||||||
|
limit = (group / "memory.max").read_text().strip()
|
||||||
|
if limit == "max" or int(limit) > 3 * 1024**3:
|
||||||
|
raise RuntimeError("Run in a user scope with MemoryMax=3G")
|
||||||
|
os.umask(0o077)
|
||||||
|
root.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||||
|
root = root.resolve()
|
||||||
|
report = {"schema": "missioncore.vesc.tool-build/v1", "source_commit": COMMIT,
|
||||||
|
"source_sha256": ARCHIVE_SHA256, "state": "running", "jobs": [],
|
||||||
|
"started_at": datetime.now(timezone.utc).isoformat(), "monotonic_started": time.monotonic(),
|
||||||
|
"hardware_access": False, "system_packages_installed": False, "packages": []}
|
||||||
|
env = dict(os.environ, LC_ALL="C", DEBIAN_FRONTEND="noninteractive", QT_QPA_PLATFORM="offscreen")
|
||||||
|
|
||||||
|
def publish():
|
||||||
|
(root / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||||
|
|
||||||
|
def run(name, args, cwd=None, timeout=600):
|
||||||
|
job = {"id": name, "state": "running"}; report["jobs"].append(job); publish()
|
||||||
|
started = time.monotonic()
|
||||||
|
with (root / (name + ".stdout")).open("wb") as out, (root / (name + ".stderr")).open("wb") as err:
|
||||||
|
result = subprocess.run(args, cwd=cwd or root, env=env, stdout=out, stderr=err, timeout=timeout)
|
||||||
|
job.update(state="complete" if result.returncode == 0 else "error", exit_code=result.returncode,
|
||||||
|
duration_seconds=time.monotonic() - started)
|
||||||
|
publish()
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError("Build step failed: " + name)
|
||||||
|
return (root / (name + ".stdout")).read_text()
|
||||||
|
|
||||||
|
try:
|
||||||
|
staging = root
|
||||||
|
if resume is not None:
|
||||||
|
previous_raw = (resume / "report.json").read_bytes()
|
||||||
|
previous = json.loads(previous_raw)
|
||||||
|
if previous.get("source_sha256") != ARCHIVE_SHA256 or previous.get("error") != "Build step failed: compile":
|
||||||
|
raise RuntimeError("Only this source's failed compile may resume")
|
||||||
|
staging = resume.resolve()
|
||||||
|
report["resume_report_sha256"] = hashlib.sha256(previous_raw).hexdigest()
|
||||||
|
report["resumed_staging"] = str(staging)
|
||||||
|
downloads = staging / "packages"
|
||||||
|
if resume is None: downloads.mkdir()
|
||||||
|
sysroot = staging / "sysroot"
|
||||||
|
if resume is None: sysroot.mkdir()
|
||||||
|
if resume is not None:
|
||||||
|
report["packages"] = previous["packages"]
|
||||||
|
for item in report["packages"]:
|
||||||
|
if hashlib.sha256((downloads / item["file"]).read_bytes()).hexdigest() != item["sha256"]:
|
||||||
|
raise RuntimeError("Resumed dependency changed")
|
||||||
|
elif dependency_cache is not None:
|
||||||
|
previous = json.loads((dependency_cache / "report.json").read_text())
|
||||||
|
if previous.get("source_sha256") != ARCHIVE_SHA256 or not previous.get("packages"):
|
||||||
|
raise RuntimeError("Unqualified dependency cache")
|
||||||
|
for item in previous["packages"]:
|
||||||
|
name = item["file"]
|
||||||
|
if Path(name).name != name or not name.endswith(".deb"):
|
||||||
|
raise RuntimeError("Invalid cached package name")
|
||||||
|
package = dependency_cache / "packages" / name
|
||||||
|
if hashlib.sha256(package.read_bytes()).hexdigest() != item["sha256"]:
|
||||||
|
raise RuntimeError("Cached dependency changed")
|
||||||
|
shutil.copyfile(package, downloads / name)
|
||||||
|
report["dependency_cache_report_sha256"] = hashlib.sha256((dependency_cache / "report.json").read_bytes()).hexdigest()
|
||||||
|
else:
|
||||||
|
# Host lists may refer to superseded security packages. Refresh only
|
||||||
|
# this job's signed Ubuntu indexes; never update /var/lib/apt or invoke
|
||||||
|
# the host's update hooks (Timescale and other sources are irrelevant).
|
||||||
|
aptdir = root / "apt"; aptdir.mkdir()
|
||||||
|
for name in ("lists", "lists/partial", "archives", "archives/partial"):
|
||||||
|
(aptdir / name).mkdir(exist_ok=True)
|
||||||
|
sources = aptdir / "sources.list"
|
||||||
|
sources.write_text("".join(
|
||||||
|
"deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg] " + url + " " + suite + " main universe\n"
|
||||||
|
for url, suite in (("https://archive.ubuntu.com/ubuntu", "noble"),
|
||||||
|
("https://archive.ubuntu.com/ubuntu", "noble-updates"),
|
||||||
|
("https://security.ubuntu.com/ubuntu", "noble-security"))))
|
||||||
|
config = aptdir / "apt.conf"
|
||||||
|
config.write_text(
|
||||||
|
'Dir::Etc::Parts "-";\nDir::Etc::main "-";\n'
|
||||||
|
'Dir::Etc::sourceparts "-";\nDir::Etc::sourcelist "' + str(sources) + '";\n'
|
||||||
|
'Dir::State::lists "' + str(aptdir / "lists") + '";\n'
|
||||||
|
'Dir::Cache::archives "' + str(aptdir / "archives") + '";\n'
|
||||||
|
'Dir::Cache::pkgcache "";\nDir::Cache::srcpkgcache "";\n'
|
||||||
|
'Acquire::Languages "none";\nDebug::NoLocking "true";\n'
|
||||||
|
'#clear APT::Update::Post-Invoke;\n#clear APT::Update::Post-Invoke-Success;\n')
|
||||||
|
env["APT_CONFIG"] = str(config)
|
||||||
|
apt = ["/usr/bin/apt-get"]
|
||||||
|
run("private-indexes", [*apt, "update"])
|
||||||
|
plan = run("dependencies-plan", [*apt, "--simulate", "--no-install-recommends", "--no-remove", "install", *DEPS])
|
||||||
|
packages = re.findall(r"^Inst (\S+)(?: \[[^\]]+\])? \((\S+)", plan, re.MULTILINE)
|
||||||
|
if not packages or len(packages) > 150:
|
||||||
|
raise RuntimeError("Unexpected dependency plan; inspect before changing profile")
|
||||||
|
for index, (name, version) in enumerate(packages):
|
||||||
|
# apt-get download verifies the archive against the host's trusted
|
||||||
|
# repository metadata. No maintainer script or package install runs.
|
||||||
|
run("download-%03d" % index, [*apt, "download", name + "=" + version], downloads)
|
||||||
|
for index, package in enumerate(sorted(downloads.glob("*.deb")) if resume is None else []):
|
||||||
|
report["packages"].append({"file": package.name, "sha256": hashlib.sha256(package.read_bytes()).hexdigest()})
|
||||||
|
run("extract-%03d" % index, ["/usr/bin/dpkg-deb", "--extract", str(package), str(sysroot)])
|
||||||
|
source = staging / "source"
|
||||||
|
if resume is None:
|
||||||
|
source.mkdir()
|
||||||
|
with tarfile.open(archive) as stream:
|
||||||
|
stream.extractall(source, filter="data")
|
||||||
|
source = source / ("vesc_tool-" + COMMIT)
|
||||||
|
qtbase = sysroot / "usr"
|
||||||
|
# APT omits already-installed runtime packages. Complete the private
|
||||||
|
# development symlinks from declared host libraries, recording provenance.
|
||||||
|
report["host_libraries"] = []
|
||||||
|
for name in ("libGL.so.1", "libGLX.so.0", "libGLU.so.1"):
|
||||||
|
target = qtbase / "lib/x86_64-linux-gnu" / name
|
||||||
|
host = Path("/usr/lib/x86_64-linux-gnu") / name
|
||||||
|
if not target.exists() and host.exists():
|
||||||
|
shutil.copyfile(host, target)
|
||||||
|
report["host_libraries"].append({"source": str(host.resolve()), "sha256": hashlib.sha256(host.read_bytes()).hexdigest()})
|
||||||
|
qtarch = qtbase / "lib/x86_64-linux-gnu/qt5"
|
||||||
|
qtbin = qtbase / "lib/qt5/bin"
|
||||||
|
qtconfig = "[Paths]\nPrefix=" + str(qtbase) + "\n" + "\n".join(
|
||||||
|
name + "=" + str(path) for name, path in {
|
||||||
|
"Headers": qtbase / "include/x86_64-linux-gnu/qt5",
|
||||||
|
"Libraries": qtbase / "lib/x86_64-linux-gnu", "ArchData": qtarch,
|
||||||
|
"HostData": qtarch, "Binaries": qtbin, "HostBinaries": qtbin,
|
||||||
|
"Plugins": qtarch / "plugins", "Qml2Imports": qtarch / "qml",
|
||||||
|
"Data": qtbase / "share/qt5",
|
||||||
|
}.items()) + "\n"
|
||||||
|
(qtbin / "qt.conf").write_text(qtconfig)
|
||||||
|
env["LD_LIBRARY_PATH"] = str(qtbase / "lib/x86_64-linux-gnu")
|
||||||
|
env["QT_PLUGIN_PATH"] = str(qtarch / "plugins")
|
||||||
|
env["PKG_CONFIG_LIBDIR"] = str(qtbase / "lib/x86_64-linux-gnu/pkgconfig")
|
||||||
|
env["PKG_CONFIG_SYSROOT_DIR"] = str(sysroot)
|
||||||
|
run("qmake", [str(qtbin / "qmake"), "-config", "release", "CONFIG += release_lin build_original exclude_fw",
|
||||||
|
"VT_GIT_COMMIT=" + COMMIT[:8], "INCLUDEPATH += " + str(qtbase / "include") + " " + str(qtbase / "include/x86_64-linux-gnu"),
|
||||||
|
"QMAKE_LIBDIR += " + str(qtbase / "lib/x86_64-linux-gnu")], source)
|
||||||
|
run("compile", ["/usr/bin/make", "-j2"], source, timeout=1800)
|
||||||
|
binary = source / "build/lin/vesc_tool_7.00"
|
||||||
|
result = run("version", [str(binary), "--version"], source, timeout=30)
|
||||||
|
report.update(state="complete", binary=str(binary), binary_sha256=hashlib.sha256(binary.read_bytes()).hexdigest(),
|
||||||
|
version_output=result, runtime_installed=False, hardware_qualified=False)
|
||||||
|
except Exception as error:
|
||||||
|
report.update(state="error", error=str(error))
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
report["duration_seconds"] = time.monotonic() - report["monotonic_started"]
|
||||||
|
report["finished_at"] = datetime.now(timezone.utc).isoformat()
|
||||||
|
publish()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--source", type=Path, required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
parser.add_argument("--dependencies", type=Path)
|
||||||
|
parser.add_argument("--resume", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
build(args.source.resolve(), args.output, args.dependencies, args.resume)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Onboard VESC discovery, archive and bounded motor identification service."""
|
||||||
|
|
||||||
|
VERSION = "0.7.4"
|
||||||
|
MODEL = "vesc.controller"
|
||||||
|
SCHEMA = "missioncore.nodedc/plugin-sdk/v0alpha2"
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Exact, read-only firmware configuration decoder. Never serializes a write."""
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import struct
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
def crc32c(data):
|
||||||
|
value = 0xffffffff
|
||||||
|
for byte in data:
|
||||||
|
value ^= byte
|
||||||
|
for _ in range(8):
|
||||||
|
value = (value >> 1) ^ (0x82f63b78 if value & 1 else 0)
|
||||||
|
return value ^ 0xffffffff
|
||||||
|
|
||||||
|
|
||||||
|
def decode(data, kind):
|
||||||
|
code, name = {"motor": (14, "mcconf"), "application": (17, "appconf")}[kind]
|
||||||
|
xml = ET.parse(Path(__file__).parent / "schemas/5.02" / ("parameters_" + name + ".xml")).getroot()
|
||||||
|
params = {p.tag: p for p in xml.find("Params")}
|
||||||
|
order = [p.text for p in xml.find("SerOrder")]
|
||||||
|
signature = "".join(n + params[n].findtext("type", "0") + params[n].findtext("vTx", "0")
|
||||||
|
+ "".join(x.text or "" for x in params[n].findall("enumNames")) for n in order)
|
||||||
|
if len(data) < 5 or data[0] != code or int.from_bytes(data[1:5], "big") != crc32c(signature.encode()):
|
||||||
|
raise ValueError("Configuration signature differs from firmware 5.02 schema")
|
||||||
|
offset, result = 5, {}
|
||||||
|
for name in order:
|
||||||
|
p = params[name]; kind = int(p.findtext("type")); tx = int(p.findtext("vTx", "0"))
|
||||||
|
if kind in (4, 5): fmt = "b"
|
||||||
|
elif kind == 6: fmt = "B"
|
||||||
|
elif kind == 2: fmt = {1: "B", 2: "b", 3: "H", 4: "h", 5: "I", 6: "i"}[tx]
|
||||||
|
elif kind == 1: fmt = {7: "h", 8: "i", 9: "I"}[tx]
|
||||||
|
else: raise ValueError("Unsupported configuration type")
|
||||||
|
size = struct.calcsize(">" + fmt)
|
||||||
|
if offset + size > len(data): raise ValueError("Truncated configuration")
|
||||||
|
value = struct.unpack_from(">" + fmt, data, offset)[0]; offset += size
|
||||||
|
if kind == 1:
|
||||||
|
if tx == 9:
|
||||||
|
exponent, fraction = (value >> 23) & 255, value & 0x7fffff
|
||||||
|
part = fraction / 16777216.0 + 0.5 if exponent or fraction else 0.0
|
||||||
|
value = math.ldexp(-part if value & 0x80000000 else part, exponent - 126)
|
||||||
|
else: value /= float(p.findtext("vTxDoubleScale", "1"))
|
||||||
|
if not math.isfinite(value): raise ValueError("Non-finite parameter")
|
||||||
|
result[name] = value
|
||||||
|
if offset != len(data): raise ValueError("Unexpected configuration tail")
|
||||||
|
return result
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Owner-assigned drive slots, independent of USB addresses and motor commands."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
LAYOUTS = {"1x1": ("left.1", "right.1"), "2x2": ("left.1", "left.2", "right.1", "right.2")}
|
||||||
|
|
||||||
|
def slot_label(layout, slot):
|
||||||
|
side = "Левый" if slot.startswith("left.") else "Правый"
|
||||||
|
return side + ((" передний" if slot.endswith(".1") else " задний") if layout == "2x2" else "")
|
||||||
|
|
||||||
|
|
||||||
|
class DriveProfile:
|
||||||
|
def __init__(self, root, atomic):
|
||||||
|
self.path, self.atomic = root / "drive-profile.json", atomic
|
||||||
|
self.value = json.loads(self.path.read_text()) if self.path.exists() else {"layout": None, "revision": 0, "bindings": {}}
|
||||||
|
|
||||||
|
def update(self, action, params, device):
|
||||||
|
current = self.value
|
||||||
|
if params["revision"] != current["revision"]:
|
||||||
|
raise ValueError("Профиль привода изменился. Обновите карточку.")
|
||||||
|
bindings = dict(current["bindings"])
|
||||||
|
layout = current["layout"]
|
||||||
|
if action == "vesc.drive.layout":
|
||||||
|
layout = params["layout"]
|
||||||
|
if set(bindings) - set(LAYOUTS[layout]):
|
||||||
|
raise ValueError("Сначала снимите назначения задних моторов.")
|
||||||
|
elif action == "vesc.drive.assign":
|
||||||
|
layout, slot = params["layout"], params["slot"]
|
||||||
|
if set(bindings) - set(LAYOUTS[layout]):
|
||||||
|
raise ValueError("Сначала снимите назначения задних моторов.")
|
||||||
|
if slot and slot in bindings and bindings[slot]["device_id"] != device.id:
|
||||||
|
raise ValueError("Это место уже занято. Сначала снимите прежнее назначение.")
|
||||||
|
bindings = {key: value for key, value in bindings.items() if value["device_id"] != device.id}
|
||||||
|
if slot:
|
||||||
|
bindings[slot] = {"device_id": device.id, "uuid": device.identity["uuid"]}
|
||||||
|
else:
|
||||||
|
bindings.pop(params["slot"], None)
|
||||||
|
value = {"layout": layout, "revision": current["revision"] + 1, "bindings": bindings}
|
||||||
|
self.atomic(self.path, value)
|
||||||
|
self.value = value
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate(action, params):
|
||||||
|
keys = {"revision", "layout"} if action == "vesc.drive.layout" else ({"revision", "layout", "slot"} if action == "vesc.drive.assign" else {"revision", "slot"})
|
||||||
|
if set(params) != keys or type(params["revision"]) is not int or params["revision"] < 0:
|
||||||
|
raise ValueError("Invalid drive profile revision")
|
||||||
|
if action == "vesc.drive.layout":
|
||||||
|
if params["layout"] not in LAYOUTS:
|
||||||
|
raise ValueError("Invalid drive layout")
|
||||||
|
elif action == "vesc.drive.assign":
|
||||||
|
if params["layout"] not in LAYOUTS or params["slot"] not in ("", *LAYOUTS[params["layout"]]):
|
||||||
|
raise ValueError("Invalid drive layout or slot")
|
||||||
|
elif params["slot"] not in LAYOUTS["2x2"]:
|
||||||
|
raise ValueError("Invalid drive slot")
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Transaction lifecycle around unchanged upstream Utility::detectAllFoc.
|
||||||
|
|
||||||
|
No FOC algorithm or wire writes here. Native firmware owns this non-interruptible
|
||||||
|
cycle; a pending marker survives any uncertain completion and prevents replay.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from .protocol import firmware, values
|
||||||
|
from .configuration import decode
|
||||||
|
from .receiver import neutral_band
|
||||||
|
|
||||||
|
|
||||||
|
def fresh_values(owner, devices):
|
||||||
|
# FW 5.02 GET_VALUES reads AND resets accumulated average current. The
|
||||||
|
# first reply after a quiet 30 s calibration includes the entire cycle.
|
||||||
|
# Drain it, then measure a new bounded interval after the release command.
|
||||||
|
for device in devices:
|
||||||
|
values(device.link.query(4, timeout=0.2))
|
||||||
|
owner.sleep(0.25)
|
||||||
|
return {device.id: values(device.link.query(4, timeout=0.2)) for device in devices}
|
||||||
|
|
||||||
|
|
||||||
|
def remove_pending(pending):
|
||||||
|
pending.unlink()
|
||||||
|
fd = os.open(pending.parent, os.O_RDONLY)
|
||||||
|
try: os.fsync(fd)
|
||||||
|
finally: os.close(fd)
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile(owner, devices):
|
||||||
|
"""Explicit neutral-return recovery, never calibration or config replay.
|
||||||
|
|
||||||
|
Admits only a previously completed/verified native receipt, exact archived
|
||||||
|
post-configs for every peer, stable identity, neutral PPM and fresh idle
|
||||||
|
telemetry. Unknown native completion always remains blocked.
|
||||||
|
"""
|
||||||
|
if any(device.link is None for device in devices): raise ValueError("Controller disconnected")
|
||||||
|
pending = owner.service.root / "calibration-pending.json"
|
||||||
|
record = json.loads(pending.read_text())
|
||||||
|
operation = record.get("operation_id", "")
|
||||||
|
if not re.fullmatch(r"op_[0-9a-f]{32}", operation): raise ValueError("Invalid pending operation")
|
||||||
|
receipt = json.loads((owner.service.root / (operation + ".json")).read_text())["receipt"]
|
||||||
|
result = receipt.get("result", {})
|
||||||
|
native = result.get("native", {})
|
||||||
|
if not (receipt.get("state") == "complete" and result.get("completed") is True
|
||||||
|
and result.get("configuration_verified") is True and native.get("validated") is True):
|
||||||
|
raise ValueError("Native completion/configuration is unconfirmed")
|
||||||
|
expected = {}
|
||||||
|
for identifier in result.get("after_backups", []):
|
||||||
|
if not re.fullmatch(r"op_[0-9a-f]{32}", identifier): raise ValueError("Invalid backup identity")
|
||||||
|
backup = json.loads((owner.service.root / ("backup_" + identifier + ".json")).read_text())
|
||||||
|
if backup.get("parent_operation_id") != operation or backup["device_id"] in expected:
|
||||||
|
raise ValueError("Calibration backup ownership differs")
|
||||||
|
expected[backup["device_id"]] = backup
|
||||||
|
if set(expected) != {d.id for d in devices}: raise ValueError("Controller set changed")
|
||||||
|
for device in devices:
|
||||||
|
backup = expected[device.id]
|
||||||
|
if firmware(device.link.query(0)) != backup["identity"]: raise ValueError("Controller identity changed")
|
||||||
|
for kind, code in (("motor",14),("application",17)):
|
||||||
|
actual = device.link.query(code)
|
||||||
|
if actual != base64.b64decode(backup["configs"][kind]["payload"], validate=True):
|
||||||
|
raise ValueError("Post-calibration configuration changed")
|
||||||
|
if kind == "application":
|
||||||
|
owner.receiver_bands[device.id] = neutral_band(decode(actual, kind))
|
||||||
|
owner.neutral(devices)
|
||||||
|
after = fresh_values(owner, devices)
|
||||||
|
if any(abs(v["motor_current_a"]) > 1 or abs(v["erpm"]) > 30 or abs(v["duty"]) > .01 or v["fault_code"] != 0 for v in after.values()):
|
||||||
|
raise ValueError("Fresh idle state unconfirmed")
|
||||||
|
evidence = {"operation_id": operation, "observed_at": owner.utc(), "after": after,
|
||||||
|
"configuration_verified": True, "release_confirmed": True, "calibration_replayed": False}
|
||||||
|
owner.atomic(owner.service.root / ("calibration_recovered_" + operation + ".json"), evidence)
|
||||||
|
remove_pending(pending)
|
||||||
|
return evidence
|
||||||
|
|
||||||
|
|
||||||
|
def archive_after(owner, command, device, configs):
|
||||||
|
identifier = "op_" + uuid.uuid4().hex
|
||||||
|
backup = {"schema": "missioncore.vesc.config-backup/v1", "device_id": device.id,
|
||||||
|
"identity": device.identity, "operation_id": identifier,
|
||||||
|
"parent_operation_id": command["operation_id"], "observed_at": owner.utc(),
|
||||||
|
"monotonic_at": owner.monotonic(), "decoded": False,
|
||||||
|
"configs": {kind: {"encoding": "base64", "payload": base64.b64encode(raw).decode(),
|
||||||
|
"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest(), "signature_hex": raw[1:5].hex()}
|
||||||
|
for kind, raw in configs.items()}}
|
||||||
|
owner.atomic(owner.service.root / ("backup_" + identifier + ".json"), backup)
|
||||||
|
owner.service.archive.add("local", backup)
|
||||||
|
device.backup = {"observed_at": backup["observed_at"], "operation_id": identifier,
|
||||||
|
"configs": {k: {"bytes": v["bytes"], "sha256": v["sha256"]} for k, v in backup["configs"].items()}}
|
||||||
|
return identifier
|
||||||
|
|
||||||
|
|
||||||
|
def calibrate(owner, command, devices, target, originals, backups, unchanged):
|
||||||
|
pending = owner.service.root / "calibration-pending.json"
|
||||||
|
owner.atomic(pending, {"device_id": target.id, "identity": target.identity,
|
||||||
|
"started_at": owner.utc(), "operation_id": command["operation_id"], "backups": backups})
|
||||||
|
owner.state("calibrating")
|
||||||
|
owner.active, owner.mode = True, "foc"
|
||||||
|
started = owner.monotonic()
|
||||||
|
native, after, issues, saved = {}, {}, [], []
|
||||||
|
verified, attempted = False, False
|
||||||
|
try:
|
||||||
|
unchanged()
|
||||||
|
owner.neutral(devices)
|
||||||
|
if owner.stop.is_set(): raise ValueError("Cancelled before calibration")
|
||||||
|
# Do not renew a 250 ms lease over the upstream 180 s calibration lease.
|
||||||
|
# No host current/RPM or configuration command is sent while it runs.
|
||||||
|
for device in devices: device.link.test_command("release")
|
||||||
|
attempted = True
|
||||||
|
target.link.calibrate_foc(command["parameters"]["max_power_loss_w"])
|
||||||
|
while owner.monotonic() - started < 225:
|
||||||
|
unchanged()
|
||||||
|
state = target.link.procedure_result()
|
||||||
|
if not state.get("running"):
|
||||||
|
native = state.get("result", {})
|
||||||
|
if state.get("uncertain"): issues.append("native_postcondition_unconfirmed")
|
||||||
|
break
|
||||||
|
if owner.stop.is_set() and "stop_requested_during_native_cycle" not in issues:
|
||||||
|
issues.append("stop_requested_during_native_cycle")
|
||||||
|
owner.sleep(0.5)
|
||||||
|
if not native.get("completed"): issues.append("native_completion_unconfirmed")
|
||||||
|
except (OSError, ValueError, TimeoutError):
|
||||||
|
issues.append("communication_unconfirmed")
|
||||||
|
finally:
|
||||||
|
for device in devices:
|
||||||
|
try: device.link.test_command("release")
|
||||||
|
except (OSError, ValueError, TimeoutError): issues.append("release_unconfirmed")
|
||||||
|
owner.sleep(0.5)
|
||||||
|
try: after = fresh_values(owner, devices)
|
||||||
|
except (OSError, ValueError, TimeoutError): after = {device.id: None for device in devices}
|
||||||
|
released = all(v is not None and abs(v["motor_current_a"]) <= 1 and abs(v["duty"]) <= .01 for v in after.values())
|
||||||
|
if not attempted or native.get("completed"):
|
||||||
|
try:
|
||||||
|
unchanged()
|
||||||
|
for device in devices:
|
||||||
|
if firmware(device.link.query(0)) != device.identity: raise ValueError("Identity changed")
|
||||||
|
configs = {kind: device.link.query(code) for kind, code in (("motor",14),("application",17))}
|
||||||
|
saved.append(archive_after(owner, command, device, configs))
|
||||||
|
if configs["application"] != originals[device.id]["application"]: raise ValueError("Receiver config changed")
|
||||||
|
if (device is not target or not attempted or not native.get("success")) and configs["motor"] != originals[device.id]["motor"]:
|
||||||
|
raise ValueError("Unchanged/restored motor config differs")
|
||||||
|
verified = not attempted or native.get("validated") is True
|
||||||
|
except (OSError, ValueError, TimeoutError): issues.append("configuration_unconfirmed")
|
||||||
|
if verified and released:
|
||||||
|
remove_pending(pending)
|
||||||
|
if not owner.latched: owner.state("ready")
|
||||||
|
else: owner.state("rc")
|
||||||
|
owner.active, owner.mode = False, None
|
||||||
|
return {"observed_at": owner.utc(), "device_id": target.id, "procedure": "native_foc_calibration",
|
||||||
|
"elapsed_s": owner.monotonic() - started, "completed": native.get("completed", False),
|
||||||
|
"success": bool(native.get("success") and verified and released), "native": native,
|
||||||
|
"configuration_verified": verified, "release_confirmed": released, "after": after,
|
||||||
|
"issues": issues, "backups": backups, "after_backups": saved}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""One local, bounded speed test for the complete owner-assigned drive profile."""
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from .drive_profile import LAYOUTS
|
||||||
|
from .protocol import ppm, values, TEST_LIMITS
|
||||||
|
from .speed_hold import SpeedHold
|
||||||
|
|
||||||
|
|
||||||
|
def targets(service, params, devices, selected):
|
||||||
|
profile = service.drive.value
|
||||||
|
bindings = profile["bindings"]
|
||||||
|
slots = LAYOUTS.get(profile["layout"], ())
|
||||||
|
if (not slots or params["profile_revision"] != profile["revision"]
|
||||||
|
or set(bindings) != set(slots)):
|
||||||
|
raise ValueError("Профиль изменился или не все моторы назначены.")
|
||||||
|
ids = [bindings[slot]["device_id"] for slot in slots]
|
||||||
|
if len(set(ids)) != len(ids) or set(params["device_ids"]) != set(ids) or selected.id not in ids:
|
||||||
|
raise ValueError("Состав проверяемого привода не совпадает с профилем.")
|
||||||
|
available = {device.id: device for device in devices}
|
||||||
|
if any(identifier not in available for identifier in ids):
|
||||||
|
raise ValueError("Один из назначенных моторов отключён.")
|
||||||
|
if any(available[b["device_id"]].identity["uuid"] != b["uuid"] for b in bindings.values()):
|
||||||
|
raise ValueError("Идентичность назначенного VESC изменилась.")
|
||||||
|
return [available[identifier] for identifier in ids]
|
||||||
|
|
||||||
|
|
||||||
|
def batch(pool, devices, function):
|
||||||
|
# Join every submitted call before propagating an error: no late worker may
|
||||||
|
# issue torque after the caller has already released the other controllers.
|
||||||
|
futures = [(device.id, pool.submit(function, device)) for device in devices]
|
||||||
|
result, errors = {}, []
|
||||||
|
for identifier, future in futures:
|
||||||
|
try: result[identifier] = future.result()
|
||||||
|
except (OSError, ValueError, TimeoutError) as error:
|
||||||
|
error.device_id = identifier
|
||||||
|
errors.append(error)
|
||||||
|
if errors: raise errors[0]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def run(owner, command, devices, moving, originals, backups, unchanged, preflight):
|
||||||
|
from .motor_test import Rejected, check_values
|
||||||
|
params = command["parameters"]
|
||||||
|
duration, erpm, current_a = (params[key] for key in ("duration_s", "erpm", "current_a"))
|
||||||
|
ids = {device.id for device in moving}
|
||||||
|
motors, samples, after, cleanup = {}, [], {}, {}
|
||||||
|
restored, failure, rotation, previous_good = True, None, 0.0, False
|
||||||
|
outcome, claimed, started, previous = "duration", False, owner.monotonic(), owner.monotonic()
|
||||||
|
stalls = {}
|
||||||
|
owner.state("testing")
|
||||||
|
owner.active, owner.mode = True, "group_speed"
|
||||||
|
with ThreadPoolExecutor(max_workers=min(16, len(devices)), thread_name_prefix="vesc-drive") as pool:
|
||||||
|
try:
|
||||||
|
for device in moving:
|
||||||
|
motors[device.id] = owner.limits.apply(device, originals[device.id]["motor"], current_a)
|
||||||
|
started = previous = owner.monotonic()
|
||||||
|
holds = {device.id: SpeedHold(erpm, duration, started) for device in moving}
|
||||||
|
while owner.monotonic() - started < duration + 20:
|
||||||
|
cycle = owner.monotonic()
|
||||||
|
if owner.stop.is_set(): outcome = "stopped"; break
|
||||||
|
unchanged()
|
||||||
|
def read(device):
|
||||||
|
level = ppm(device.link.query(31, timeout=.06))["level"]
|
||||||
|
value = values(device.link.query(4, timeout=.06)) if device.id in ids else None
|
||||||
|
return level, value
|
||||||
|
observed = batch(pool, devices, read)
|
||||||
|
if any(owner.receiver_active(identifier, level) for identifier, (level, _) in observed.items()):
|
||||||
|
owner.state("rc")
|
||||||
|
raise Rejected("Приёмник передаёт команду. Общая проверка остановлена; управление за пультом.")
|
||||||
|
now = owner.monotonic()
|
||||||
|
readings = {identifier: observed[identifier][1] for identifier in ids}
|
||||||
|
setpoint = None
|
||||||
|
for identifier, value in readings.items():
|
||||||
|
check_values(value, moving=True, current_a=current_a, motor=motors[identifier])
|
||||||
|
setpoint, _, error = holds[identifier].update(now, value)
|
||||||
|
if error: raise Rejected(error)
|
||||||
|
if abs(value["motor_current_a"]) > TEST_LIMITS["stall_current_a"]:
|
||||||
|
at, tacho = stalls.setdefault(identifier, (now, value["tachometer"]))
|
||||||
|
if abs(value["erpm"]) >= 60 and abs(value["tachometer"] - tacho) >= 3:
|
||||||
|
stalls[identifier] = (now, value["tachometer"])
|
||||||
|
elif now - at >= TEST_LIMITS["stall_timeout_s"]:
|
||||||
|
raise Rejected("Один из моторов не движется при токе выше 5 А. Общая проверка остановлена.")
|
||||||
|
else: stalls.pop(identifier, None)
|
||||||
|
good = all(h.hold_started is not None and h.previous_good for h in holds.values())
|
||||||
|
delta = now - previous
|
||||||
|
if good and previous_good and delta <= .25: rotation += delta
|
||||||
|
previous, previous_good = now, good
|
||||||
|
sample = {"at": now-started, "devices": readings, "rotation_s": rotation,
|
||||||
|
"phase": "holding" if good else "accelerating", "commanded_erpm": None}
|
||||||
|
samples.append(sample)
|
||||||
|
if rotation >= duration: break
|
||||||
|
if owner.monotonic()-cycle > .12: raise Rejected("Связь слишком медленная для общей проверки.")
|
||||||
|
claimed = True
|
||||||
|
batch(pool, devices, lambda d: d.link.test_command("claim"))
|
||||||
|
if owner.stop.is_set(): outcome = "stopped"; break
|
||||||
|
if owner.monotonic()-cycle > .16: raise Rejected("Связь слишком медленная для общей проверки.")
|
||||||
|
def send(device):
|
||||||
|
if owner.stop.is_set(): return
|
||||||
|
if device.id in ids: device.link.test_speed(setpoint)
|
||||||
|
else: device.link.test_command("release")
|
||||||
|
sent_at = owner.monotonic()
|
||||||
|
batch(pool, devices, send)
|
||||||
|
sample.update(commanded_erpm=setpoint, command_batch_s=owner.monotonic()-sent_at)
|
||||||
|
owner.sleep(max(0, .1-(owner.monotonic()-cycle)))
|
||||||
|
if outcome == "duration" and rotation < duration:
|
||||||
|
outcome = "Общий срок проверки истёк; заданное время совместного вращения не набрано."
|
||||||
|
except (OSError, ValueError, TimeoutError) as error:
|
||||||
|
failure = {"type": type(error).__name__, "message": str(error), "elapsed_s": owner.monotonic()-started}
|
||||||
|
failure["device_id"] = getattr(error, "device_id", None)
|
||||||
|
failure["native_rpc"] = getattr(error, "native_rpc", None)
|
||||||
|
failure["native_history"] = getattr(error, "native_history", [])
|
||||||
|
outcome = str(error) if isinstance(error, Rejected) else "Ответ одного из VESC не получен вовремя. Общая проверка остановлена."
|
||||||
|
finally:
|
||||||
|
def release(device):
|
||||||
|
try:
|
||||||
|
device.link.test_command("release")
|
||||||
|
return "zero_current_sent"
|
||||||
|
except (OSError, ValueError, TimeoutError): return "unconfirmed"
|
||||||
|
if claimed: cleanup = batch(pool, devices, release)
|
||||||
|
owner.sleep(.3)
|
||||||
|
for device in devices:
|
||||||
|
try: after[device.id] = values(device.link.query(4, timeout=.2))
|
||||||
|
except (OSError, ValueError, TimeoutError): after[device.id] = None
|
||||||
|
confirmed = all(v is not None and abs(v["motor_current_a"]) <= 1 for v in after.values())
|
||||||
|
for device in moving:
|
||||||
|
try: owner.limits.restore(device)
|
||||||
|
except (OSError, ValueError, TimeoutError): restored = False
|
||||||
|
if not confirmed or not restored: owner.state("rc")
|
||||||
|
elif not owner.latched: owner.state("ready")
|
||||||
|
owner.active, owner.mode = False, None
|
||||||
|
return {"observed_at": owner.utc(), "device_ids": [d.id for d in moving], "mode": "group_speed",
|
||||||
|
"preflight": preflight, "profile_revision": params["profile_revision"], "current_a": current_a, "erpm_target": erpm,
|
||||||
|
"duration_limit_s": duration, "rotation_s": rotation, "outcome": outcome, "failure": failure,
|
||||||
|
"samples": samples, "release": cleanup, "release_confirmed": confirmed,
|
||||||
|
"limits_restored": restored, "after": after, "backups": backups}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Native FW 5.02 Hall measurement used by VESC Tool (COMM_DETECT_HALL_FOC).
|
||||||
|
|
||||||
|
This is a non-interruptible ~12 s firmware procedure: it locks mc_interface,
|
||||||
|
overrides phase, sweeps three electrical turns in each direction and restores
|
||||||
|
its prior RAM configuration. A host stop/current-zero cannot cancel the sweep.
|
||||||
|
No table is applied here; measurements are archived in the operation receipt.
|
||||||
|
"""
|
||||||
|
from .configuration import decode
|
||||||
|
from .protocol import values, ppm
|
||||||
|
|
||||||
|
|
||||||
|
def parse_result(raw):
|
||||||
|
if len(raw) != 10 or raw[0] != 28 or raw[9] not in (0, 1):
|
||||||
|
raise ValueError("Invalid Hall result")
|
||||||
|
table = list(raw[1:9])
|
||||||
|
if any(v > 200 and v != 255 for v in table): raise ValueError("Invalid Hall angle")
|
||||||
|
observed = [i for i, v in enumerate(table) if v != 255]
|
||||||
|
return {"firmware_success": raw[9] == 0, "hall_table": table,
|
||||||
|
"observed_states": observed, "valid_six_states": raw[9] == 0 and len(observed) == 6}
|
||||||
|
|
||||||
|
|
||||||
|
def measure(owner, devices, target, original, backups, unchanged, preflight):
|
||||||
|
motor = decode(original, "motor")
|
||||||
|
if motor["m_sensor_port_mode"] != 0:
|
||||||
|
from .motor_test import Rejected
|
||||||
|
raise Rejected("Вход датчиков выбран не в режиме Холла.")
|
||||||
|
pending = owner.service.root / "hall-pending.json"
|
||||||
|
owner.atomic(pending, {"device_id": target.id, "identity": target.identity,
|
||||||
|
"started_at": owner.utc(), "backups": backups})
|
||||||
|
owner.state("calibrating")
|
||||||
|
owner.active, owner.mode = True, "hall"
|
||||||
|
started = owner.monotonic()
|
||||||
|
samples, issues, result, after = [], [], None, {}
|
||||||
|
completed, restored, attempted = False, False, False
|
||||||
|
try:
|
||||||
|
unchanged()
|
||||||
|
owner.neutral(devices)
|
||||||
|
# Do not enter the native procedure if Stop arrived during preflight.
|
||||||
|
if owner.stop.is_set(): raise ValueError("Measurement cancelled before start")
|
||||||
|
for device in devices: device.link.test_command("claim")
|
||||||
|
attempted = True
|
||||||
|
target.link.detect_hall()
|
||||||
|
while owner.monotonic() - started < 30:
|
||||||
|
cycle = owner.monotonic()
|
||||||
|
try:
|
||||||
|
unchanged()
|
||||||
|
for device in devices:
|
||||||
|
incoming = ppm(device.link.query(31, timeout=0.06))
|
||||||
|
if owner.receiver_active(device.id, incoming["level"]):
|
||||||
|
owner.state("rc")
|
||||||
|
if "rc_during_native_cycle" not in issues: issues.append("rc_during_native_cycle")
|
||||||
|
sample = values(target.link.query(4, timeout=0.06))
|
||||||
|
samples.append({"at": owner.monotonic() - started, "values": sample})
|
||||||
|
for device in devices: device.link.test_command("claim")
|
||||||
|
for device in devices:
|
||||||
|
if device is not target: device.link.test_command("release")
|
||||||
|
except (OSError, ValueError, TimeoutError):
|
||||||
|
if "communication_unconfirmed" not in issues: issues.append("communication_unconfirmed")
|
||||||
|
if owner.stop.is_set() and "stop_requested_during_native_cycle" not in issues:
|
||||||
|
issues.append("stop_requested_during_native_cycle")
|
||||||
|
reply = target.link.hall_result
|
||||||
|
if reply is not None:
|
||||||
|
result = parse_result(reply)
|
||||||
|
completed = True
|
||||||
|
break
|
||||||
|
owner.sleep(max(0, 0.1 - (owner.monotonic() - cycle)))
|
||||||
|
except (OSError, ValueError, TimeoutError):
|
||||||
|
issues.append("native_completion_unconfirmed")
|
||||||
|
finally:
|
||||||
|
# Zero is a release AFTER completion, never a claim that native detect
|
||||||
|
# is interruptible. Peers also receive release if the target disappears.
|
||||||
|
for device in devices:
|
||||||
|
try: device.link.test_command("release")
|
||||||
|
except (OSError, ValueError, TimeoutError): issues.append("release_unconfirmed")
|
||||||
|
owner.sleep(0.3)
|
||||||
|
for device in devices:
|
||||||
|
try: after[device.id] = values(device.link.query(4, timeout=0.1))
|
||||||
|
except (OSError, ValueError, TimeoutError): after[device.id] = None
|
||||||
|
if completed or not attempted:
|
||||||
|
target.link.hall_pending = False
|
||||||
|
try: restored = target.link.query(14) == original
|
||||||
|
except (OSError, ValueError, TimeoutError): pass
|
||||||
|
released = all(v is not None and abs(v["motor_current_a"]) <= 1 for v in after.values())
|
||||||
|
if (completed or not attempted) and restored and released:
|
||||||
|
pending.unlink()
|
||||||
|
import os
|
||||||
|
fd = os.open(pending.parent, os.O_RDONLY)
|
||||||
|
try: os.fsync(fd)
|
||||||
|
finally: os.close(fd)
|
||||||
|
if not owner.latched: owner.state("ready")
|
||||||
|
else:
|
||||||
|
owner.state("rc")
|
||||||
|
owner.active, owner.mode = False, None
|
||||||
|
return {"observed_at": owner.utc(), "device_id": target.id, "procedure": "native_foc_hall",
|
||||||
|
"preflight": preflight,
|
||||||
|
"current_a": 5, "elapsed_s": owner.monotonic() - started,
|
||||||
|
"completed": completed, "measurement": result, "issues": issues,
|
||||||
|
"configuration_restored": restored, "configuration_written": False,
|
||||||
|
"release_confirmed": released, "after": after, "samples": samples, "backups": backups}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Read controller settings using the bundled upstream VESC Tool decoder."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
FIELDS = frozenset({"l_current_max", "l_current_min", "l_current_max_scale", "l_current_min_scale",
|
||||||
|
"l_in_current_max", "l_in_current_min", "l_min_erpm", "l_max_erpm", "l_max_duty",
|
||||||
|
"l_watt_max", "l_watt_min", "si_motor_poles", "si_gear_ratio", "si_wheel_diameter"})
|
||||||
|
|
||||||
|
|
||||||
|
def read_limits(link):
|
||||||
|
configuration = link.configuration()
|
||||||
|
result = {}
|
||||||
|
for parameter in configuration["motor"]["parameters"]:
|
||||||
|
name, value = parameter["name"], parameter.get("value")
|
||||||
|
if name in FIELDS and type(value) in (int, float) and math.isfinite(value):
|
||||||
|
result[name] = value
|
||||||
|
if set(result) != FIELDS:
|
||||||
|
raise ValueError("Native configuration is missing limit fields")
|
||||||
|
return result
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Bounded idle transport measurement through the installed native owners.
|
||||||
|
|
||||||
|
Only application/PPM/telemetry reads, no leases, motor commands or configuration writes.
|
||||||
|
The 500 ms diagnostic deadline observes replies beyond the 60 ms motor budget;
|
||||||
|
it never relaxes the motor-control deadline or authorizes powered operation.
|
||||||
|
"""
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from contextlib import ExitStack
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .protocol import ppm, values
|
||||||
|
from .configuration import decode
|
||||||
|
from .receiver import active, neutral_band
|
||||||
|
|
||||||
|
|
||||||
|
def summary(samples):
|
||||||
|
times = sorted(s["elapsed_ms"] for s in samples)
|
||||||
|
if not times: return {"replies": 0}
|
||||||
|
def percentile(p): return times[max(0, math.ceil(len(times)*p)-1)]
|
||||||
|
return {"replies": len(times), "p50_ms": percentile(.5), "p95_ms": percentile(.95),
|
||||||
|
"p99_ms": percentile(.99), "max_ms": times[-1],
|
||||||
|
"over_60_ms": sum(t > 60 for t in times)}
|
||||||
|
|
||||||
|
|
||||||
|
def measure(service, command, devices, *, sleep=time.sleep, monotonic=time.monotonic):
|
||||||
|
started = monotonic()
|
||||||
|
observed = datetime.now(timezone.utc).isoformat()
|
||||||
|
deadline = datetime.fromisoformat(command["deadline_at"].replace("Z", "+00:00"))
|
||||||
|
ids = {d.id: d.session for d in devices}
|
||||||
|
if not devices or len(ids) != len(devices) or ids != command["parameters"]["sessions"]:
|
||||||
|
raise ValueError("Controller sessions changed")
|
||||||
|
samples = {d.id: [] for d in devices}
|
||||||
|
bands = {}
|
||||||
|
failure = None
|
||||||
|
stop_reason = "complete"
|
||||||
|
def interrupted():
|
||||||
|
cancelled = service.motor.cancelled_at
|
||||||
|
if cancelled is not None and cancelled >= datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00")):
|
||||||
|
return "stopped"
|
||||||
|
if monotonic()-started >= 25 or (deadline-datetime.now(timezone.utc)).total_seconds() < 2:
|
||||||
|
return "deadline"
|
||||||
|
return None
|
||||||
|
def failed(device, code, before, error):
|
||||||
|
return {"device_id": device.id, "command": code, "reason": "read_failed", "error": str(error),
|
||||||
|
"elapsed_ms": (monotonic()-before)*1000,
|
||||||
|
"native_rpc": getattr(error, "native_rpc", None),
|
||||||
|
"native_history": getattr(error, "native_history", [])}
|
||||||
|
def read(device):
|
||||||
|
for code in (31, 4):
|
||||||
|
before = monotonic()
|
||||||
|
try:
|
||||||
|
raw = device.link.query(code, timeout=.5)
|
||||||
|
reading = ppm(raw) if code == 31 else values(raw)
|
||||||
|
sample = {"command": code, "at_s": before-started, "elapsed_ms": (monotonic()-before)*1000,
|
||||||
|
"native_rpc": getattr(device.link, "last_rpc", None)}
|
||||||
|
if code == 31:
|
||||||
|
sample["ppm_level"] = reading["level"]
|
||||||
|
idle = not active(reading["level"], bands[device.id])
|
||||||
|
else:
|
||||||
|
sample.update(erpm=reading["erpm"], current_a=reading["motor_current_a"],
|
||||||
|
duty=reading["duty"], fault_code=reading["fault_code"])
|
||||||
|
idle = abs(reading["erpm"]) <= 30 and abs(reading["motor_current_a"]) <= 1 and abs(reading["duty"]) <= .01
|
||||||
|
samples[device.id].append(sample)
|
||||||
|
if not idle: return {"device_id": device.id, "reason": "not_idle"}
|
||||||
|
except (OSError, ValueError, TimeoutError) as error:
|
||||||
|
return failed(device, code, before, error)
|
||||||
|
return None
|
||||||
|
with ExitStack() as locks:
|
||||||
|
for device in sorted(devices, key=lambda d: d.id): locks.enter_context(device.lock)
|
||||||
|
if any(d.link is None for d in devices): raise ValueError("Controller unavailable")
|
||||||
|
for device in devices:
|
||||||
|
if reason := interrupted():
|
||||||
|
stop_reason = reason; break
|
||||||
|
before = monotonic()
|
||||||
|
try:
|
||||||
|
bands[device.id] = neutral_band(decode(device.link.query(17), "application"))
|
||||||
|
except (OSError, ValueError, TimeoutError) as error:
|
||||||
|
failure = [failed(device, 17, before, error)]
|
||||||
|
stop_reason = "read_failed"; break
|
||||||
|
# Same cadence and per-controller query order as group rotation, with a
|
||||||
|
# larger read-only deadline to expose latency instead of destroying it.
|
||||||
|
with ThreadPoolExecutor(max_workers=min(16, len(devices))) as pool:
|
||||||
|
for _ in range(100):
|
||||||
|
if stop_reason != "complete": break
|
||||||
|
cycle = monotonic()
|
||||||
|
if reason := interrupted():
|
||||||
|
stop_reason = reason; break
|
||||||
|
futures = [pool.submit(read, d) for d in devices]
|
||||||
|
errors = [error for future in futures if (error := future.result()) is not None]
|
||||||
|
if errors:
|
||||||
|
failure = errors; stop_reason = errors[0]["reason"]; break
|
||||||
|
sleep(max(0, .1-(monotonic()-cycle)))
|
||||||
|
return {"observed_at": observed, "monotonic_started": started, "duration_s": monotonic()-started,
|
||||||
|
"outcome": stop_reason, "motor_commands_sent": False, "read_timeout_ms": 500,
|
||||||
|
"failure": failure, "devices": {d.id: {"name": "VESC "+d.identity["uuid"][:6].upper(),
|
||||||
|
"neutral_band": bands.get(d.id), "summary": summary(samples[d.id]),
|
||||||
|
"samples": samples[d.id]} for d in devices}}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
"""A bounded 0.5–30 A raised-rig test for firmware 5.02, never a drive API.
|
||||||
|
|
||||||
|
Firmware reference: vedderb/bldc 3f670137e27e6e383fa79c50cc6b1fa85aab1554,
|
||||||
|
commands.c, app_ppm.c, app.c and chvt.h. The admitted PPM applications are paused
|
||||||
|
with separate 250 ms leases, never broadcast. Firmware resumes PPM on expiry,
|
||||||
|
including its missing-pulse timeout. USB commands alone cannot rely on the
|
||||||
|
global timeout: receiver pulses reset it even while app output is disabled.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
from contextlib import ExitStack
|
||||||
|
import hashlib
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from .configuration import decode
|
||||||
|
from .protocol import firmware, ppm, values, TEST_LIMITS, SPEED_LIMITS
|
||||||
|
from .speed_hold import SpeedHold
|
||||||
|
from .temporary_limits import TemporaryLimits
|
||||||
|
from .receiver import active as receiver_active, neutral_band
|
||||||
|
|
||||||
|
|
||||||
|
class Rejected(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LimitExceeded(Rejected):
|
||||||
|
def __init__(self, field, value, low, high, label, unit, scale=1):
|
||||||
|
self.violation = {"field": field, "value": value, "minimum": low, "maximum": high}
|
||||||
|
number = lambda v: format(v * scale, ".3g").replace(".", ",")
|
||||||
|
super().__init__(f"Тест остановлен: {label} {number(value)} {unit}; диапазон проверки {number(low)}…{number(high)} {unit}.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_configuration(identity, motor, app):
|
||||||
|
if (identity["version"], identity["hardware"], identity["test_firmware"], identity["hardware_type"]) != ("5.02", "75_300_R2", 0, 0):
|
||||||
|
raise Rejected("Тест поддерживает только проверенный профиль VESC 75_300_R2 / 5.02.")
|
||||||
|
if (motor["motor_type"] != 2 or app["app_to_use"] not in (1, 4)
|
||||||
|
or app["timeout_msec"] > 1000 or app["timeout_msec"] < 100
|
||||||
|
or app["timeout_brake_current"] != 0 or app["app_ppm_conf.ctrl_type"] != 4
|
||||||
|
or not 0.01 <= app["app_ppm_conf.hyst"] <= 0.3):
|
||||||
|
raise Rejected("Настройки FOC, PPM или тайм-аута не подходят для короткой проверки.")
|
||||||
|
if not 2 <= motor["l_current_max"] <= 100 or not 2 <= motor["l_in_current_max"] <= 100:
|
||||||
|
raise Rejected("Нужна проверка токовых ограничений.")
|
||||||
|
|
||||||
|
|
||||||
|
def check_values(value, moving=False, current_a=2, motor=None, *, standstill_confirmed=False):
|
||||||
|
current_limit = max(5, current_a * 1.2 + 2) if moving else 1
|
||||||
|
speed_limit = TEST_LIMITS["max_erpm"] if moving else 30
|
||||||
|
duty_limit = TEST_LIMITS["max_duty"] if moving else 0.01
|
||||||
|
if moving and motor is not None:
|
||||||
|
current_limit = min(current_limit, motor["l_current_max"])
|
||||||
|
speed_limit = min(speed_limit, motor["l_max_erpm"], -motor["l_min_erpm"])
|
||||||
|
duty_limit = min(duty_limit, motor["l_max_duty"])
|
||||||
|
# FW 5.02 continues its observer/PLL while undriven. Sensorless ERPM and
|
||||||
|
# tachometer share that estimate, so neither proves physical standstill.
|
||||||
|
# Only explicitly attended Hall/speed/release operations may substitute
|
||||||
|
# observation. Current, modulation bounds and sensored ERPM still apply.
|
||||||
|
observed_sensorless = (standstill_confirmed is True and not moving
|
||||||
|
and motor is not None and motor["motor_type"] == 2
|
||||||
|
and motor["foc_sensor_mode"] == 0)
|
||||||
|
if observed_sensorless:
|
||||||
|
# FW 5.02 mcpwm_foc.c calculates duty_now from measured phase voltages
|
||||||
|
# even in the undriven branch. COMM_GET_VALUES quantizes it to 1/1000;
|
||||||
|
# one idle quantum is not proof that PWM is enabled. Permit at most
|
||||||
|
# that quantum only with fresh operator-confirmed physical standstill.
|
||||||
|
# Current/voltage/temperature/fault and neutral-window guards remain.
|
||||||
|
duty_limit = 0.001
|
||||||
|
bounds = (
|
||||||
|
("fault_code", 0, 0, "код ошибки VESC", "", 1),
|
||||||
|
("input_voltage_v", 20, 60, "напряжение питания", "В", 1),
|
||||||
|
("mos_temperature_c", 0, 65, "температура контроллера", "°C", 1),
|
||||||
|
("motor_current_a", -current_limit, current_limit, "ток мотора", "А", 1),
|
||||||
|
("erpm", -speed_limit, speed_limit, "электрические обороты", "ERPM", 1),
|
||||||
|
("duty", -duty_limit, duty_limit, "заполнение PWM", "%", 100),
|
||||||
|
)
|
||||||
|
for field, low, high, label, unit, scale in bounds:
|
||||||
|
actual = value[field]
|
||||||
|
estimated_speed = field == "erpm" and observed_sensorless
|
||||||
|
if not math.isfinite(actual) or (not estimated_speed and not low <= actual <= high):
|
||||||
|
raise LimitExceeded(field, actual, low, high, label, unit, scale)
|
||||||
|
if standstill_confirmed:
|
||||||
|
actual = value["input_current_a"]
|
||||||
|
if not math.isfinite(actual) or abs(actual) > 1:
|
||||||
|
raise LimitExceeded("input_current_a", actual, -1, 1, "ток батареи", "А")
|
||||||
|
|
||||||
|
|
||||||
|
class MotorTest:
|
||||||
|
def __init__(self, service, atomic, utc, sleep=time.sleep, monotonic=time.monotonic):
|
||||||
|
self.service, self.atomic, self.utc = service, atomic, utc
|
||||||
|
self.sleep, self.monotonic = sleep, monotonic
|
||||||
|
self.stop = threading.Event()
|
||||||
|
self.stop_lock = threading.Lock()
|
||||||
|
self.cancelled_at = None
|
||||||
|
self.active = False
|
||||||
|
self.mode = None
|
||||||
|
self.receiver_bands = {}
|
||||||
|
self.limits = TemporaryLimits(service, atomic)
|
||||||
|
self.authority = service.root / "motor-authority.json"
|
||||||
|
# A process restart during a test cannot silently grant another pulse.
|
||||||
|
if self.authority.exists():
|
||||||
|
import json
|
||||||
|
self.latched = json.loads(self.authority.read_text()).get("state") != "ready"
|
||||||
|
else:
|
||||||
|
self.latched = False
|
||||||
|
|
||||||
|
def state(self, value):
|
||||||
|
self.atomic(self.authority, {"state": value, "observed_at": self.utc()})
|
||||||
|
self.latched = value == "rc"
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
with self.stop_lock:
|
||||||
|
self.cancelled_at = datetime.now(timezone.utc)
|
||||||
|
self.stop.set()
|
||||||
|
|
||||||
|
def neutral(self, devices):
|
||||||
|
for device in devices:
|
||||||
|
value = ppm(device.link.query(31, timeout=0.06))
|
||||||
|
if self.receiver_active(device.id, value["level"]):
|
||||||
|
self.state("rc")
|
||||||
|
raise Rejected("Приёмник передаёт команду. Управление удерживается за пультом.")
|
||||||
|
|
||||||
|
def receiver_active(self, identifier, level):
|
||||||
|
if identifier not in self.receiver_bands:
|
||||||
|
raise Rejected("Нейтраль приёмника ещё не проверена по конфигурации VESC.")
|
||||||
|
return receiver_active(level, self.receiver_bands[identifier])
|
||||||
|
|
||||||
|
def run(self, command, devices, target, release=False, remote=None):
|
||||||
|
if not 1 <= len(devices) <= 128 or len({d.id for d in devices}) != len(devices):
|
||||||
|
raise Rejected("Для проверки нужны однозначно определённые VESC этого борта.")
|
||||||
|
duration = command["parameters"]["duration_s"]
|
||||||
|
current_a = command["parameters"]["current_a"]
|
||||||
|
group_mode = command["action_id"] == "vesc.drive.run"
|
||||||
|
speed_mode = command["action_id"] in ("vesc.motor.run", "vesc.drive.run")
|
||||||
|
moving = [target]
|
||||||
|
if group_mode:
|
||||||
|
from .group_test import targets
|
||||||
|
try: moving = targets(self.service, command["parameters"], devices, target)
|
||||||
|
except ValueError as error: raise Rejected(str(error)) from error
|
||||||
|
hall_mode = command["action_id"] == "vesc.hall.measure"
|
||||||
|
foc_mode = command["action_id"] == "vesc.foc.calibrate"
|
||||||
|
budget = duration + (20 if speed_mode else 0)
|
||||||
|
if self.latched and not release and remote is None:
|
||||||
|
raise Rejected("Управление удерживается за пультом. Верните его явно после нейтрали.")
|
||||||
|
requested = datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00"))
|
||||||
|
if (datetime.now(timezone.utc) - requested).total_seconds() > 10:
|
||||||
|
raise Rejected("Команда устарела до начала проверки. Повторите запрос.")
|
||||||
|
deadline = datetime.fromisoformat(command["deadline_at"].replace("Z", "+00:00"))
|
||||||
|
def preflight():
|
||||||
|
if remote is not None: remote.ensure_live()
|
||||||
|
if self.stop.is_set(): raise Rejected("Проверка отменена.")
|
||||||
|
if (deadline - datetime.now(timezone.utc)).total_seconds() < budget + 5:
|
||||||
|
raise Rejected("Не хватило времени для проверки всех контроллеров. Ток не подавался.")
|
||||||
|
attachments = {d.attachment for d in devices}
|
||||||
|
def unchanged():
|
||||||
|
if set(self.service.discover_fn()) != attachments:
|
||||||
|
raise Rejected("Подключение VESC изменилось. Обновите устройства.")
|
||||||
|
with self.stop_lock:
|
||||||
|
if self.cancelled_at is not None and requested <= self.cancelled_at:
|
||||||
|
raise Rejected("Проверка отменена до начала исполнения.")
|
||||||
|
self.stop.clear()
|
||||||
|
with ExitStack() as stack:
|
||||||
|
# Never reuse an earlier operation's neutral configuration.
|
||||||
|
self.receiver_bands = {}
|
||||||
|
for device in sorted(devices, key=lambda d: d.id):
|
||||||
|
stack.enter_context(device.lock)
|
||||||
|
recovered = None
|
||||||
|
if release and (self.service.root / "calibration-pending.json").exists():
|
||||||
|
from .foc_calibration import reconcile
|
||||||
|
unchanged()
|
||||||
|
try: recovered = reconcile(self, devices)
|
||||||
|
except (OSError, ValueError, TimeoutError, KeyError) as error:
|
||||||
|
raise Rejected("Возврат управления пока невозможен: итог калибровки, конфигурация или нулевой ток не подтверждены.") from error
|
||||||
|
identities, applications, backups, originals = {}, {}, [], {}
|
||||||
|
motors, idle_samples = {}, []
|
||||||
|
observed_standstill = (hall_mode or speed_mode or release) and command["parameters"].get("standstill_confirmed") is True
|
||||||
|
target_motor, target_raw = None, None
|
||||||
|
for device in devices:
|
||||||
|
preflight()
|
||||||
|
if device.link is None: raise Rejected("Один из контроллеров отключён.")
|
||||||
|
if self.limits.pending(device):
|
||||||
|
raise Rejected("Восстановление временных токовых пределов ещё не подтверждено.")
|
||||||
|
if (self.service.root / "hall-pending.json").exists():
|
||||||
|
raise Rejected("Завершение предыдущего измерения Холла не подтверждено. Нужна проверка состояния VESC.")
|
||||||
|
if (self.service.root / "calibration-pending.json").exists():
|
||||||
|
raise Rejected("Завершение предыдущей калибровки не подтверждено. Новое движение заблокировано.")
|
||||||
|
identity = firmware(device.link.query(0))
|
||||||
|
if identity != device.identity: raise Rejected("Идентичность контроллера изменилась.")
|
||||||
|
configs = {kind: device.link.query(code) for kind, code in (("motor", 14), ("application", 17))}
|
||||||
|
originals[device.id] = configs
|
||||||
|
motor, app = decode(configs["motor"], "motor"), decode(configs["application"], "application")
|
||||||
|
motors[device.id] = motor
|
||||||
|
check_configuration(identity, motor, app)
|
||||||
|
self.receiver_bands[device.id] = neutral_band(app)
|
||||||
|
if device in moving:
|
||||||
|
if not foc_mode and current_a > min(motor["l_current_max"], motor["l_in_current_max"]):
|
||||||
|
raise Rejected("Ток проверки превышает настроенный предел выбранного контроллера.")
|
||||||
|
if not (motor["l_max_erpm"] > 0 and motor["l_min_erpm"] < 0 and 0 < motor["l_max_duty"] <= 1):
|
||||||
|
raise Rejected("Нужна проверка настроенных пределов оборотов и PWM.")
|
||||||
|
target_motor = motor
|
||||||
|
target_raw = configs["motor"]
|
||||||
|
if foc_mode and any(abs(motor[key]) <= 0.001 for key in ("l_in_current_min", "l_in_current_max", "foc_openloop_rpm", "foc_sl_erpm")):
|
||||||
|
raise Rejected("Для калибровки нужны ненулевые сохранённые пределы питания и настройки запуска FOC.")
|
||||||
|
if speed_mode and abs(command["parameters"]["erpm"]) > min(SPEED_LIMITS["max_erpm"],
|
||||||
|
(motor["l_max_erpm"] if command["parameters"]["erpm"] > 0 else -motor["l_min_erpm"]) * 0.8):
|
||||||
|
raise Rejected("Заданная скорость превышает настроенный диапазон контроллера.")
|
||||||
|
if speed_mode and abs(command["parameters"]["erpm"]) < motor["s_pid_min_erpm"]:
|
||||||
|
raise Rejected(f"Минимальная скорость регулятора этого VESC: {motor['s_pid_min_erpm']:g} ERPM.")
|
||||||
|
check_values(values(device.link.query(4)), motor=motor,
|
||||||
|
standstill_confirmed=observed_standstill)
|
||||||
|
identities[device.id], applications[device.id] = identity, app
|
||||||
|
identifier = "op_" + uuid.uuid4().hex
|
||||||
|
backup = {"schema": "missioncore.vesc.config-backup/v1", "device_id": device.id,
|
||||||
|
"identity": identity, "operation_id": identifier, "parent_operation_id": command["operation_id"],
|
||||||
|
"observed_at": self.utc(), "monotonic_at": self.monotonic(), "decoded": False,
|
||||||
|
"configs": {kind: {"encoding": "base64", "payload": base64.b64encode(raw).decode(),
|
||||||
|
"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest(), "signature_hex": raw[1:5].hex()}
|
||||||
|
for kind, raw in configs.items()}}
|
||||||
|
self.atomic(self.service.root / ("backup_" + identifier + ".json"), backup)
|
||||||
|
self.service.archive.add("local", backup)
|
||||||
|
device.backup = {"observed_at": backup["observed_at"], "operation_id": identifier,
|
||||||
|
"configs": {k: {"bytes": v["bytes"], "sha256": v["sha256"]} for k, v in backup["configs"].items()}}
|
||||||
|
backups.append(identifier)
|
||||||
|
ids = {int(app["controller_id"]) for app in applications.values()}
|
||||||
|
if len(ids) != len(devices): raise Rejected("У контроллеров совпали CAN ID. Нужна проверка схемы.")
|
||||||
|
for device in devices:
|
||||||
|
preflight()
|
||||||
|
# CAN ping includes up to 5 ms transmit wait plus 10 ms reply
|
||||||
|
# wait for every ID; scheduling can exceed the former 4 s cap.
|
||||||
|
try:
|
||||||
|
peers = device.link.query(62, timeout=8)
|
||||||
|
except TimeoutError as error:
|
||||||
|
raise Rejected("Проверка CAN не завершилась вовремя. Ток не подавался.") from error
|
||||||
|
if not peers or peers[0] != 62 or not set(peers[1:]).issubset(ids):
|
||||||
|
raise Rejected("На CAN обнаружено другое устройство. Нужна проверка схемы.")
|
||||||
|
if foc_mode and len(peers) != 1:
|
||||||
|
raise Rejected("Этот профиль калибрует VESC по отдельным USB без CAN-соседей. Требуется профиль связанного CAN-борта.")
|
||||||
|
for _ in range(10):
|
||||||
|
preflight()
|
||||||
|
unchanged()
|
||||||
|
self.neutral(devices)
|
||||||
|
if observed_standstill:
|
||||||
|
for device in devices:
|
||||||
|
sample = values(device.link.query(4, timeout=0.06))
|
||||||
|
check_values(sample, motor=motors[device.id],
|
||||||
|
standstill_confirmed=observed_standstill)
|
||||||
|
idle_samples.append({"device_id": device.id, "at": self.monotonic(),
|
||||||
|
"sensor_mode": motors[device.id]["foc_sensor_mode"], "values": sample})
|
||||||
|
self.sleep(0.1)
|
||||||
|
preflight_record = {"standstill_confirmed": observed_standstill, "samples": idle_samples}
|
||||||
|
if release:
|
||||||
|
self.state("ready")
|
||||||
|
return {"authority": "ready", "observed_at": self.utc(), "backups": backups, "calibration_recovered": recovered, "preflight": preflight_record}
|
||||||
|
if hall_mode:
|
||||||
|
from .hall_detection import measure
|
||||||
|
return measure(self, devices, target, target_raw, backups, unchanged,
|
||||||
|
preflight_record)
|
||||||
|
if foc_mode:
|
||||||
|
from .foc_calibration import calibrate
|
||||||
|
return calibrate(self, command, devices, target, originals, backups, unchanged)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if (deadline - now).total_seconds() < budget + 1:
|
||||||
|
raise Rejected("Команда устарела до запуска. Повторите проверку.")
|
||||||
|
if group_mode:
|
||||||
|
if remote is not None:
|
||||||
|
self.state("testing")
|
||||||
|
return remote.drive(self, command, devices, moving, originals, unchanged)
|
||||||
|
from .group_test import run
|
||||||
|
return run(self, command, devices, moving, originals, backups, unchanged, preflight_record)
|
||||||
|
self.state("testing")
|
||||||
|
self.active = True
|
||||||
|
self.mode = "speed" if speed_mode else "current"
|
||||||
|
started = self.monotonic()
|
||||||
|
samples, outcome, cleanup = [], "duration", {}
|
||||||
|
limit_violation = None
|
||||||
|
failure = None
|
||||||
|
claimed = False
|
||||||
|
sent_current = 0.0
|
||||||
|
last_command_at = started
|
||||||
|
stalled_since = None
|
||||||
|
stall_tachometer = None
|
||||||
|
hold = SpeedHold(command["parameters"]["erpm"], duration, started) if speed_mode else None
|
||||||
|
restored = not speed_mode
|
||||||
|
try:
|
||||||
|
if speed_mode:
|
||||||
|
target_motor = self.limits.apply(target, target_raw, current_a)
|
||||||
|
started = self.monotonic()
|
||||||
|
hold = SpeedHold(command["parameters"]["erpm"], duration, started)
|
||||||
|
while self.monotonic() - started < budget:
|
||||||
|
cycle = self.monotonic()
|
||||||
|
if self.stop.is_set():
|
||||||
|
outcome = "stopped"; break
|
||||||
|
unchanged()
|
||||||
|
self.neutral(devices)
|
||||||
|
sample = {"at": self.monotonic() - started, "devices": {}}
|
||||||
|
current = values(target.link.query(4, timeout=0.06))
|
||||||
|
sample["commanded_current_a"] = None
|
||||||
|
sample["devices"][target.id] = current
|
||||||
|
samples.append(sample)
|
||||||
|
check_values(current, moving=True, current_a=current_a, motor=target_motor)
|
||||||
|
if hold:
|
||||||
|
setpoint, done, error = hold.update(self.monotonic(), current)
|
||||||
|
sample.update(phase=hold.phase, rotation_s=hold.rotation_s, commanded_erpm=None)
|
||||||
|
if error: raise Rejected(error)
|
||||||
|
if done: break
|
||||||
|
# A current command is torque, not a speed setpoint. Do not
|
||||||
|
# repeatedly coast/restart at each Hall edge. A hard limit
|
||||||
|
# ends this operation and cannot automatically re-arm it.
|
||||||
|
# Above 5 A require continuing measured movement. Hall/FOC
|
||||||
|
# telemetry is not an independent physical motion sensor.
|
||||||
|
if max(abs(current["motor_current_a"]), sent_current) > TEST_LIMITS["stall_current_a"]:
|
||||||
|
if stalled_since is None:
|
||||||
|
stalled_since = self.monotonic()
|
||||||
|
stall_tachometer = current["tachometer"]
|
||||||
|
elif abs(current["erpm"]) >= 60 and abs(current["tachometer"] - stall_tachometer) >= 3:
|
||||||
|
stalled_since = self.monotonic()
|
||||||
|
stall_tachometer = current["tachometer"]
|
||||||
|
elif self.monotonic() - stalled_since >= TEST_LIMITS["stall_timeout_s"]:
|
||||||
|
raise Rejected("Тест остановлен: при токе выше 5 А движение не подтверждается 2 секунды. Проверьте мотор и датчики.")
|
||||||
|
else:
|
||||||
|
stalled_since = None
|
||||||
|
if self.monotonic() - cycle > 0.12:
|
||||||
|
raise Rejected("Связь слишком медленная для короткой проверки.")
|
||||||
|
# Refresh local leases only after fresh neutral/telemetry. A
|
||||||
|
# delayed process never sends current after an expired lease.
|
||||||
|
claimed = True
|
||||||
|
for device in devices: device.link.test_command("claim")
|
||||||
|
for device in devices:
|
||||||
|
if device is not target: device.link.test_command("release")
|
||||||
|
if self.stop.is_set():
|
||||||
|
outcome = "stopped"; break
|
||||||
|
if self.monotonic() - cycle > 0.16:
|
||||||
|
raise Rejected("Связь слишком медленная для короткой проверки.")
|
||||||
|
if self.monotonic() - started >= budget:
|
||||||
|
if hold: raise Rejected("Истёк общий срок проверки; время вращения не набрано.")
|
||||||
|
break
|
||||||
|
if hold:
|
||||||
|
target.link.test_speed(setpoint)
|
||||||
|
sample["commanded_erpm"] = setpoint
|
||||||
|
else:
|
||||||
|
elapsed = self.monotonic() - last_command_at
|
||||||
|
sent_current = round(min(current_a, max(0.5, sent_current + elapsed * TEST_LIMITS["current_ramp_a_per_s"])), 3) if sent_current else 0.5
|
||||||
|
target.link.test_current(sent_current)
|
||||||
|
last_command_at = self.monotonic()
|
||||||
|
sample["commanded_current_a"] = None if hold else sent_current
|
||||||
|
self.sleep(max(0, 0.05 - (self.monotonic() - cycle)))
|
||||||
|
except (OSError, ValueError, TimeoutError) as error:
|
||||||
|
failure = {"type": type(error).__name__, "message": str(error), "elapsed_s": self.monotonic()-started}
|
||||||
|
failure["native_rpc"] = getattr(error, "native_rpc", None)
|
||||||
|
failure["native_history"] = getattr(error, "native_history", [])
|
||||||
|
outcome = str(error) if isinstance(error, Rejected) else "Обмен с VESC прерван. Проверка остановлена."
|
||||||
|
if isinstance(error, LimitExceeded): limit_violation = error.violation
|
||||||
|
finally:
|
||||||
|
# Never reconnect to send a stop to a replacement device. Leases
|
||||||
|
# expire in firmware even if USB or this process is lost.
|
||||||
|
if claimed:
|
||||||
|
for device in devices:
|
||||||
|
try:
|
||||||
|
device.link.test_command("release")
|
||||||
|
cleanup[device.id] = "zero_current_sent"
|
||||||
|
except (OSError, ValueError, TimeoutError):
|
||||||
|
cleanup[device.id] = "unconfirmed"
|
||||||
|
self.active = False
|
||||||
|
self.mode = None
|
||||||
|
if not self.latched: self.state("ready")
|
||||||
|
self.sleep(0.3)
|
||||||
|
after = {}
|
||||||
|
for device in devices:
|
||||||
|
try: after[device.id] = values(device.link.query(4, timeout=0.1))
|
||||||
|
except (OSError, ValueError, TimeoutError): after[device.id] = None
|
||||||
|
confirmed = all(value is not None and abs(value["motor_current_a"]) <= 1 for value in after.values())
|
||||||
|
if claimed and not confirmed:
|
||||||
|
self.state("rc")
|
||||||
|
outcome = "Снятие тока не подтверждено. Проверьте фактическое состояние моторов."
|
||||||
|
if speed_mode:
|
||||||
|
try: restored = self.limits.restore(target)
|
||||||
|
except (OSError, ValueError, TimeoutError):
|
||||||
|
self.state("rc")
|
||||||
|
outcome += " Восстановление прежних токовых пределов не подтверждено; новые запуски заблокированы."
|
||||||
|
if outcome == "duration" and hold.rotation_s < duration:
|
||||||
|
outcome = "Проверка закончилась до набора заданного времени вращения."
|
||||||
|
return {"observed_at": self.utc(), "device_id": target.id, "current_a": current_a,
|
||||||
|
"mode": "speed" if speed_mode else "current", "rotation_s": hold.rotation_s if hold else None,
|
||||||
|
"erpm_target": hold.erpm if hold else None, "limits_restored": restored,
|
||||||
|
"current_ramp_a_per_s": TEST_LIMITS["current_ramp_a_per_s"],
|
||||||
|
"test_limits": TEST_LIMITS, "preflight": preflight_record,
|
||||||
|
"duration_limit_s": duration, "outcome": outcome, "samples": samples,
|
||||||
|
"limit_violation": limit_violation, "failure": failure,
|
||||||
|
"release": cleanup, "release_confirmed": confirmed, "after": after, "backups": backups}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Private lifecycle/RPC boundary to unmodified upstream VESC Tool C++.
|
||||||
|
|
||||||
|
No wire encoding or calibration algorithm lives here. The native process owns
|
||||||
|
one exact USB attachment and sends all commands through upstream Commands.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
from collections import deque
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import select
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .serial import check_attachment
|
||||||
|
|
||||||
|
|
||||||
|
class NativeLink:
|
||||||
|
def __init__(self, attachment):
|
||||||
|
self.attachment = attachment
|
||||||
|
self.process = None
|
||||||
|
self.buffer = b""
|
||||||
|
self.sequence = 0
|
||||||
|
self.hall_pending = False
|
||||||
|
self.history = deque(maxlen=32)
|
||||||
|
self.check()
|
||||||
|
root = Path("/usr/lib/mission-core-vesc/native")
|
||||||
|
config = Path("/run/mission-core-vesc/native") / hashlib.sha256(attachment.binding.encode()).hexdigest()[:24]
|
||||||
|
config.mkdir(parents=True, mode=0o700, exist_ok=True)
|
||||||
|
env = dict(os.environ, QT_QPA_PLATFORM="offscreen", XDG_CONFIG_HOME=str(config),
|
||||||
|
XDG_CACHE_HOME=str(config / "cache"), LD_LIBRARY_PATH=str(root / "lib"),
|
||||||
|
QT_PLUGIN_PATH=str(root / "plugins"))
|
||||||
|
# Preserve native startup diagnostics in the private runtime directory.
|
||||||
|
with (config / "engine.log").open("wb") as diagnostic:
|
||||||
|
self.process = subprocess.Popen([str(root / "bin/mission-core-vesc-engine"), "/dev/" + attachment.tty],
|
||||||
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=diagnostic,
|
||||||
|
env=env, bufsize=0, close_fds=True)
|
||||||
|
try:
|
||||||
|
hello = self._receive(time.monotonic() + 6)
|
||||||
|
if hello.get("ready") is not True or not hello.get("engine", {}).get("connected"):
|
||||||
|
raise OSError("Native VESC Tool did not connect")
|
||||||
|
self.engine = hello["engine"]
|
||||||
|
self.check()
|
||||||
|
except BaseException:
|
||||||
|
self.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
@property
|
||||||
|
def alive(self):
|
||||||
|
return self.process is not None and self.process.poll() is None
|
||||||
|
|
||||||
|
def check(self):
|
||||||
|
check_attachment(self.attachment)
|
||||||
|
|
||||||
|
def _receive(self, deadline):
|
||||||
|
while b"\n" not in self.buffer:
|
||||||
|
if not self.alive or not select.select([self.process.stdout], [], [], max(0, deadline-time.monotonic()))[0]:
|
||||||
|
raise TimeoutError("Native VESC Tool response timed out")
|
||||||
|
data = os.read(self.process.stdout.fileno(), 65536)
|
||||||
|
if not data: raise OSError("Native VESC Tool exited")
|
||||||
|
self.buffer += data
|
||||||
|
if len(self.buffer) > 2 * 1024 * 1024: raise ValueError("Native response exceeds bound")
|
||||||
|
line, self.buffer = self.buffer.split(b"\n", 1)
|
||||||
|
return json.loads(line)
|
||||||
|
|
||||||
|
def rpc(self, method, timeout=2, **parameters):
|
||||||
|
self.sequence += 1
|
||||||
|
request = json.dumps({"id": self.sequence, "method": method, **parameters}, allow_nan=False).encode()+b"\n"
|
||||||
|
if len(request) > 65536: raise ValueError("Native request exceeds bound")
|
||||||
|
started = time.monotonic()
|
||||||
|
deadline = started + timeout
|
||||||
|
trace = {"method": method, "command": parameters.get("command"),
|
||||||
|
"timeout_ms": parameters.get("timeout_ms", timeout*1000),
|
||||||
|
"attachment": {"usb": self.attachment.usb, "address": self.attachment.address,
|
||||||
|
"tty": self.attachment.tty}}
|
||||||
|
try:
|
||||||
|
trace["stage"] = "attachment_before"
|
||||||
|
self.check()
|
||||||
|
trace["attachment_before_ms"] = (time.monotonic()-started)*1000
|
||||||
|
trace["stage"] = "request_write"
|
||||||
|
if not self.alive: raise OSError("Native VESC Tool is not running")
|
||||||
|
if not select.select([], [self.process.stdin], [], max(0, deadline-time.monotonic()))[1]:
|
||||||
|
raise TimeoutError("Native VESC Tool request timed out")
|
||||||
|
if os.write(self.process.stdin.fileno(), request) != len(request):
|
||||||
|
raise OSError("Native request write incomplete")
|
||||||
|
sent_at = time.monotonic()
|
||||||
|
trace["request_write_ms"] = (sent_at-started)*1000-trace["attachment_before_ms"]
|
||||||
|
trace["stage"] = "native_response"
|
||||||
|
response = self._receive(deadline)
|
||||||
|
received_at = time.monotonic()
|
||||||
|
trace["native_response_ms"] = (received_at-sent_at)*1000
|
||||||
|
if not isinstance(response, dict): raise ValueError("Invalid native response")
|
||||||
|
if response.get("id") != self.sequence: raise ValueError("Native response identity mismatch")
|
||||||
|
if response.get("ok") is not True:
|
||||||
|
if isinstance(response.get("diagnostics"), dict):
|
||||||
|
trace["transport"] = response["diagnostics"]
|
||||||
|
raise OSError(response.get("error", "Native operation failed"))
|
||||||
|
trace["stage"] = "attachment_after"
|
||||||
|
self.check()
|
||||||
|
trace["attachment_after_ms"] = (time.monotonic()-received_at)*1000
|
||||||
|
trace["stage"] = "complete"
|
||||||
|
result = response.get("result")
|
||||||
|
if not isinstance(result, dict): raise ValueError("Invalid native result")
|
||||||
|
trace.update(ok=True, elapsed_ms=(time.monotonic()-started)*1000)
|
||||||
|
self.last_rpc = trace
|
||||||
|
if hasattr(self, "history"): self.history.append(trace)
|
||||||
|
return result
|
||||||
|
except (OSError, ValueError, TimeoutError) as error:
|
||||||
|
# An unconfirmed reply is never silently retried on the same stream.
|
||||||
|
trace.update(ok=False, elapsed_ms=(time.monotonic()-started)*1000,
|
||||||
|
process_alive=self.alive, error=str(error))
|
||||||
|
try: self.check(); trace["attachment_present"] = True
|
||||||
|
except OSError: trace["attachment_present"] = False
|
||||||
|
self.last_rpc = trace
|
||||||
|
if hasattr(self, "history"): self.history.append(trace)
|
||||||
|
error.native_rpc = trace
|
||||||
|
error.native_history = list(getattr(self, "history", []))
|
||||||
|
self.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def query(self, command, timeout=2):
|
||||||
|
result = self.rpc("query", timeout=timeout+0.1, command=command, timeout_ms=max(20, int(timeout*1000)))
|
||||||
|
return base64.b64decode(result["payload"], validate=True)
|
||||||
|
|
||||||
|
def test_command(self, action):
|
||||||
|
if action not in ("claim", "release"): raise ValueError("Unknown control action")
|
||||||
|
self.rpc("lease" if action == "claim" else "release", timeout=0.1)
|
||||||
|
|
||||||
|
def test_current(self, current_a):
|
||||||
|
self.rpc("current", timeout=0.1, current_a=current_a)
|
||||||
|
|
||||||
|
def test_speed(self, erpm):
|
||||||
|
self.rpc("rpm", timeout=0.1, erpm=erpm)
|
||||||
|
|
||||||
|
def set_temporary_limits(self, config):
|
||||||
|
from .temporary_limits import FIELDS
|
||||||
|
self.rpc("limits", timeout=2.2, parameters={k: config[k] for k in FIELDS})
|
||||||
|
|
||||||
|
def configuration(self):
|
||||||
|
return self.rpc("configuration", timeout=5)
|
||||||
|
|
||||||
|
def detect_hall(self):
|
||||||
|
if self.hall_pending: raise ValueError("Hall measurement already pending")
|
||||||
|
self.hall_pending = True
|
||||||
|
self.rpc("hall_start", timeout=0.2, current_a=5)
|
||||||
|
|
||||||
|
def calibrate_foc(self, max_power_loss_w):
|
||||||
|
self.rpc("foc_start", timeout=0.2, max_power_loss_w=max_power_loss_w)
|
||||||
|
|
||||||
|
def procedure_result(self):
|
||||||
|
return self.rpc("procedure_result", timeout=0.2)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hall_result(self):
|
||||||
|
if not self.hall_pending: return None
|
||||||
|
state = self.rpc("procedure_result", timeout=0.1)
|
||||||
|
result = state.get("result", {})
|
||||||
|
if state.get("running") or not result.get("completed"): return None
|
||||||
|
return base64.b64decode(result["payload"], validate=True)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
process, self.process = self.process, None
|
||||||
|
if process is None: return
|
||||||
|
if process.poll() is None:
|
||||||
|
process.terminate()
|
||||||
|
try: process.wait(timeout=1)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill(); process.wait(timeout=1)
|
||||||
|
for stream in (process.stdin, process.stdout):
|
||||||
|
if stream: stream.close()
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Bounded VESC serial reader.
|
||||||
|
|
||||||
|
Wire reference: vedderb/vesc_tool dc53c658cbb89a947246034f7a00149cf79abdfc,
|
||||||
|
packet.cpp, commands.cpp and datatypes.h. No arbitrary packet transmit API.
|
||||||
|
Config payloads remain opaque until their exact firmware schema is admitted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import binascii
|
||||||
|
import struct
|
||||||
|
|
||||||
|
READ_COMMANDS = frozenset({0, 4, 14, 17, 31, 62})
|
||||||
|
MAX_PACKET = 10000
|
||||||
|
|
||||||
|
|
||||||
|
def request(command):
|
||||||
|
if type(command) is not int or command not in READ_COMMANDS:
|
||||||
|
raise ValueError("Unsupported read command")
|
||||||
|
data = bytes([command])
|
||||||
|
return b"\x02\x01" + data + binascii.crc_hqx(data, 0).to_bytes(2, "big") + b"\x03"
|
||||||
|
|
||||||
|
|
||||||
|
TEST_LIMITS = {"min_current_a": 0.5, "max_current_a": 30, "min_duration_s": 0.5,
|
||||||
|
"max_duration_s": 30, "current_ramp_a_per_s": 2.0,
|
||||||
|
"continuous_current": True, "max_erpm": 6000, "max_duty": 0.25,
|
||||||
|
"stall_current_a": 5, "stall_timeout_s": 2.0}
|
||||||
|
|
||||||
|
# A separate action/capability keeps old clients from silently changing modes.
|
||||||
|
SPEED_LIMITS = {"min_erpm": 300, "max_erpm": 3000, "ramp_erpm_per_s": 600,
|
||||||
|
"startup_timeout_s": 15, "settle_s": 1, "speed_tolerance": 0.15,
|
||||||
|
"lost_speed_timeout_s": 2, "duration_basis": "measured_speed",
|
||||||
|
"reverse_supported": True, "standstill_confirmation_required": True}
|
||||||
|
|
||||||
|
|
||||||
|
def frame(data):
|
||||||
|
if not 1 <= len(data) <= 255: raise ValueError("Invalid bounded command size")
|
||||||
|
return bytes([2, len(data)]) + data + binascii.crc_hqx(data, 0).to_bytes(2, "big") + b"\x03"
|
||||||
|
|
||||||
|
|
||||||
|
def speed_packet(erpm):
|
||||||
|
if type(erpm) not in (int, float) or not -SPEED_LIMITS["max_erpm"] <= erpm <= SPEED_LIMITS["max_erpm"]:
|
||||||
|
raise ValueError("Invalid test speed")
|
||||||
|
return frame(bytes([8]) + struct.pack(">i", round(erpm)))
|
||||||
|
|
||||||
|
|
||||||
|
def hall_packet():
|
||||||
|
# FW 5.02 native FOC Hall sweep, fixed 5 A; no store and no CAN forwarding.
|
||||||
|
return frame(bytes([28]) + struct.pack(">i", 5000))
|
||||||
|
|
||||||
|
|
||||||
|
def current_packet(current_a):
|
||||||
|
if type(current_a) not in (int, float) or not TEST_LIMITS["min_current_a"] <= current_a <= TEST_LIMITS["max_current_a"]:
|
||||||
|
raise ValueError("Test current must be between 0.5 and 30 A")
|
||||||
|
data = b"\x06" + struct.pack(">i", round(current_a * 1000))
|
||||||
|
return bytes([2, len(data)]) + data + binascii.crc_hqx(data, 0).to_bytes(2, "big") + b"\x03"
|
||||||
|
|
||||||
|
|
||||||
|
def test_packet(action):
|
||||||
|
"""Only fixed volatile commands; no arbitrary current/lease/packet."""
|
||||||
|
data = {"current": b"\x06" + struct.pack(">i", 2000),
|
||||||
|
"release": b"\x06" + bytes(4),
|
||||||
|
"claim": b"\x3f\x00" + struct.pack(">i", 250)}[action]
|
||||||
|
return bytes([2, len(data)]) + data + binascii.crc_hqx(data, 0).to_bytes(2, "big") + b"\x03"
|
||||||
|
|
||||||
|
|
||||||
|
def ppm(packet):
|
||||||
|
if len(packet) != 9 or packet[0] != 31:
|
||||||
|
raise ValueError("Incomplete PPM reply")
|
||||||
|
level, pulse = struct.unpack(">ii", packet[1:])
|
||||||
|
if not -1100000 <= level <= 1100000 or not 0 <= pulse <= 3000000:
|
||||||
|
raise ValueError("Invalid PPM values")
|
||||||
|
return {"level": level / 1e6, "pulse_ms": pulse / 1e6}
|
||||||
|
|
||||||
|
|
||||||
|
class Decoder:
|
||||||
|
def __init__(self):
|
||||||
|
self.buffer = bytearray()
|
||||||
|
|
||||||
|
def feed(self, data):
|
||||||
|
if len(self.buffer) + len(data) > MAX_PACKET * 2 + 16:
|
||||||
|
self.buffer.clear()
|
||||||
|
raise ValueError("Serial buffer overflow")
|
||||||
|
self.buffer.extend(data)
|
||||||
|
packets = []
|
||||||
|
while self.buffer:
|
||||||
|
start = self.buffer[0]
|
||||||
|
if start not in (2, 3, 4):
|
||||||
|
del self.buffer[0]
|
||||||
|
continue
|
||||||
|
width = start - 1
|
||||||
|
if len(self.buffer) < width + 1:
|
||||||
|
break
|
||||||
|
size = int.from_bytes(self.buffer[1:width + 1], "big")
|
||||||
|
if not 1 <= size <= MAX_PACKET:
|
||||||
|
del self.buffer[0]
|
||||||
|
continue
|
||||||
|
end = width + 1 + size
|
||||||
|
if len(self.buffer) < end + 3:
|
||||||
|
break
|
||||||
|
payload = bytes(self.buffer[width + 1:end])
|
||||||
|
if self.buffer[end + 2] != 3 or int.from_bytes(self.buffer[end:end + 2], "big") != binascii.crc_hqx(payload, 0):
|
||||||
|
del self.buffer[0]
|
||||||
|
continue
|
||||||
|
del self.buffer[:end + 3]
|
||||||
|
packets.append(payload)
|
||||||
|
return packets
|
||||||
|
|
||||||
|
|
||||||
|
def firmware(packet):
|
||||||
|
if len(packet) < 4 or packet[0] != 0:
|
||||||
|
raise ValueError("Incomplete firmware reply")
|
||||||
|
end = packet.find(b"\0", 3, 132)
|
||||||
|
if end <= 3 or len(packet) < end + 13:
|
||||||
|
raise ValueError("Firmware has no complete hardware identity")
|
||||||
|
name = packet[3:end].decode("ascii")
|
||||||
|
if not all(32 <= ord(c) < 127 for c in name):
|
||||||
|
raise ValueError("Invalid hardware name")
|
||||||
|
uuid = packet[end + 1:end + 13]
|
||||||
|
if uuid in (bytes(12), b"\xff" * 12):
|
||||||
|
raise ValueError("Invalid hardware UUID")
|
||||||
|
optional = packet[end + 13:]
|
||||||
|
return {"major": packet[1], "minor": packet[2], "version": f"{packet[1]}.{packet[2]:02d}",
|
||||||
|
"hardware": name, "uuid": uuid.hex(),
|
||||||
|
"test_firmware": optional[1] if len(optional) > 1 else None,
|
||||||
|
"hardware_type": optional[2] if len(optional) > 2 else None,
|
||||||
|
"custom_configs": optional[3] if len(optional) > 3 else None}
|
||||||
|
|
||||||
|
|
||||||
|
def values(packet):
|
||||||
|
if len(packet) < 54 or packet[0] != 4:
|
||||||
|
raise ValueError("Incomplete telemetry reply")
|
||||||
|
fields = (("mos_temperature_c", "h", 10), ("motor_temperature_c", "h", 10),
|
||||||
|
("motor_current_a", "i", 100), ("input_current_a", "i", 100),
|
||||||
|
("id_current_a", "i", 100), ("iq_current_a", "i", 100),
|
||||||
|
("duty", "h", 1000), ("erpm", "i", 1), ("input_voltage_v", "h", 10),
|
||||||
|
("amp_hours", "i", 10000), ("amp_hours_charged", "i", 10000),
|
||||||
|
("watt_hours", "i", 10000), ("watt_hours_charged", "i", 10000),
|
||||||
|
("tachometer", "i", 1), ("tachometer_abs", "i", 1), ("fault_code", "B", 1))
|
||||||
|
result, offset = {}, 1
|
||||||
|
for key, fmt, scale in fields:
|
||||||
|
result[key] = struct.unpack_from(">" + fmt, packet, offset)[0] / scale
|
||||||
|
offset += struct.calcsize(fmt)
|
||||||
|
# Optional tail is ordered, not an independent set of guessed offsets.
|
||||||
|
for key, fmt, scale in (("position_deg", "i", 1e6), ("can_id", "B", 1),
|
||||||
|
("mos1_c", "h", 10), ("mos2_c", "h", 10), ("mos3_c", "h", 10),
|
||||||
|
("vd_v", "i", 1000), ("vq_v", "i", 1000), ("status", "B", 1)):
|
||||||
|
size = struct.calcsize(fmt)
|
||||||
|
if len(packet) < offset + size:
|
||||||
|
break
|
||||||
|
result[key] = struct.unpack_from(">" + fmt, packet, offset)[0] / scale
|
||||||
|
offset += size
|
||||||
|
if "status" in result:
|
||||||
|
flags = int(result.pop("status"))
|
||||||
|
result.update(timeout=bool(flags & 1), kill_switch=bool(flags & 2))
|
||||||
|
return result
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Interpret the admitted FW 5.02 PPM input before its firmware deadband.
|
||||||
|
|
||||||
|
app_ppm.c publishes input_val before utils_deadband. A nonzero decoded value
|
||||||
|
inside app_ppm_conf.hyst is therefore not a motor command. Do not infer radio
|
||||||
|
link presence from this value: the receiver can keep emitting failsafe pulses.
|
||||||
|
"""
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
def neutral_band(application):
|
||||||
|
band = application["app_ppm_conf.hyst"]
|
||||||
|
if (application["app_to_use"] not in (1, 4)
|
||||||
|
or application["app_ppm_conf.ctrl_type"] != 4
|
||||||
|
or not math.isfinite(band) or not .01 <= band <= .3):
|
||||||
|
raise ValueError("Unsupported PPM neutral configuration")
|
||||||
|
return band
|
||||||
|
|
||||||
|
|
||||||
|
def active(level, band):
|
||||||
|
if not math.isfinite(level) or not math.isfinite(band) or not .01 <= band <= .3:
|
||||||
|
raise ValueError("Invalid PPM level or neutral band")
|
||||||
|
return abs(level) > band
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"""Single Node-owned, volatile keyboard control session and live read projection.
|
||||||
|
|
||||||
|
Configuration/calibration keeps the same exclusive hardware owner. Browser input
|
||||||
|
is a short lease, not a queue. Native Tool still owns all serial commands.
|
||||||
|
"""
|
||||||
|
import copy
|
||||||
|
import math
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from .group_test import batch
|
||||||
|
from .protocol import ppm, values
|
||||||
|
from .drive_profile import LAYOUTS, slot_label
|
||||||
|
|
||||||
|
|
||||||
|
class ControlEnded(ValueError):
|
||||||
|
"""A terminal input lease is normal stop; cleanup still verifies release."""
|
||||||
|
|
||||||
|
|
||||||
|
class InputLease:
|
||||||
|
def __init__(self, clock=time.monotonic):
|
||||||
|
self.clock = clock
|
||||||
|
self.id = None
|
||||||
|
self.sequence = -1
|
||||||
|
self.until = 0
|
||||||
|
self.demand = (0., 0.)
|
||||||
|
self.retired = set()
|
||||||
|
|
||||||
|
def accept(self, command):
|
||||||
|
if not isinstance(command, dict) or set(command) != {"id", "sequence", "ttl_ms", "left", "right", "settings"}:
|
||||||
|
raise ValueError("Invalid control envelope")
|
||||||
|
if (not re.fullmatch(r"[0-9a-f]{32}", str(command["id"]))
|
||||||
|
or type(command["sequence"]) is not int or not 0 <= command["sequence"] < 2**53
|
||||||
|
or any(type(command[k]) not in (int,float) or not math.isfinite(command[k]) for k in ("left","right","ttl_ms"))
|
||||||
|
or max(abs(command["left"]), abs(command["right"])) > 1
|
||||||
|
or not 0 < command["ttl_ms"] <= 400):
|
||||||
|
raise ValueError("Invalid control bounds")
|
||||||
|
s = command["settings"]
|
||||||
|
if (not isinstance(s, dict) or set(s) != {"standstill_confirmed","current_a","max_erpm"}
|
||||||
|
or s["standstill_confirmed"] is not True
|
||||||
|
or type(s["current_a"]) not in (int,float) or not .5 <= s["current_a"] <= 30
|
||||||
|
or type(s["max_erpm"]) not in (int,float) or not 300 <= s["max_erpm"] <= 3000):
|
||||||
|
raise ValueError("Invalid control settings")
|
||||||
|
if command["id"] in self.retired:
|
||||||
|
return False
|
||||||
|
if self.id is not None and (self.id != command["id"] or self.clock() >= self.until):
|
||||||
|
self.stop()
|
||||||
|
return False
|
||||||
|
if self.id == command["id"] and command["sequence"] <= self.sequence:
|
||||||
|
return False # Repeated frames cannot extend a lease.
|
||||||
|
self.id, self.sequence = command["id"], command["sequence"]
|
||||||
|
self.until = self.clock()+command["ttl_ms"]/1000
|
||||||
|
self.demand = (command["left"], command["right"])
|
||||||
|
return True
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if self.id:
|
||||||
|
self.retired.add(self.id)
|
||||||
|
# Bound tombstones; Core never reuses a cryptographic session id.
|
||||||
|
if len(self.retired)>1024:
|
||||||
|
raise ValueError("Restart control service after excessive sessions")
|
||||||
|
self.until = 0
|
||||||
|
self.demand = (0.,0.)
|
||||||
|
|
||||||
|
def live(self):
|
||||||
|
return self.id is not None and self.id not in self.retired and self.clock() < self.until
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteControl:
|
||||||
|
def __init__(self, service):
|
||||||
|
self.service = service
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.lease = InputLease()
|
||||||
|
self.instance = uuid.uuid4().hex
|
||||||
|
self.relay = None
|
||||||
|
self.primed = False
|
||||||
|
self.thread = None
|
||||||
|
self.watch_until = 0
|
||||||
|
self.state = "observing"
|
||||||
|
self.message = None
|
||||||
|
self.readings = {}
|
||||||
|
self.release_confirmed = None
|
||||||
|
|
||||||
|
def feed(self, body):
|
||||||
|
if (set(body) != {"watch","command","relay_id"} or type(body["watch"]) is not bool
|
||||||
|
or not re.fullmatch(r"[0-9a-f]{32}", str(body["relay_id"]))):
|
||||||
|
raise ValueError("Invalid relay")
|
||||||
|
with self.lock:
|
||||||
|
if self.relay != body["relay_id"]:
|
||||||
|
self.lease.stop()
|
||||||
|
self.relay = body["relay_id"]
|
||||||
|
self.primed = False
|
||||||
|
if body["watch"]:
|
||||||
|
self.watch_until = time.monotonic()+2
|
||||||
|
command = body["command"]
|
||||||
|
if command is None:
|
||||||
|
self.primed = True
|
||||||
|
self.lease.stop()
|
||||||
|
elif not self.primed:
|
||||||
|
self.lease.retired.add(command.get("id"))
|
||||||
|
else:
|
||||||
|
running = self.thread is not None and self.thread.is_alive()
|
||||||
|
if not running and command.get("id") != self.lease.id:
|
||||||
|
self.lease.id = None
|
||||||
|
self.lease.sequence = -1
|
||||||
|
accepted = self.lease.accept(command)
|
||||||
|
if accepted and not running:
|
||||||
|
self.state, self.message = "preparing", None
|
||||||
|
self.thread = threading.Thread(target=self._prepare, args=(copy.deepcopy(command),), daemon=True,
|
||||||
|
name="vesc-remote-control")
|
||||||
|
self.thread.start()
|
||||||
|
return self.snapshot()
|
||||||
|
|
||||||
|
def snapshot(self):
|
||||||
|
with self.lock:
|
||||||
|
readings = [{**copy.deepcopy(v), "age_ms": int((time.monotonic()-v["sampled_at"])*1000)} for v in self.readings.values()]
|
||||||
|
for v in readings: v.pop("sampled_at", None)
|
||||||
|
return {"supported": True, "instance": self.instance, "state": self.state,
|
||||||
|
"session_id": self.lease.id, "message": self.message, "devices": readings,
|
||||||
|
"release_confirmed": self.release_confirmed,
|
||||||
|
"profile": copy.deepcopy(self.service.drive.value)}
|
||||||
|
|
||||||
|
def observe(self):
|
||||||
|
with self.lock:
|
||||||
|
# Let an in-flight read finish, but do not start another one while
|
||||||
|
# the control worker is waiting to become the exclusive owner.
|
||||||
|
if self.thread is not None and self.thread.is_alive():
|
||||||
|
return
|
||||||
|
if time.monotonic() >= self.watch_until or not self.service.operation_lock.acquire(False):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with self.service.lock: devices = [d for d in self.service.devices.values() if d.link and d.readable]
|
||||||
|
# Reading is explicit window interest, never discovery-port reset.
|
||||||
|
for d in devices:
|
||||||
|
if not d.lock.acquire(False): continue
|
||||||
|
try:
|
||||||
|
self._publish(d, values(d.link.query(4, timeout=.06)), ppm(d.link.query(31, timeout=.06)))
|
||||||
|
except (OSError, ValueError, TimeoutError):
|
||||||
|
pass # Old values retain age; a read failure is never zero.
|
||||||
|
finally: d.lock.release()
|
||||||
|
finally: self.service.operation_lock.release()
|
||||||
|
|
||||||
|
def _publish(self, device, value, receiver):
|
||||||
|
profile = self.service.drive.value
|
||||||
|
slot = next((k for k,b in profile["bindings"].items() if b["device_id"]==device.id), None)
|
||||||
|
with self.lock:
|
||||||
|
self.readings[device.id] = {"id": device.id,"uuid":device.identity["uuid"],
|
||||||
|
"slot":slot,"label":slot_label(profile["layout"],slot) if slot else "VESC "+device.identity["uuid"][:6].upper(),
|
||||||
|
"values":value,"receiver":receiver,"sampled_at":time.monotonic()}
|
||||||
|
|
||||||
|
def _prepare(self, envelope):
|
||||||
|
owner = self.service.motor
|
||||||
|
acquired = False
|
||||||
|
try:
|
||||||
|
# Observation also owns this lock. A nonblocking attempt made
|
||||||
|
# arming depend on which thread happened to read first. Wait only
|
||||||
|
# briefly, with a live input lease, never enqueue a future drive.
|
||||||
|
deadline = time.monotonic() + .5
|
||||||
|
while not acquired:
|
||||||
|
self.ensure_live()
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
if remaining <= 0:
|
||||||
|
raise ValueError("Другая операция с VESC ещё выполняется.")
|
||||||
|
acquired = self.service.operation_lock.acquire(timeout=min(.02, remaining))
|
||||||
|
self.ensure_live()
|
||||||
|
with self.service.lock: devices = list(self.service.devices.values())
|
||||||
|
profile = self.service.drive.value
|
||||||
|
slots = LAYOUTS.get(profile["layout"], ())
|
||||||
|
if not slots or set(profile["bindings"]) != set(slots):
|
||||||
|
raise ValueError("Назначьте все моторы в настройках борта.")
|
||||||
|
if not devices or any(not d.link for d in devices):
|
||||||
|
raise ValueError("Один из VESC недоступен.")
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
settings = envelope["settings"]
|
||||||
|
command = {"action_id":"vesc.drive.run","operation_id":"op_"+uuid.uuid4().hex,
|
||||||
|
"requested_at":now.isoformat(),"deadline_at":(now+timedelta(seconds=300)).isoformat(),
|
||||||
|
"parameters":{"current_a":settings["current_a"],"duration_s":30,"erpm":settings["max_erpm"],
|
||||||
|
"standstill_confirmed":True,"profile_revision":profile["revision"],
|
||||||
|
"device_ids":[profile["bindings"][s]["device_id"] for s in slots]}}
|
||||||
|
owner.run(command, devices, devices[0], remote=self)
|
||||||
|
except ControlEnded:
|
||||||
|
with self.lock:
|
||||||
|
self.state = "fault" if self.message or self.release_confirmed is False else "stopped"
|
||||||
|
except (OSError, ValueError, TimeoutError, KeyError) as error:
|
||||||
|
with self.lock:
|
||||||
|
self.state = "fault"
|
||||||
|
self.message = str(error) if self.message is None else self.message + " " + str(error)
|
||||||
|
finally:
|
||||||
|
with self.lock: self.lease.stop()
|
||||||
|
if acquired: self.service.operation_lock.release()
|
||||||
|
|
||||||
|
def ensure_live(self):
|
||||||
|
with self.lock:
|
||||||
|
if not self.lease.live(): raise ControlEnded("Управление остановлено: команда больше не подтверждается.")
|
||||||
|
|
||||||
|
def drive(self, owner, command, devices, moving, originals, unchanged):
|
||||||
|
from .motor_test import check_values
|
||||||
|
limit = command["parameters"]["current_a"]
|
||||||
|
maximum = command["parameters"]["erpm"]
|
||||||
|
profile = copy.deepcopy(self.service.drive.value)
|
||||||
|
sides = {b["device_id"]:0 if slot.startswith("left.") else 1 for slot,b in profile["bindings"].items()}
|
||||||
|
configs, minimum_speeds, claimed, neutral_since = {}, {}, False, None
|
||||||
|
previous, speeds = time.monotonic(), {d.id:0. for d in moving}
|
||||||
|
last_sign, undriven_since = {}, None
|
||||||
|
self.release_confirmed = None
|
||||||
|
owner.active, owner.mode = True, "remote"
|
||||||
|
receiver = False
|
||||||
|
zero_seen = False
|
||||||
|
stalls = {}
|
||||||
|
with ThreadPoolExecutor(max_workers=min(16,len(devices)),thread_name_prefix="vesc-remote") as pool:
|
||||||
|
try:
|
||||||
|
for d in moving:
|
||||||
|
self.ensure_live()
|
||||||
|
configs[d.id] = owner.limits.apply(d, originals[d.id]["motor"], limit)
|
||||||
|
minimum = configs[d.id]["s_pid_min_erpm"]
|
||||||
|
if not math.isfinite(minimum) or minimum < 0:
|
||||||
|
raise ValueError("Некорректный минимальный порог регулятора VESC.")
|
||||||
|
# Native Tool serializes setRpm as an integer. Round up so
|
||||||
|
# the first command actually reaches the firmware threshold.
|
||||||
|
minimum_speeds[d.id] = max(1, math.ceil(minimum))
|
||||||
|
if maximum < minimum_speeds[d.id]:
|
||||||
|
raise ValueError(f"Минимальная скорость регулятора этого VESC: {minimum_speeds[d.id]} ERPM.")
|
||||||
|
self.state = "ready"
|
||||||
|
while True:
|
||||||
|
cycle = time.monotonic()
|
||||||
|
if owner.stop.is_set(): break
|
||||||
|
if not receiver: self.ensure_live()
|
||||||
|
unchanged()
|
||||||
|
if self.service.drive.value != profile: raise ValueError("Назначения моторов изменились.")
|
||||||
|
def read(d): return values(d.link.query(4,timeout=.06)),ppm(d.link.query(31,timeout=.06))
|
||||||
|
readouts = batch(pool, devices, read)
|
||||||
|
for d in devices: self._publish(d,*readouts[d.id])
|
||||||
|
active = any(owner.receiver_active(d.id,readouts[d.id][1]["level"]) for d in devices)
|
||||||
|
if active:
|
||||||
|
receiver = True
|
||||||
|
self.state = "receiver"
|
||||||
|
owner.state("rc")
|
||||||
|
with self.lock: self.lease.stop()
|
||||||
|
if receiver:
|
||||||
|
# Hold zero locally through the first gesture. Neutral
|
||||||
|
# releases PPM, so only a subsequent gesture drives it.
|
||||||
|
stopped = all(abs(v[0]["motor_current_a"])<=1 and abs(v[0]["duty"])<.01 for v in readouts.values())
|
||||||
|
neutral_since = (neutral_since or cycle) if not active and stopped else None
|
||||||
|
if neutral_since is not None and cycle-neutral_since>=.5: break
|
||||||
|
demand=(0.,0.)
|
||||||
|
else:
|
||||||
|
with self.lock: demand=self.lease.demand
|
||||||
|
if demand==(0.,0.): zero_seen=True
|
||||||
|
if not zero_seen: demand=(0.,0.)
|
||||||
|
now=time.monotonic()
|
||||||
|
dt=min(.15,now-previous);previous=now
|
||||||
|
targets = {}
|
||||||
|
for d in moving:
|
||||||
|
value=readouts[d.id][0]
|
||||||
|
check_values(value,moving=True,current_a=limit,motor=configs[d.id])
|
||||||
|
target=demand[sides[d.id]]*maximum
|
||||||
|
minimum = minimum_speeds[d.id]
|
||||||
|
# A small analogue request must never be rounded UP to
|
||||||
|
# a faster requested speed. Release below the operable
|
||||||
|
# range; firmware would otherwise enter zero-duty mode.
|
||||||
|
if abs(target) < minimum: target=0
|
||||||
|
targets[d.id] = target
|
||||||
|
signs = {key: 1 if target>0 else -1 if target<0 else 0 for key,target in targets.items()}
|
||||||
|
quiet = all(abs(readouts[d.id][0]["erpm"])<300
|
||||||
|
and abs(readouts[d.id][0]["motor_current_a"])<=1
|
||||||
|
and abs(readouts[d.id][0]["duty"])<.01 for d in moving)
|
||||||
|
# Count only observed neutral while ALL previous outputs
|
||||||
|
# were released. A long operator pause already satisfies it.
|
||||||
|
if quiet and not any(speeds.values()):
|
||||||
|
if undriven_since is None: undriven_since = now
|
||||||
|
else: undriven_since = None
|
||||||
|
reversing = any(sign and last_sign.get(key,sign)!=sign for key,sign in signs.items())
|
||||||
|
if reversing and (undriven_since is None or now-undriven_since<.5):
|
||||||
|
# One shared barrier: after a turn, the side keeping its
|
||||||
|
# direction must not drive while the other waits to reverse.
|
||||||
|
targets = dict.fromkeys(targets, 0.)
|
||||||
|
else:
|
||||||
|
last_sign.update({key:sign for key,sign in signs.items() if sign})
|
||||||
|
for d in moving:
|
||||||
|
value=readouts[d.id][0]
|
||||||
|
target=targets[d.id]
|
||||||
|
minimum=minimum_speeds[d.id]
|
||||||
|
# Zero is immediate release, never an RPM hold/brake.
|
||||||
|
if target==0:
|
||||||
|
speeds[d.id]=0
|
||||||
|
else:
|
||||||
|
step=600*dt
|
||||||
|
speeds[d.id]+=max(-step,min(step,target-speeds[d.id]))
|
||||||
|
# FW 5.02 disables its speed PID below s_pid_min_erpm.
|
||||||
|
# Ramping from zero spent 900/600 = 1.5 s sending
|
||||||
|
# ineffective commands. Enter the configured range
|
||||||
|
# immediately, then keep the existing ramp above it.
|
||||||
|
if abs(speeds[d.id]) < minimum:
|
||||||
|
speeds[d.id] = math.copysign(minimum, target)
|
||||||
|
if abs(value["motor_current_a"])>5:
|
||||||
|
at,tacho=stalls.setdefault(d.id,(now,value["tachometer"]))
|
||||||
|
if abs(value["erpm"])>=60 and abs(value["tachometer"]-tacho)>=3: stalls[d.id]=(now,value["tachometer"])
|
||||||
|
elif now-at>=2: raise ValueError("Мотор не движется при токе выше 5 А.")
|
||||||
|
else: stalls.pop(d.id,None)
|
||||||
|
if now-cycle>.12: raise ValueError("Связь с VESC слишком медленная.")
|
||||||
|
if not receiver: self.ensure_live()
|
||||||
|
claimed=True
|
||||||
|
batch(pool,devices,lambda d:d.link.test_command("claim"))
|
||||||
|
if time.monotonic()-cycle>.16: raise ValueError("Связь с VESC слишком медленная.")
|
||||||
|
if not receiver: self.ensure_live()
|
||||||
|
def send(d):
|
||||||
|
if not receiver: self.ensure_live()
|
||||||
|
if owner.stop.is_set() or receiver or abs(speeds.get(d.id,0))<1: d.link.test_command("release")
|
||||||
|
else: d.link.test_speed(speeds[d.id])
|
||||||
|
batch(pool,devices,send)
|
||||||
|
if not receiver: self.state="driving" if any(speeds.values()) else "ready"
|
||||||
|
owner.sleep(max(0,.1-(time.monotonic()-cycle)))
|
||||||
|
finally:
|
||||||
|
self.state="stopping"
|
||||||
|
def release(d):
|
||||||
|
try: d.link.test_command("release")
|
||||||
|
except (OSError,ValueError,TimeoutError): pass
|
||||||
|
if claimed: batch(pool,devices,release)
|
||||||
|
if claimed:
|
||||||
|
owner.sleep(.3)
|
||||||
|
def released(d):
|
||||||
|
try:
|
||||||
|
value = values(d.link.query(4, timeout=.1))
|
||||||
|
return all(math.isfinite(value[k]) and abs(value[k]) <= 1
|
||||||
|
for k in ("motor_current_a", "input_current_a")) and abs(value["duty"]) < .01
|
||||||
|
except (OSError, ValueError, TimeoutError): return False
|
||||||
|
self.release_confirmed = all(batch(pool, devices, released).values())
|
||||||
|
if not self.release_confirmed:
|
||||||
|
self.message = "Снятие тока не подтверждено. Проверьте фактическое состояние моторов."
|
||||||
|
owner.state("rc")
|
||||||
|
for d in moving:
|
||||||
|
try: owner.limits.restore(d)
|
||||||
|
except (OSError,ValueError,TimeoutError):
|
||||||
|
notice="Восстановление пределов ожидает нейтрали."
|
||||||
|
self.message=(self.message+" " if self.message else "")+notice
|
||||||
|
owner.state("rc")
|
||||||
|
owner.active,owner.mode=False,None
|
||||||
|
self.state="fault" if self.release_confirmed is False else "receiver" if receiver else "stopped"
|
||||||
|
if not receiver and not owner.latched: owner.state("ready")
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
Firmware 5.02 configuration definitions from official vedderb/vesc_tool commit 01d5f10901116c311e3fb84d5a1541f663d3ce20, res/config/5.02. Copyright Benjamin Vedder and contributors; see VESC_TOOL_LICENSE. Definitions are unchanged.
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Exclusive serial ownership and OS attachment generation checks."""
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import select
|
||||||
|
import stat
|
||||||
|
import termios
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from .protocol import Decoder, request, test_packet, current_packet, speed_packet, hall_packet
|
||||||
|
|
||||||
|
|
||||||
|
def device_id(value):
|
||||||
|
return "vesc_" + hashlib.sha256(value.encode()).hexdigest()[:32]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Attachment:
|
||||||
|
usb: str
|
||||||
|
address: str
|
||||||
|
tty: str
|
||||||
|
speed: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def binding(self):
|
||||||
|
return self.usb + ":" + self.address
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self):
|
||||||
|
return device_id("provisional:" + self.binding)
|
||||||
|
|
||||||
|
|
||||||
|
def attachment_at(path):
|
||||||
|
"""Read one physical USB generation; never walk sibling devices or drivers."""
|
||||||
|
if not re.fullmatch(r"[0-9]+-[0-9]+(?:\.[0-9]+)*", path.name): return None
|
||||||
|
try:
|
||||||
|
if ((path / "idVendor").read_text().strip() != "0483"
|
||||||
|
or (path / "idProduct").read_text().strip() != "5740"
|
||||||
|
or (path / "product").read_text().strip() != "ChibiOS/RT Virtual COM Port"):
|
||||||
|
return None
|
||||||
|
address = (path / "devnum").read_text().strip()
|
||||||
|
tty = [p.name for p in path.glob(path.name + ":*/tty/ttyACM*")
|
||||||
|
if re.fullmatch(r"ttyACM[0-9]+", p.name)]
|
||||||
|
speed = (path / "speed").read_text().strip() + " Мбит/с"
|
||||||
|
if len(tty) != 1 or address != (path / "devnum").read_text().strip(): return None
|
||||||
|
return Attachment(path.name, address, tty[0], speed)
|
||||||
|
except (OSError, ValueError): return None
|
||||||
|
|
||||||
|
|
||||||
|
def check_attachment(attachment, root=Path("/sys/bus/usb/devices")):
|
||||||
|
if (not re.fullmatch(r"[0-9]+-[0-9]+(?:\.[0-9]+)*", attachment.usb)
|
||||||
|
or attachment_at(root / attachment.usb) != attachment):
|
||||||
|
raise OSError("USB attachment changed")
|
||||||
|
|
||||||
|
|
||||||
|
def discover(root=Path("/sys/bus/usb/devices")):
|
||||||
|
found = [attachment_at(path) for path in sorted(root.iterdir())]
|
||||||
|
return [item for item in found if item is not None][:128]
|
||||||
|
|
||||||
|
|
||||||
|
class Link:
|
||||||
|
def __init__(self, attachment):
|
||||||
|
self.attachment = attachment
|
||||||
|
self.fd = -1
|
||||||
|
self.decoder = Decoder()
|
||||||
|
self.hall_result = None
|
||||||
|
self.hall_pending = False
|
||||||
|
self.check()
|
||||||
|
fd = os.open("/dev/" + attachment.tty, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK | os.O_NOFOLLOW)
|
||||||
|
try:
|
||||||
|
info = os.fstat(fd)
|
||||||
|
if not stat.S_ISCHR(info.st_mode) or os.major(info.st_rdev) != 166:
|
||||||
|
raise ValueError("Not a CDC ACM device")
|
||||||
|
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
fcntl.ioctl(fd, termios.TIOCEXCL)
|
||||||
|
settings = termios.tcgetattr(fd)
|
||||||
|
settings[0] = settings[1] = settings[3] = 0
|
||||||
|
settings[2] = termios.CLOCAL | termios.CREAD | termios.CS8
|
||||||
|
settings[4] = settings[5] = termios.B115200
|
||||||
|
settings[6][termios.VMIN] = settings[6][termios.VTIME] = 0
|
||||||
|
termios.tcsetattr(fd, termios.TCSANOW, settings)
|
||||||
|
self.check()
|
||||||
|
self.fd = fd
|
||||||
|
except BaseException:
|
||||||
|
os.close(fd)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def check(self):
|
||||||
|
check_attachment(self.attachment)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.fd >= 0:
|
||||||
|
os.close(self.fd)
|
||||||
|
self.fd = -1
|
||||||
|
|
||||||
|
def query(self, command, timeout=2):
|
||||||
|
return self._exchange(request(command), command, timeout)
|
||||||
|
|
||||||
|
def _exchange(self, payload, command, timeout):
|
||||||
|
self.check()
|
||||||
|
if not self.hall_pending:
|
||||||
|
self.decoder = Decoder()
|
||||||
|
termios.tcflush(self.fd, termios.TCIFLUSH)
|
||||||
|
deadline = time.monotonic() + min(timeout, 8 if command == 62 else 2)
|
||||||
|
sent = 0
|
||||||
|
while sent < len(payload):
|
||||||
|
if not select.select([], [self.fd], [], max(0, deadline - time.monotonic()))[1]:
|
||||||
|
raise TimeoutError("Serial write timeout")
|
||||||
|
sent += os.write(self.fd, payload[sent:])
|
||||||
|
total = 0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if not select.select([self.fd], [], [], max(0, deadline - time.monotonic()))[0]:
|
||||||
|
break
|
||||||
|
raw = os.read(self.fd, 4096)
|
||||||
|
if not raw:
|
||||||
|
raise OSError("Serial device disconnected")
|
||||||
|
total += len(raw)
|
||||||
|
if total > 32768:
|
||||||
|
raise ValueError("Unexpected serial traffic")
|
||||||
|
answer = None
|
||||||
|
for packet in self.decoder.feed(raw):
|
||||||
|
if self.hall_pending and packet[0] == 28:
|
||||||
|
self.hall_result = packet
|
||||||
|
if packet[0] == command:
|
||||||
|
answer = packet
|
||||||
|
if answer is not None:
|
||||||
|
self.check()
|
||||||
|
return answer
|
||||||
|
raise TimeoutError("Controller did not reply")
|
||||||
|
|
||||||
|
def test_command(self, action):
|
||||||
|
self._test_write(test_packet(action))
|
||||||
|
|
||||||
|
def test_current(self, current_a):
|
||||||
|
self._test_write(current_packet(current_a))
|
||||||
|
|
||||||
|
def test_speed(self, erpm):
|
||||||
|
self._test_write(speed_packet(erpm))
|
||||||
|
|
||||||
|
def set_temporary_limits(self, config):
|
||||||
|
from .temporary_limits import packet
|
||||||
|
if self._exchange(packet(config), 48, 2) != bytes([48]):
|
||||||
|
raise ValueError("Invalid volatile limits ACK")
|
||||||
|
|
||||||
|
def detect_hall(self):
|
||||||
|
if self.hall_pending: raise ValueError("Hall detection already started")
|
||||||
|
self.decoder = Decoder()
|
||||||
|
self.hall_result = None
|
||||||
|
self.hall_pending = True
|
||||||
|
self._test_write(hall_packet())
|
||||||
|
|
||||||
|
def _test_write(self, payload):
|
||||||
|
self.check()
|
||||||
|
deadline = time.monotonic() + 0.04
|
||||||
|
sent = 0
|
||||||
|
while sent < len(payload):
|
||||||
|
if not select.select([], [self.fd], [], max(0, deadline - time.monotonic()))[1]:
|
||||||
|
raise TimeoutError("Test command write timeout")
|
||||||
|
sent += os.write(self.fd, payload[sent:])
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Private Unix socket endpoint; peer UID must be the installed Node service."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import pwd
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import socketserver
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
from http.server import BaseHTTPRequestHandler
|
||||||
|
from urllib.parse import urlsplit, parse_qs
|
||||||
|
|
||||||
|
from .service import Service
|
||||||
|
from .remote_control import RemoteControl
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *_):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
self.dispatch()
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
self.dispatch()
|
||||||
|
|
||||||
|
def dispatch(self):
|
||||||
|
self.connection.settimeout(10)
|
||||||
|
status = 200
|
||||||
|
try:
|
||||||
|
node = self.headers.get("X-Node-Id", "")
|
||||||
|
if not re.fullmatch(r"[a-zA-Z0-9_.:-]{1,128}", node) or self.headers.get("Transfer-Encoding"):
|
||||||
|
raise ValueError("Invalid request")
|
||||||
|
if self.command == "GET" and self.path == "/inventory":
|
||||||
|
result = self.server.service.inventory(node)
|
||||||
|
elif self.command == "GET" and self.path.startswith("/archives/"):
|
||||||
|
url = urlsplit(self.path)
|
||||||
|
parts = url.path.split("/")
|
||||||
|
if len(parts) not in (3, 4) or not re.fullmatch(r"vesc_[0-9a-f]{32}", parts[2]):
|
||||||
|
raise ValueError("Invalid archive target")
|
||||||
|
archive = self.server.service.archive
|
||||||
|
if len(parts) == 4:
|
||||||
|
result = archive.read("local", parts[2], parts[3])
|
||||||
|
else:
|
||||||
|
before = int(parse_qs(url.query).get("before", ["0"])[0])
|
||||||
|
result = archive.listing("local", parts[2], before)
|
||||||
|
elif self.command == "GET" and self.path.startswith("/archive-export?"):
|
||||||
|
after = int(parse_qs(urlsplit(self.path).query).get("after", ["0"])[0])
|
||||||
|
result = self.server.service.archive.export("local", after)
|
||||||
|
elif self.command == "POST" and self.path in ("/operation", "/remote"):
|
||||||
|
size = int(self.headers.get("Content-Length", "0"))
|
||||||
|
if not 0 < size <= 65536 or self.headers.get("Content-Type") != "application/json":
|
||||||
|
raise ValueError("Invalid command")
|
||||||
|
raw = self.rfile.read(size)
|
||||||
|
if len(raw) != size:
|
||||||
|
raise ValueError("Truncated command")
|
||||||
|
result = (self.server.service.execute(json.loads(raw)) if self.path == "/operation"
|
||||||
|
else self.server.service.remote.feed(json.loads(raw)))
|
||||||
|
else:
|
||||||
|
raise ValueError("Unknown route")
|
||||||
|
except (ValueError, KeyError, TypeError, OSError):
|
||||||
|
status, result = 400, {"error": "Запрос VESC отклонён. Обновите сведения об устройстве."}
|
||||||
|
data = json.dumps(result, ensure_ascii=False, allow_nan=False).encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(data)))
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(data)
|
||||||
|
|
||||||
|
|
||||||
|
class Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
def __init__(self, path, handler):
|
||||||
|
self.slots = threading.BoundedSemaphore(8)
|
||||||
|
super().__init__(path, handler)
|
||||||
|
|
||||||
|
def process_request(self, request, address):
|
||||||
|
if not self.slots.acquire(blocking=False):
|
||||||
|
self.shutdown_request(request)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
super().process_request(request, address)
|
||||||
|
except BaseException:
|
||||||
|
self.slots.release()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def process_request_thread(self, request, address):
|
||||||
|
try:
|
||||||
|
super().process_request_thread(request, address)
|
||||||
|
finally:
|
||||||
|
self.slots.release()
|
||||||
|
|
||||||
|
def verify_request(self, request, address):
|
||||||
|
_, uid, _ = struct.unpack("3i", request.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
|
||||||
|
return uid == self.node_uid
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if os.geteuid() == 0:
|
||||||
|
raise RuntimeError("VESC must run as its own unprivileged user")
|
||||||
|
os.umask(0o007)
|
||||||
|
service = Service("/var/lib/mission-core-vesc")
|
||||||
|
service.remote = RemoteControl(service)
|
||||||
|
stop = threading.Event()
|
||||||
|
|
||||||
|
def scan():
|
||||||
|
while not stop.is_set():
|
||||||
|
try:
|
||||||
|
service.scan()
|
||||||
|
except OSError:
|
||||||
|
# A transient sysfs race must not silently kill discovery.
|
||||||
|
pass
|
||||||
|
stop.wait(2)
|
||||||
|
|
||||||
|
def observe():
|
||||||
|
while not stop.is_set():
|
||||||
|
service.remote.observe()
|
||||||
|
stop.wait(.2)
|
||||||
|
|
||||||
|
path = Path("/run/mission-core-vesc/driver.sock")
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
with Server(str(path), Handler) as server:
|
||||||
|
server.node_uid = pwd.getpwnam("mission-core-node").pw_uid
|
||||||
|
server.service = service
|
||||||
|
thread = threading.Thread(target=scan, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
threading.Thread(target=observe, daemon=True, name="vesc-observer").start()
|
||||||
|
try:
|
||||||
|
server.serve_forever()
|
||||||
|
finally:
|
||||||
|
stop.set()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""One onboard owner; both operator surfaces consume the same bounded device operations."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from . import MODEL, SCHEMA, VERSION
|
||||||
|
from .protocol import firmware, values, ppm, TEST_LIMITS, SPEED_LIMITS
|
||||||
|
from .motor_test import MotorTest, Rejected
|
||||||
|
from .drive_profile import DriveProfile, slot_label, validate as validate_drive
|
||||||
|
from .serial import device_id, discover
|
||||||
|
from .native_link import NativeLink
|
||||||
|
try:
|
||||||
|
from .archive import Archive
|
||||||
|
except ImportError: # Source checkout; packaging copies this exact shared file.
|
||||||
|
from k1link.device_plugins.vesc.archive import Archive
|
||||||
|
|
||||||
|
ACTIONS = frozenset({"verify", "details", "vesc.link.check", "vesc.telemetry.read", "vesc.limits.read", "vesc.config.backup", "vesc.input.read", "vesc.can.read", "vesc.drive.assign", "vesc.drive.unassign", "vesc.drive.layout", "vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.hall.measure", "vesc.foc.calibrate", "vesc.motor.stop", "vesc.control.release"})
|
||||||
|
|
||||||
|
|
||||||
|
def utc():
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def atomic(path, value):
|
||||||
|
fd, name = tempfile.mkstemp(prefix=".vesc-", dir=path.parent)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as stream:
|
||||||
|
os.fchmod(stream.fileno(), 0o600)
|
||||||
|
json.dump(value, stream, ensure_ascii=False, allow_nan=False)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(name, path)
|
||||||
|
fd = os.open(path.parent, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(fd)
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(name):
|
||||||
|
os.unlink(name)
|
||||||
|
|
||||||
|
|
||||||
|
class Device:
|
||||||
|
def __init__(self, attachment):
|
||||||
|
self.attachment = attachment
|
||||||
|
self.id = attachment.id
|
||||||
|
self.session = "vesc_" + uuid.uuid4().hex
|
||||||
|
self.opened = utc()
|
||||||
|
self.link = None
|
||||||
|
self.identity = None
|
||||||
|
self.error = None
|
||||||
|
self.telemetry = None
|
||||||
|
self.backup = None
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.retry_at = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def readable(self):
|
||||||
|
identity = self.identity
|
||||||
|
return self.readable_identity(identity)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def readable_identity(identity):
|
||||||
|
return bool(identity and identity["major"] in (5, 6, 7)
|
||||||
|
and identity["hardware_type"] in (None, 0))
|
||||||
|
|
||||||
|
def connect(self, factory):
|
||||||
|
with self.lock:
|
||||||
|
try:
|
||||||
|
self.session = "vesc_" + uuid.uuid4().hex
|
||||||
|
self.link = factory(self.attachment)
|
||||||
|
identity = firmware(self.link.query(0))
|
||||||
|
self.identity = identity
|
||||||
|
self.id = device_id("uuid:" + identity["uuid"])
|
||||||
|
self.error = None
|
||||||
|
except (OSError, ValueError, TimeoutError):
|
||||||
|
if self.link:
|
||||||
|
self.link.close()
|
||||||
|
self.link = None
|
||||||
|
self.error = "Контроллер не ответил. Проверьте питание, USB и доступность порта."
|
||||||
|
self.retry_at = time.monotonic() + 15
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
with self.lock:
|
||||||
|
if self.link:
|
||||||
|
self.link.close()
|
||||||
|
self.link = None
|
||||||
|
|
||||||
|
|
||||||
|
class Service:
|
||||||
|
def __init__(self, root, discover_fn=discover, link_factory=NativeLink):
|
||||||
|
self.root = Path(root)
|
||||||
|
self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
self.archive = Archive(self.root / "archive")
|
||||||
|
for path in sorted(self.root.glob("backup_op_*.json")):
|
||||||
|
self.archive.add("local", json.loads(path.read_text()))
|
||||||
|
self.discover_fn, self.link_factory = discover_fn, link_factory
|
||||||
|
self.devices = {}
|
||||||
|
self.lock = threading.RLock()
|
||||||
|
self.operation_lock = threading.Lock()
|
||||||
|
self.journal_lock = threading.Lock()
|
||||||
|
self.motor = MotorTest(self, atomic, utc)
|
||||||
|
self.drive = DriveProfile(self.root, atomic)
|
||||||
|
self.instance = "vesc_" + uuid.uuid4().hex
|
||||||
|
self.revision = 0
|
||||||
|
|
||||||
|
def scan(self):
|
||||||
|
attachments = {item.binding: item for item in self.discover_fn()}
|
||||||
|
with self.lock:
|
||||||
|
removed = [self.devices.pop(k) for k, d in list(self.devices.items())
|
||||||
|
if attachments.get(k) != d.attachment]
|
||||||
|
for key, item in attachments.items():
|
||||||
|
if key not in self.devices:
|
||||||
|
self.devices[key] = Device(item)
|
||||||
|
devices = list(self.devices.values())
|
||||||
|
for device in removed:
|
||||||
|
device.close()
|
||||||
|
for device in devices:
|
||||||
|
if device.link is not None and not getattr(device.link, "alive", True):
|
||||||
|
device.close()
|
||||||
|
if device.link is None and time.monotonic() >= device.retry_at:
|
||||||
|
device.connect(self.link_factory)
|
||||||
|
if self.operation_lock.acquire(blocking=False):
|
||||||
|
try:
|
||||||
|
for device in devices:
|
||||||
|
if device.link is not None and self.motor.limits.pending(device):
|
||||||
|
with device.lock:
|
||||||
|
try:
|
||||||
|
self.motor.limits.restore(device)
|
||||||
|
device.error = None
|
||||||
|
except (OSError, ValueError, TimeoutError):
|
||||||
|
device.error = "Прежние токовые пределы ещё не восстановлены. Новые проверки заблокированы."
|
||||||
|
finally:
|
||||||
|
self.operation_lock.release()
|
||||||
|
with self.lock:
|
||||||
|
self.revision += 1
|
||||||
|
|
||||||
|
def inventory(self, node_id):
|
||||||
|
with self.lock:
|
||||||
|
# Copy each generation before counting identities. Serial I/O never
|
||||||
|
# holds the inventory lock; it can complete during this projection.
|
||||||
|
devices = [d.__dict__.copy() for d in self.devices.values()]
|
||||||
|
keys = [device_id("uuid:" + d["identity"]["uuid"]) if d["identity"] and d["link"] else d["attachment"].id for d in devices]
|
||||||
|
items = []
|
||||||
|
for d, key in zip(devices, keys):
|
||||||
|
identity, attachment = d["identity"], d["attachment"]
|
||||||
|
unique = keys.count(key) == 1 and identity is not None and d["link"] is not None
|
||||||
|
identifier = key if unique else attachment.id
|
||||||
|
message = d["error"] if keys.count(key) == 1 else "Контроллеры сообщили одинаковый UUID. Настройка недоступна."
|
||||||
|
items.append({"id": identifier, "attachment_id": attachment.id,
|
||||||
|
"name": "VESC " + (identity["uuid"][:6].upper() if unique else "· USB " + attachment.usb),
|
||||||
|
"model": identity["hardware"] if unique else "VESC · USB", "kind": MODEL,
|
||||||
|
"firmware": identity["version"] if unique else None,
|
||||||
|
"initializable": True, "prepared": True, "configured": unique, "verified": unique,
|
||||||
|
"preparation_safe": True, "online": True, "usb": attachment.speed,
|
||||||
|
"connection_label": "USB " + attachment.usb + next((" · " + slot_label(self.drive.value["layout"], slot) for slot, binding in self.drive.value["bindings"].items() if binding["device_id"] == identifier), ""), "layers": [],
|
||||||
|
"vesc_status": {"identity": identity if unique else None, "readable": unique and Device.readable_identity(identity),
|
||||||
|
"engine": getattr(d["link"], "engine", None), "message": message, "telemetry": d["telemetry"], "backup": d["backup"], "board_settings_supported": True, "group_test_supported": True, "link_check_supported": True, "test_supported": unique and identity["version"] == "5.02" and identity["hardware"] == "75_300_R2", "drive_profile": self.drive.value, "test_limits": TEST_LIMITS, "speed_limits": SPEED_LIMITS, "hall_measurement": {"current_a": 5, "interruptible": False, "standstill_confirmation_required": True}, "foc_calibration": {"min_power_loss_w": 10, "max_power_loss_w": 150, "interruptible": False}, "test_mode": self.motor.mode, "test_active": self.motor.active, "rc_latched": self.motor.latched},
|
||||||
|
"snapshot": {"context": {"session_id": d["session"],
|
||||||
|
"device": {"device_id": identifier, "model": {"plugin_id": "missioncore.vesc",
|
||||||
|
"plugin_version": VERSION, "model_id": MODEL},
|
||||||
|
"stability": "stable" if unique else "provisional",
|
||||||
|
"basis": "hardware-identifier" if unique else "transport-local"},
|
||||||
|
"execution": {"node_id": node_id, "agent_instance_id": self.instance, "platform": "linux"},
|
||||||
|
"opened_at": d["opened"]}, "revision": self.revision, "observed_at": utc(),
|
||||||
|
"enrollment": "enrolled" if unique else "empty", "acquisition": "idle",
|
||||||
|
"connectivity": "connected" if unique else "degraded", "message": message}})
|
||||||
|
return {"items": items}
|
||||||
|
|
||||||
|
def validate(self, command):
|
||||||
|
if not isinstance(command, dict) or command.get("api_version") != SCHEMA or command.get("kind") != "OperationRequest":
|
||||||
|
raise ValueError("Invalid contract")
|
||||||
|
identifier = command.get("operation_id", "")
|
||||||
|
if not re.fullmatch(r"op_[0-9a-f]{32}", identifier) or command.get("idempotency_key") != identifier:
|
||||||
|
raise ValueError("Invalid operation identity")
|
||||||
|
action, params = command.get("action_id"), command.get("parameters")
|
||||||
|
if action not in ACTIONS or not isinstance(params, dict):
|
||||||
|
raise ValueError("Unsupported operation")
|
||||||
|
if action in ("vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.control.release"):
|
||||||
|
keys = {"sessions", "rig_clear", "duration_s", "current_a"} | ({"standstill_confirmed"} if action != "vesc.motor.pulse" else set()) | ({"erpm"} if action in ("vesc.motor.run", "vesc.drive.run") else set()) | ({"profile_revision", "device_ids"} if action == "vesc.drive.run" else set())
|
||||||
|
if (set(params) != keys or params["rig_clear"] is not True
|
||||||
|
or not isinstance(params["sessions"], dict) or not 1 <= len(params["sessions"]) <= 128
|
||||||
|
or type(params["current_a"]) not in (int, float) or not TEST_LIMITS["min_current_a"] <= params["current_a"] <= TEST_LIMITS["max_current_a"]
|
||||||
|
or type(params["duration_s"]) not in (int, float) or not TEST_LIMITS["min_duration_s"] <= params["duration_s"] <= TEST_LIMITS["max_duration_s"]):
|
||||||
|
raise ValueError("Explicit raised-rig confirmation, duration and controller sessions required")
|
||||||
|
if action != "vesc.motor.pulse" and params["standstill_confirmed"] is not True:
|
||||||
|
raise ValueError("Explicit observation of all motors at standstill required")
|
||||||
|
if action in ("vesc.motor.run", "vesc.drive.run") and (type(params["erpm"]) not in (int, float) or not SPEED_LIMITS["min_erpm"] <= abs(params["erpm"]) <= SPEED_LIMITS["max_erpm"]):
|
||||||
|
raise ValueError("Speed is outside the supported range")
|
||||||
|
if action == "vesc.drive.run" and (type(params["profile_revision"]) is not int or params["profile_revision"] < 0
|
||||||
|
or not isinstance(params["device_ids"], list) or not 2 <= len(params["device_ids"]) <= 128
|
||||||
|
or any(not isinstance(v, str) for v in params["device_ids"])
|
||||||
|
or len(set(params["device_ids"])) != len(params["device_ids"])):
|
||||||
|
raise ValueError("Explicit complete drive profile required")
|
||||||
|
elif action in ("vesc.hall.measure", "vesc.foc.calibrate"):
|
||||||
|
extra = {"max_power_loss_w"} if action == "vesc.foc.calibrate" else {"standstill_confirmed"}
|
||||||
|
if (set(params) != ({"sessions", "rig_clear", "native_cycle_confirmed"} | extra)
|
||||||
|
or params["rig_clear"] is not True or params["native_cycle_confirmed"] is not True
|
||||||
|
or not isinstance(params["sessions"], dict) or not 1 <= len(params["sessions"]) <= 128):
|
||||||
|
raise ValueError("Native procedure and rig confirmation required")
|
||||||
|
if action == "vesc.hall.measure" and params["standstill_confirmed"] is not True:
|
||||||
|
raise ValueError("Explicit observation of all motors at standstill required")
|
||||||
|
if action == "vesc.foc.calibrate" and (type(params["max_power_loss_w"]) not in (int,float) or not 10 <= params["max_power_loss_w"] <= 150):
|
||||||
|
raise ValueError("Heating budget must be between 10 and 150 W")
|
||||||
|
elif action == "vesc.link.check":
|
||||||
|
if set(params) != {"sessions"} or not isinstance(params["sessions"], dict) or not 1 <= len(params["sessions"]) <= 128:
|
||||||
|
raise ValueError("Controller sessions required")
|
||||||
|
elif action in ("vesc.drive.assign", "vesc.drive.unassign", "vesc.drive.layout"):
|
||||||
|
validate_drive(action, params)
|
||||||
|
elif params != {}:
|
||||||
|
raise ValueError("This operation has no parameters")
|
||||||
|
start = datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00"))
|
||||||
|
end = datetime.fromisoformat(command["deadline_at"].replace("Z", "+00:00"))
|
||||||
|
if start.tzinfo is None or end.tzinfo is None or not 0 < (end - start).total_seconds() <= 360:
|
||||||
|
raise ValueError("Invalid operation deadline")
|
||||||
|
return end
|
||||||
|
|
||||||
|
def execute(self, command):
|
||||||
|
deadline = self.validate(command)
|
||||||
|
path = self.root / (command["operation_id"] + ".json")
|
||||||
|
digest = hashlib.sha256(json.dumps(command, sort_keys=True).encode()).hexdigest()
|
||||||
|
if command["action_id"] == "vesc.motor.stop":
|
||||||
|
with self.journal_lock:
|
||||||
|
if path.exists():
|
||||||
|
previous = json.loads(path.read_text())
|
||||||
|
if previous["digest"] != digest: raise ValueError("Operation identity conflict")
|
||||||
|
return previous["receipt"]
|
||||||
|
if deadline <= datetime.now(timezone.utc): raise ValueError("Operation expired")
|
||||||
|
self.motor.cancel()
|
||||||
|
result = {"state": "complete", "result": {"stop_requested": True, "interruptible": self.motor.mode not in ("hall", "foc"), "drive_profile": self.drive.value, "test_limits": TEST_LIMITS, "test_active": self.motor.active}}
|
||||||
|
atomic(path, {"digest": digest, "receipt": result})
|
||||||
|
return result
|
||||||
|
if not self.operation_lock.acquire(blocking=False):
|
||||||
|
raise ValueError("Another VESC operation is running")
|
||||||
|
try:
|
||||||
|
if path.exists():
|
||||||
|
previous = json.loads(path.read_text())
|
||||||
|
if previous["digest"] != digest:
|
||||||
|
raise ValueError("Operation identity conflict")
|
||||||
|
return previous["receipt"]
|
||||||
|
if deadline <= datetime.now(timezone.utc):
|
||||||
|
raise ValueError("Operation expired")
|
||||||
|
with self.lock:
|
||||||
|
matches = [d for d in self.devices.values() if d.id == command["session"]["device_id"]]
|
||||||
|
if len(matches) != 1 or matches[0].session != command["session"]["session_id"]:
|
||||||
|
raise ValueError("Device session changed")
|
||||||
|
device = matches[0]
|
||||||
|
# Keep backups and receipts bounded without deleting evidence automatically.
|
||||||
|
if sum(p.stat().st_size for p in self.root.glob("*.json")) > 32 * 1024 * 1024:
|
||||||
|
raise ValueError("Read journal is full")
|
||||||
|
record = {"digest": digest, "receipt": {"state": "unknown", "error": "Чтение не подтверждено. Обновите состояние."}}
|
||||||
|
with self.journal_lock:
|
||||||
|
if path.exists(): raise ValueError("Operation identity already reserved")
|
||||||
|
atomic(path, record)
|
||||||
|
try:
|
||||||
|
if command["action_id"] == "vesc.link.check":
|
||||||
|
from .link_check import measure
|
||||||
|
with self.lock: devices = list(self.devices.values())
|
||||||
|
result = measure(self, command, devices)
|
||||||
|
record["receipt"] = {"state": "complete", "result": result}
|
||||||
|
atomic(path, record)
|
||||||
|
return record["receipt"]
|
||||||
|
if command["action_id"] in ("vesc.drive.assign", "vesc.drive.unassign", "vesc.drive.layout"):
|
||||||
|
if device.identity is None: raise Rejected("Сначала подтвердите личность VESC.")
|
||||||
|
try:
|
||||||
|
result = self.drive.update(command["action_id"], command["parameters"], device)
|
||||||
|
except ValueError as error:
|
||||||
|
raise Rejected(str(error)) from error
|
||||||
|
record["receipt"] = {"state": "complete", "result": result}
|
||||||
|
atomic(path, record)
|
||||||
|
return record["receipt"]
|
||||||
|
if command["action_id"] in ("vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.hall.measure", "vesc.foc.calibrate", "vesc.control.release"):
|
||||||
|
with self.lock:
|
||||||
|
devices = list(self.devices.values())
|
||||||
|
actual = {d.id: d.session for d in devices}
|
||||||
|
if len(actual) != len(devices) or actual != command["parameters"]["sessions"]:
|
||||||
|
raise Rejected("Состав или сеансы VESC изменились. Обновите устройства.")
|
||||||
|
run_command = command
|
||||||
|
if command["action_id"] == "vesc.hall.measure":
|
||||||
|
run_command = {**command, "parameters": {**command["parameters"], "current_a": 5, "duration_s": 30}}
|
||||||
|
if command["action_id"] == "vesc.foc.calibrate":
|
||||||
|
run_command = {**command, "parameters": {**command["parameters"], "current_a": 5, "duration_s": 225}}
|
||||||
|
result = self.motor.run(run_command, devices, device, command["action_id"] == "vesc.control.release")
|
||||||
|
record["receipt"] = {"state": "complete", "result": result}
|
||||||
|
atomic(path, record)
|
||||||
|
return record["receipt"]
|
||||||
|
with device.lock:
|
||||||
|
if device.link is None:
|
||||||
|
raise OSError("Device disconnected")
|
||||||
|
remaining = (deadline - datetime.now(timezone.utc)).total_seconds()
|
||||||
|
if remaining < 8:
|
||||||
|
raise TimeoutError("Insufficient time for bounded read")
|
||||||
|
actual = firmware(device.link.query(0))
|
||||||
|
if actual != device.identity:
|
||||||
|
device.link.close()
|
||||||
|
device.link = None
|
||||||
|
device.identity = None
|
||||||
|
device.session = "vesc_" + uuid.uuid4().hex
|
||||||
|
raise ValueError("Controller identity changed")
|
||||||
|
result = {"identity": actual, "device_id": device.id, "observed_at": utc()}
|
||||||
|
action = command["action_id"]
|
||||||
|
if action in {"vesc.telemetry.read", "vesc.limits.read", "vesc.config.backup"} and not device.readable:
|
||||||
|
raise ValueError("Firmware read layout is unsupported")
|
||||||
|
if action == "vesc.telemetry.read":
|
||||||
|
result.update(values=values(device.link.query(4)), monotonic_at=time.monotonic())
|
||||||
|
device.telemetry = result
|
||||||
|
elif action == "vesc.limits.read":
|
||||||
|
from .limits_view import read_limits
|
||||||
|
result.update(parameters=read_limits(device.link))
|
||||||
|
if firmware(device.link.query(0)) != actual:
|
||||||
|
raise ValueError("Controller changed during configuration read")
|
||||||
|
elif action == "vesc.input.read":
|
||||||
|
if actual["version"] != "5.02": raise ValueError("Input layout unsupported")
|
||||||
|
result["input"] = ppm(device.link.query(31))
|
||||||
|
elif action == "vesc.can.read":
|
||||||
|
if actual["version"] != "5.02": raise ValueError("CAN layout unsupported")
|
||||||
|
started = time.monotonic()
|
||||||
|
reply = device.link.query(62, timeout=8)
|
||||||
|
if not reply or reply[0] != 62: raise ValueError("Invalid CAN reply")
|
||||||
|
result.update(can_ids=list(reply[1:]), elapsed_s=time.monotonic()-started)
|
||||||
|
elif action == "vesc.config.backup":
|
||||||
|
configs = {}
|
||||||
|
for name, code in (("motor", 14), ("application", 17)):
|
||||||
|
raw = device.link.query(code)
|
||||||
|
if not 5 <= len(raw) <= 10000:
|
||||||
|
raise ValueError("Incomplete configuration")
|
||||||
|
configs[name] = {"encoding": "base64", "payload": base64.b64encode(raw).decode(),
|
||||||
|
"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest(),
|
||||||
|
"signature_hex": raw[1:5].hex()}
|
||||||
|
# Identity is checked on the same exclusively held attachment at both ends.
|
||||||
|
if firmware(device.link.query(0)) != actual:
|
||||||
|
raise ValueError("Controller changed during backup")
|
||||||
|
result.update(schema="missioncore.vesc.config-backup/v1", configs=configs,
|
||||||
|
decoded=False, operation_id=command["operation_id"], monotonic_at=time.monotonic())
|
||||||
|
backup_path = self.root / ("backup_" + command["operation_id"] + ".json")
|
||||||
|
atomic(backup_path, result)
|
||||||
|
self.archive.add("local", result)
|
||||||
|
device.backup = {"observed_at": result["observed_at"], "operation_id": command["operation_id"],
|
||||||
|
"configs": {k: {"bytes": v["bytes"], "sha256": v["sha256"]} for k, v in configs.items()}}
|
||||||
|
record["receipt"] = {"state": "complete", "result": result}
|
||||||
|
except (OSError, ValueError, TimeoutError) as error:
|
||||||
|
record["receipt"] = {"state": "error", "error": str(error) if isinstance(error, Rejected) else "Операция не выполнена. Проверьте связь и совместимость контроллера."}
|
||||||
|
if getattr(error, "native_rpc", None) is not None:
|
||||||
|
# Keep transport evidence in the receipt, not product copy.
|
||||||
|
# This covers ordinary reads and preflight before a motor
|
||||||
|
# procedure has its own result/failure envelope.
|
||||||
|
record["receipt"]["result"] = {"failure": {
|
||||||
|
"type": type(error).__name__, "native_rpc": error.native_rpc,
|
||||||
|
"native_history": getattr(error, "native_history", []),
|
||||||
|
}}
|
||||||
|
atomic(path, record)
|
||||||
|
return record["receipt"]
|
||||||
|
|
||||||
|
finally:
|
||||||
|
self.operation_lock.release()
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Speed acquisition and measured hold time; never infers motion from a command."""
|
||||||
|
from .protocol import SPEED_LIMITS
|
||||||
|
|
||||||
|
|
||||||
|
class SpeedHold:
|
||||||
|
def __init__(self, erpm, duration, started):
|
||||||
|
self.erpm, self.duration, self.started = erpm, duration, started
|
||||||
|
self.previous = started
|
||||||
|
self.tachometer = None
|
||||||
|
self.motion_at = None
|
||||||
|
self.stable_at = None
|
||||||
|
self.hold_started = None
|
||||||
|
self.lost_at = None
|
||||||
|
self.rotation_s = 0.0
|
||||||
|
self.phase = "accelerating"
|
||||||
|
self.previous_good = False
|
||||||
|
|
||||||
|
def update(self, now, value):
|
||||||
|
delta = now - self.previous
|
||||||
|
self.previous = now
|
||||||
|
if self.tachometer is not None and value["tachometer"] != self.tachometer:
|
||||||
|
self.motion_at = now
|
||||||
|
self.tachometer = value["tachometer"]
|
||||||
|
good = (abs(value["erpm"] - self.erpm) <= abs(self.erpm) * SPEED_LIMITS["speed_tolerance"]
|
||||||
|
and self.motion_at is not None and now - self.motion_at <= 0.25)
|
||||||
|
error = None
|
||||||
|
if self.hold_started is None:
|
||||||
|
if good:
|
||||||
|
if self.stable_at is None: self.stable_at = now
|
||||||
|
if now - self.stable_at >= SPEED_LIMITS["settle_s"]:
|
||||||
|
self.hold_started = now
|
||||||
|
self.phase = "holding"
|
||||||
|
else:
|
||||||
|
self.stable_at = None
|
||||||
|
if self.hold_started is None and now - self.started >= SPEED_LIMITS["startup_timeout_s"]:
|
||||||
|
error = "Мотор не вышел на заданную скорость за 15 секунд. Отсчёт вращения не начался."
|
||||||
|
else:
|
||||||
|
if good:
|
||||||
|
# Both endpoints must be observed in band. Do not count a gap,
|
||||||
|
# USB delay, stationary tachometer or an unobserved last interval.
|
||||||
|
if self.previous_good and delta <= 0.25: self.rotation_s += delta
|
||||||
|
self.lost_at = None
|
||||||
|
else:
|
||||||
|
if self.lost_at is None: self.lost_at = now
|
||||||
|
if now - self.lost_at >= SPEED_LIMITS["lost_speed_timeout_s"]:
|
||||||
|
error = "Мотор перестал удерживать заданную скорость. Проверка завершена досрочно."
|
||||||
|
self.previous_good = good
|
||||||
|
if now - self.started > SPEED_LIMITS["startup_timeout_s"] + self.duration + 5:
|
||||||
|
error = "Истёк общий срок проверки; заданное время вращения не набрано."
|
||||||
|
done = self.rotation_s >= self.duration
|
||||||
|
setpoint = (1 if self.erpm > 0 else -1) * min(abs(self.erpm), max(1, (now - self.started) * SPEED_LIMITS["ramp_erpm_per_s"]))
|
||||||
|
return setpoint, done, error
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""FW 5.02 volatile limits with durable, identity-bound restoration.
|
||||||
|
|
||||||
|
COMM_SET_MCCONF_TEMP (48): store=false, forward=false, ack=true, divide=false.
|
||||||
|
Reference: pinned bldc 3f670137 commands.c. No flash write, no app changes.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
|
||||||
|
from .configuration import decode
|
||||||
|
from .protocol import frame, firmware, values, ppm
|
||||||
|
|
||||||
|
FIELDS = ("l_current_min_scale", "l_current_max_scale", "l_min_erpm", "l_max_erpm",
|
||||||
|
"l_min_duty", "l_max_duty", "l_watt_min", "l_watt_max",
|
||||||
|
"l_in_current_min", "l_in_current_max")
|
||||||
|
|
||||||
|
|
||||||
|
def packet(config):
|
||||||
|
numbers = [config[key] for key in FIELDS]
|
||||||
|
if not all(math.isfinite(v) for v in numbers): raise ValueError("Invalid volatile limits")
|
||||||
|
return frame(bytes([48, 0, 0, 1, 0]) + struct.pack(">10f", *numbers))
|
||||||
|
|
||||||
|
|
||||||
|
class TemporaryLimits:
|
||||||
|
def __init__(self, service, atomic):
|
||||||
|
self.service, self.atomic = service, atomic
|
||||||
|
|
||||||
|
def path(self, device):
|
||||||
|
return self.service.root / ("limits_" + device.id + ".json")
|
||||||
|
|
||||||
|
def pending(self, device):
|
||||||
|
return self.path(device).exists()
|
||||||
|
|
||||||
|
def apply(self, device, raw, current_a):
|
||||||
|
if self.pending(device): raise ValueError("Previous limits restoration is pending")
|
||||||
|
old = decode(raw, "motor")
|
||||||
|
if not (old["l_current_min"] < 0 < old["l_current_max"]
|
||||||
|
and 0 < old["l_current_min_scale"] <= 1 and 0 < old["l_current_max_scale"] <= 1):
|
||||||
|
raise ValueError("Unsupported motor current limits")
|
||||||
|
changed = dict(old)
|
||||||
|
changed["l_current_max_scale"] = min(old["l_current_max_scale"], current_a / old["l_current_max"])
|
||||||
|
changed["l_current_min_scale"] = min(old["l_current_min_scale"], current_a / -old["l_current_min"])
|
||||||
|
# Persist BEFORE sending: a lost ACK or killed process must be recoverable.
|
||||||
|
record = {"identity": device.identity, "original": base64.b64encode(raw).decode(),
|
||||||
|
"applied": {key: changed[key] for key in FIELDS}}
|
||||||
|
self.atomic(self.path(device), record)
|
||||||
|
device.link.set_temporary_limits(changed)
|
||||||
|
actual = decode(device.link.query(14), "motor")
|
||||||
|
if any(actual[k] != old[k] for k in old if k not in FIELDS):
|
||||||
|
raise ValueError("Unexpected configuration change")
|
||||||
|
if (actual["l_current_max"] * actual["l_current_max_scale"] > current_a + 0.001
|
||||||
|
or -actual["l_current_min"] * actual["l_current_min_scale"] > current_a + 0.001):
|
||||||
|
raise ValueError("Current limit readback failed")
|
||||||
|
if any(not math.isclose(actual[k], changed[k], rel_tol=1e-6, abs_tol=1e-8) for k in FIELDS):
|
||||||
|
raise ValueError("Volatile limits readback differs")
|
||||||
|
return actual
|
||||||
|
|
||||||
|
def restore(self, device):
|
||||||
|
path = self.path(device)
|
||||||
|
if not path.exists(): return True
|
||||||
|
record = json.loads(path.read_text())
|
||||||
|
if firmware(device.link.query(0)) != record["identity"]: raise ValueError("Restoration identity changed")
|
||||||
|
raw = base64.b64decode(record["original"], validate=True)
|
||||||
|
old = decode(raw, "motor")
|
||||||
|
actual_raw = device.link.query(14)
|
||||||
|
actual = decode(actual_raw, "motor")
|
||||||
|
# An independently changed configuration is never overwritten.
|
||||||
|
if any(actual[k] != old[k] for k in old if k not in FIELDS):
|
||||||
|
raise ValueError("Configuration changed outside this operation; restoration pending")
|
||||||
|
if any(not (math.isclose(actual[k], old[k], rel_tol=1e-6, abs_tol=1e-8)
|
||||||
|
or math.isclose(actual[k], record["applied"][k], rel_tol=1e-6, abs_tol=1e-8)) for k in FIELDS):
|
||||||
|
raise ValueError("Limits changed outside this operation; restoration pending")
|
||||||
|
if actual_raw != raw:
|
||||||
|
state = values(device.link.query(4))
|
||||||
|
from .receiver import active, neutral_band
|
||||||
|
application = decode(device.link.query(17), "application")
|
||||||
|
if abs(state["motor_current_a"]) > 1 or active(ppm(device.link.query(31))["level"], neutral_band(application)):
|
||||||
|
raise ValueError("Wait for zero current and neutral before restoring limits")
|
||||||
|
device.link.set_temporary_limits(old)
|
||||||
|
if device.link.query(14) != raw: raise ValueError("Original configuration readback failed")
|
||||||
|
path.unlink()
|
||||||
|
# atomic() fsyncs the directory on writes; persist removal as well.
|
||||||
|
import os
|
||||||
|
fd = os.open(path.parent, os.O_RDONLY)
|
||||||
|
try: os.fsync(fd)
|
||||||
|
finally: os.close(fd)
|
||||||
|
return True
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Targeted attachment checks preserve generation and topology rejection."""
|
||||||
|
from pathlib import Path
|
||||||
|
import sys,tempfile,unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
|
||||||
|
from runtime.serial import Attachment, attachment_at, check_attachment, discover
|
||||||
|
|
||||||
|
class AttachmentTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
|
||||||
|
self.root=Path(self.temp.name);self.device=self.root/'1-2.3';self.device.mkdir()
|
||||||
|
for name,value in {'idVendor':'0483','idProduct':'5740','product':'ChibiOS/RT Virtual COM Port','devnum':'17','speed':'12'}.items():
|
||||||
|
(self.device/name).write_text(value)
|
||||||
|
(self.device/'1-2.3:1.0/tty/ttyACM2').mkdir(parents=True)
|
||||||
|
self.expected=Attachment('1-2.3','17','ttyACM2','12 Мбит/с')
|
||||||
|
|
||||||
|
def test_target_check_never_enumerates_siblings(self):
|
||||||
|
original=Path.iterdir
|
||||||
|
def entries(path):
|
||||||
|
if path==self.root:raise AssertionError('full bus scan in per-request check')
|
||||||
|
return original(path)
|
||||||
|
with patch.object(Path,'iterdir',entries):check_attachment(self.expected,self.root)
|
||||||
|
self.assertEqual(discover(self.root),[self.expected])
|
||||||
|
|
||||||
|
def test_generation_change_or_removed_port_invalidates_old_owner(self):
|
||||||
|
(self.device/'devnum').write_text('18')
|
||||||
|
with self.assertRaises(OSError):check_attachment(self.expected,self.root)
|
||||||
|
(self.device/'devnum').write_text('17')
|
||||||
|
(self.device/'1-2.3:1.0/tty/ttyACM2').rmdir()
|
||||||
|
with self.assertRaises(OSError):check_attachment(self.expected,self.root)
|
||||||
|
|
||||||
|
def test_ambiguous_tty_or_other_vendor_rejected(self):
|
||||||
|
extra=self.device/'1-2.3:1.1/tty/ttyACM3';extra.mkdir(parents=True)
|
||||||
|
self.assertIsNone(attachment_at(self.device))
|
||||||
|
extra.rmdir();(self.device/'idVendor').write_text('1234')
|
||||||
|
self.assertIsNone(attachment_at(self.device))
|
||||||
|
|
||||||
|
def test_driver_sibling_tty_cannot_be_mistaken_for_device_interface(self):
|
||||||
|
(self.device/'driver/tty/ttyACM9').mkdir(parents=True)
|
||||||
|
self.assertEqual(attachment_at(self.device),self.expected)
|
||||||
|
with self.assertRaises(OSError):check_attachment(Attachment('../1-2.3','17','ttyACM2','12 Мбит/с'),self.root)
|
||||||
|
|
||||||
|
def test_generation_change_during_read_is_rejected(self):
|
||||||
|
original=Path.read_text;reads=0
|
||||||
|
def read(path,*args,**kwargs):
|
||||||
|
nonlocal reads
|
||||||
|
if path==self.device/'devnum':
|
||||||
|
reads+=1;return '17' if reads==1 else '18'
|
||||||
|
return original(path,*args,**kwargs)
|
||||||
|
with patch.object(Path,'read_text',read):self.assertIsNone(attachment_at(self.device))
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""User-assigned drive layout, persistence and conflict tests; no motor I/O."""
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
|
||||||
|
import test_reader
|
||||||
|
|
||||||
|
|
||||||
|
class DriveTests(test_reader.ServiceTests):
|
||||||
|
def test_change_layout_keeps_assignments_without_motor_commands(self):
|
||||||
|
left, right = list(self.service.devices.values())
|
||||||
|
self.assign(left, 'left.1')
|
||||||
|
self.assign(right, 'right.1')
|
||||||
|
command = self.command(action='vesc.drive.layout')
|
||||||
|
command['parameters'] = {'layout': '2x2', 'revision': 2}
|
||||||
|
result = self.service.execute(command)
|
||||||
|
self.assertEqual(result['state'], 'complete')
|
||||||
|
self.assertEqual(result['result']['layout'], '2x2')
|
||||||
|
self.assertEqual(result['result']['bindings']['left.1']['device_id'], left.id)
|
||||||
|
self.assertEqual(len(result['result']['bindings']), 2)
|
||||||
|
self.assertTrue(all(link.commands == [0] for link in test_reader.FakeLink.instances))
|
||||||
|
|
||||||
|
def assign(self, device, slot, layout='1x1', revision=None):
|
||||||
|
item=next(i for i in self.service.inventory('node_synthetic')['items'] if i['id']==device.id)
|
||||||
|
command=self.command(item,action='vesc.drive.assign')
|
||||||
|
command['parameters']={'layout':layout,'slot':slot,'revision':self.service.drive.value['revision'] if revision is None else revision}
|
||||||
|
return self.service.execute(command)
|
||||||
|
|
||||||
|
def test_assign_is_user_metadata_without_serial_commands_and_survives_restart(self):
|
||||||
|
left,right=list(self.service.devices.values())
|
||||||
|
self.assertEqual(self.assign(left,'left.1')['state'],'complete')
|
||||||
|
self.assertEqual(self.assign(right,'right.1')['state'],'complete')
|
||||||
|
self.assertTrue(all(link.commands==[0] for link in test_reader.FakeLink.instances))
|
||||||
|
from runtime.drive_profile import DriveProfile
|
||||||
|
restored=DriveProfile(self.service.root,self.service.drive.atomic)
|
||||||
|
self.assertEqual(restored.value,self.service.drive.value)
|
||||||
|
self.assertEqual(restored.value['layout'],'1x1')
|
||||||
|
self.assertEqual(restored.value['bindings']['left.1']['device_id'],left.id)
|
||||||
|
self.assertEqual(self.service.drive.path.stat().st_mode & 0o777,0o600)
|
||||||
|
|
||||||
|
def test_duplicate_slot_and_stale_revision_never_replace_an_assignment(self):
|
||||||
|
left,right=list(self.service.devices.values())
|
||||||
|
self.assign(left,'left.1')
|
||||||
|
self.assertEqual(self.assign(right,'left.1')['state'],'error')
|
||||||
|
self.assertEqual(self.assign(right,'right.1',revision=0)['state'],'error')
|
||||||
|
self.assertEqual(list(self.service.drive.value['bindings']),['left.1'])
|
||||||
|
|
||||||
|
def test_layout_shrink_requires_explicit_unassign_of_removed_slots(self):
|
||||||
|
left,right=list(self.service.devices.values())
|
||||||
|
self.assign(left,'left.2','2x2')
|
||||||
|
self.assign(right,'right.1','2x2')
|
||||||
|
self.assertEqual(self.assign(right,'right.1','1x1')['state'],'error')
|
||||||
|
command=self.command(action='vesc.drive.unassign')
|
||||||
|
command['parameters']={'slot':'left.2','revision':self.service.drive.value['revision']}
|
||||||
|
self.assertEqual(self.service.execute(command)['state'],'complete')
|
||||||
|
self.assertEqual(self.assign(right,'right.1','1x1')['state'],'complete')
|
||||||
|
self.assertEqual(list(self.service.drive.value['bindings']),['right.1'])
|
||||||
|
|
||||||
|
def test_reassign_moves_one_controller_and_replay_does_not_repeat(self):
|
||||||
|
device=next(iter(self.service.devices.values()))
|
||||||
|
self.assign(device,'left.1')
|
||||||
|
command=self.command(action='vesc.drive.assign')
|
||||||
|
command['parameters']={'layout':'2x2','slot':'right.2','revision':1}
|
||||||
|
result=self.service.execute(command)
|
||||||
|
self.assertEqual(result['state'],'complete')
|
||||||
|
self.assertEqual(list(result['result']['bindings']),['right.2'])
|
||||||
|
self.assertEqual(self.service.execute(command),result)
|
||||||
|
self.assertEqual(self.service.drive.value['revision'],2)
|
||||||
|
|
||||||
|
test_two_attachments_promote_independently = None
|
||||||
|
test_uuid_collision_never_aliases_a_controller = None
|
||||||
|
test_reconnect_invalidates_old_session_but_retains_uuid = None
|
||||||
|
test_receipt_replay_never_repeats_serial_query = None
|
||||||
|
test_backup_is_raw_hashed_and_bound_to_identity = None
|
||||||
|
test_arbitrary_writes_parameters_and_expired_requests_rejected = None
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""Fault injection at the native calibration transaction boundary; no hardware."""
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import unittest
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import test_motor_test
|
||||||
|
from test_motor_test import configuration
|
||||||
|
from runtime.configuration import decode
|
||||||
|
|
||||||
|
|
||||||
|
class CalibrationTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.case = test_motor_test.PulseTests('test_fixed_wire_commands_no_broadcast_and_no_parameters')
|
||||||
|
self.case.setUp()
|
||||||
|
self.addCleanup(self.case.tearDown)
|
||||||
|
c = self.case
|
||||||
|
self.original = configuration('motor', {**decode(c.motor, 'motor'),
|
||||||
|
'l_current_min': -60, 'l_in_current_min': -55, 'foc_openloop_rpm': 600, 'foc_sl_erpm': 2000})
|
||||||
|
self.configs = {d.id: self.original for d in c.devices}
|
||||||
|
self.starts = []
|
||||||
|
self.finished = False
|
||||||
|
for device in c.devices:
|
||||||
|
previous = device.link.query
|
||||||
|
def query(code, timeout=2, previous=previous, device=device):
|
||||||
|
if code == 14: return self.configs[device.id]
|
||||||
|
if code == 62: return bytes([62])
|
||||||
|
return previous(code, timeout)
|
||||||
|
device.link.query = query
|
||||||
|
def start(loss, device=device):
|
||||||
|
self.starts.append((device.id, loss))
|
||||||
|
self.configs[device.id] = configuration('motor', {**decode(self.original, 'motor'),
|
||||||
|
'foc_motor_r': 0.08, 'foc_motor_l': 0.00003, 'foc_motor_flux_linkage': 0.009, 'foc_sensor_mode': 0})
|
||||||
|
device.link.calibrate_foc = start
|
||||||
|
def result():
|
||||||
|
if not self.finished:
|
||||||
|
self.finished = True
|
||||||
|
return {'running': True}
|
||||||
|
return {'running': False, 'uncertain': False, 'result': {
|
||||||
|
'kind': 'foc', 'completed': True, 'success': True, 'validated': True, 'code': 0, 'sensor_mode': 0}}
|
||||||
|
device.link.procedure_result = result
|
||||||
|
|
||||||
|
def command(self):
|
||||||
|
c = self.case.command(action='vesc.foc.calibrate')
|
||||||
|
c['deadline_at'] = (datetime.fromisoformat(c['requested_at']) + timedelta(seconds=300)).isoformat()
|
||||||
|
c['parameters'] = {'rig_clear': True, 'native_cycle_confirmed': True, 'max_power_loss_w': 50,
|
||||||
|
'sessions': {d.id: d.session for d in self.case.devices}}
|
||||||
|
return c
|
||||||
|
|
||||||
|
def test_native_success_archives_both_versions_and_never_sends_host_torque(self):
|
||||||
|
command = self.command()
|
||||||
|
receipt = self.case.service.execute(command)
|
||||||
|
result = receipt['result']
|
||||||
|
self.assertTrue(result['success'], receipt)
|
||||||
|
self.assertEqual(result['native']['sensor_mode'], 0)
|
||||||
|
self.assertEqual(self.starts, [(self.case.devices[0].id, 50)])
|
||||||
|
self.assertEqual(len(result['backups']), 2)
|
||||||
|
self.assertEqual(len(result['after_backups']), 2)
|
||||||
|
self.assertEqual(self.case.currents, [])
|
||||||
|
self.assertTrue(all(action == 'release' for _, action in self.case.sent))
|
||||||
|
self.assertFalse((self.case.service.root/'calibration-pending.json').exists())
|
||||||
|
self.assertEqual(self.case.service.execute(command), receipt)
|
||||||
|
self.assertEqual(len(self.starts), 1)
|
||||||
|
|
||||||
|
def test_unknown_completion_persists_across_service_restart_and_blocks_new_motion(self):
|
||||||
|
target = self.case.devices[0]
|
||||||
|
target.link.procedure_result = lambda: {'running': False, 'uncertain': True, 'result': {}}
|
||||||
|
result = self.case.service.execute(self.command())['result']
|
||||||
|
self.assertFalse(result['completed'])
|
||||||
|
self.assertFalse(result['success'])
|
||||||
|
self.assertTrue((self.case.service.root/'calibration-pending.json').exists())
|
||||||
|
self.assertTrue(self.case.service.motor.latched)
|
||||||
|
from runtime.service import Service
|
||||||
|
restarted = Service(self.case.service.root, discover_fn=self.case.service.discover_fn)
|
||||||
|
self.assertTrue(restarted.motor.latched)
|
||||||
|
self.assertEqual(self.case.service.execute(self.case.command_for_pulse())['state'], 'error')
|
||||||
|
self.assertEqual(len(self.starts), 1)
|
||||||
|
|
||||||
|
def test_peer_configuration_change_is_archived_and_blocks_further_movement(self):
|
||||||
|
start = self.case.devices[0].link.calibrate_foc
|
||||||
|
def mutate(loss):
|
||||||
|
start(loss)
|
||||||
|
self.configs[self.case.devices[1].id] = configuration('motor', {**decode(self.original, 'motor'), 'foc_motor_r': 1})
|
||||||
|
self.case.devices[0].link.calibrate_foc = mutate
|
||||||
|
result = self.case.service.execute(self.command())['result']
|
||||||
|
self.assertFalse(result['success'])
|
||||||
|
self.assertFalse(result['configuration_verified'])
|
||||||
|
self.assertEqual(len(result['after_backups']), 2)
|
||||||
|
self.assertTrue(self.case.service.motor.latched)
|
||||||
|
|
||||||
|
def test_known_can_peers_still_block_upstream_broadcast_side_effect(self):
|
||||||
|
target = self.case.devices[0]
|
||||||
|
query = target.link.query
|
||||||
|
target.link.query = lambda code, timeout=2: bytes([62, 11]) if code == 62 else query(code, timeout)
|
||||||
|
result = self.case.service.execute(self.command())
|
||||||
|
self.assertEqual(result['state'], 'error')
|
||||||
|
self.assertEqual(self.starts, [])
|
||||||
|
|
||||||
|
def test_confirmed_failed_cycle_restores_and_can_be_retried_explicitly(self):
|
||||||
|
target = self.case.devices[0]
|
||||||
|
target.link.calibrate_foc = lambda loss: self.starts.append((target.id, loss))
|
||||||
|
target.link.procedure_result = lambda: {'running': False, 'uncertain': False, 'result': {
|
||||||
|
'completed': True, 'success': False, 'validated': True, 'code': -10}}
|
||||||
|
result = self.case.service.execute(self.command())['result']
|
||||||
|
self.assertTrue(result['completed'])
|
||||||
|
self.assertFalse(result['success'])
|
||||||
|
self.assertTrue(result['configuration_verified'])
|
||||||
|
self.assertFalse((self.case.service.root/'calibration-pending.json').exists())
|
||||||
|
|
||||||
|
def test_missing_native_write_ack_never_accepts_calibration(self):
|
||||||
|
target = self.case.devices[0]
|
||||||
|
target.link.procedure_result = lambda: {'running': False, 'uncertain': True, 'result': {
|
||||||
|
'completed': True, 'success': False, 'validated': False, 'code': 0, 'error': 'ACK missing'}}
|
||||||
|
result = self.case.service.execute(self.command())['result']
|
||||||
|
self.assertFalse(result['success'])
|
||||||
|
self.assertTrue((self.case.service.root/'calibration-pending.json').exists())
|
||||||
|
|
||||||
|
def test_calibration_heating_budget_is_finite_and_explicit(self):
|
||||||
|
for loss in (True, float('nan'), float('inf'), 0, 151):
|
||||||
|
command = self.command(); command['parameters']['max_power_loss_w'] = loss
|
||||||
|
with self.assertRaises(ValueError): self.case.service.execute(command)
|
||||||
|
self.assertEqual(self.starts, [])
|
||||||
|
|
||||||
|
def averaged_current(self, transient=True):
|
||||||
|
target = self.case.devices[0]
|
||||||
|
query = target.link.query
|
||||||
|
reads = []
|
||||||
|
def read(code, timeout=2):
|
||||||
|
raw = query(code, timeout)
|
||||||
|
if code == 4 and self.starts:
|
||||||
|
reads.append(1)
|
||||||
|
if not transient or len(reads) == 1:
|
||||||
|
return raw[:5] + struct.pack('>i', 367) + raw[9:]
|
||||||
|
return raw
|
||||||
|
target.link.query = read
|
||||||
|
return query, reads
|
||||||
|
|
||||||
|
def test_cycle_average_is_drained_before_verifying_released_current(self):
|
||||||
|
_, reads = self.averaged_current()
|
||||||
|
result = self.case.service.execute(self.command())['result']
|
||||||
|
self.assertTrue(result['success'], result)
|
||||||
|
self.assertTrue(result['release_confirmed'])
|
||||||
|
self.assertGreaterEqual(len(reads), 2)
|
||||||
|
self.assertEqual(result['after'][self.case.devices[0].id]['motor_current_a'], 0)
|
||||||
|
|
||||||
|
def test_explicit_neutral_recovery_after_verified_cycle_never_recalibrates(self):
|
||||||
|
query, _ = self.averaged_current(transient=False)
|
||||||
|
command = self.command()
|
||||||
|
receipt = self.case.service.execute(command)
|
||||||
|
self.assertFalse(receipt['result']['release_confirmed'])
|
||||||
|
self.assertTrue(receipt['result']['configuration_verified'])
|
||||||
|
self.case.devices[0].link.query = query
|
||||||
|
release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'; release['parameters']['standstill_confirmed'] = True
|
||||||
|
result = self.case.service.execute(release)['result']
|
||||||
|
self.assertEqual(result['authority'], 'ready')
|
||||||
|
self.assertFalse(result['calibration_recovered']['calibration_replayed'])
|
||||||
|
self.assertEqual(len(self.starts), 1)
|
||||||
|
self.assertEqual(self.case.service.execute(command), receipt)
|
||||||
|
self.assertFalse((self.case.service.root/'calibration-pending.json').exists())
|
||||||
|
self.assertEqual(self.case.currents, [])
|
||||||
|
|
||||||
|
def test_recovery_rejects_new_configuration_and_does_not_overwrite_it(self):
|
||||||
|
query, _ = self.averaged_current(transient=False)
|
||||||
|
self.case.service.execute(self.command())
|
||||||
|
self.case.devices[0].link.query = query
|
||||||
|
key = self.case.devices[0].id
|
||||||
|
self.configs[key] = configuration('motor', {**decode(self.configs[key], 'motor'), 'foc_motor_r': .5})
|
||||||
|
before = self.configs[key]
|
||||||
|
release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'; release['parameters']['standstill_confirmed'] = True
|
||||||
|
receipt = self.case.service.execute(release)
|
||||||
|
self.assertEqual(receipt['state'], 'error')
|
||||||
|
self.assertEqual(self.configs[key], before)
|
||||||
|
self.assertTrue((self.case.service.root/'calibration-pending.json').exists())
|
||||||
|
self.assertEqual(len(self.starts), 1)
|
||||||
|
|
||||||
|
def test_unknown_completion_cannot_be_cleared_by_neutral_return(self):
|
||||||
|
self.case.devices[0].link.procedure_result = lambda: {'running': False, 'uncertain': True, 'result': {}}
|
||||||
|
self.case.service.execute(self.command())
|
||||||
|
release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'; release['parameters']['standstill_confirmed'] = True
|
||||||
|
self.assertEqual(self.case.service.execute(release)['state'], 'error')
|
||||||
|
self.assertTrue((self.case.service.root/'calibration-pending.json').exists())
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Synthetic complete-profile motion, shared timing and all-peer failure tests."""
|
||||||
|
import struct
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import test_motor_test
|
||||||
|
import test_reader
|
||||||
|
from test_motor_test import configuration
|
||||||
|
from runtime.configuration import decode
|
||||||
|
from runtime.drive_profile import LAYOUTS
|
||||||
|
from runtime.temporary_limits import FIELDS
|
||||||
|
|
||||||
|
|
||||||
|
class GroupTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.case = test_motor_test.PulseTests('test_fixed_wire_commands_no_broadcast_and_no_parameters')
|
||||||
|
self.case.setUp()
|
||||||
|
self.addCleanup(self.case.tearDown)
|
||||||
|
self.configure(2)
|
||||||
|
|
||||||
|
def configure(self, count):
|
||||||
|
c = self.case
|
||||||
|
if len(c.devices) != count:
|
||||||
|
c.attachments = [test_reader.Attachment(f'2-{i+1}', str(i+1), f'ttyACM{i}', '12') for i in range(count)]
|
||||||
|
c.service.scan(); c.configure_controllers()
|
||||||
|
self.configs, self.speeds, self.commands, self.tacho, self.sets = {}, {}, [], {}, []
|
||||||
|
self.motion = lambda device: True
|
||||||
|
for device in c.devices:
|
||||||
|
self.configs[device.id] = configuration('motor', {**decode(c.motor, 'motor'),
|
||||||
|
'l_current_min': -60, 'l_current_min_scale': 1, 'l_current_max_scale': 1})
|
||||||
|
original = device.link.query
|
||||||
|
def query(code, timeout=2, original=original, device=device):
|
||||||
|
if code == 14: return self.configs[device.id]
|
||||||
|
raw = original(code, timeout)
|
||||||
|
if code == 4 and device.id in self.speeds and self.motion(device):
|
||||||
|
self.tacho[device.id] = self.tacho.get(device.id, 0)+6
|
||||||
|
raw = raw[:23]+struct.pack('>i', round(self.speeds[device.id]))+raw[27:]
|
||||||
|
raw = raw[:45]+struct.pack('>ii', self.tacho[device.id], self.tacho[device.id])+raw[53:]
|
||||||
|
return raw
|
||||||
|
device.link.query = query
|
||||||
|
def limits(config, device=device):
|
||||||
|
self.sets.append(device.id)
|
||||||
|
self.configs[device.id] = configuration('motor', {**decode(self.configs[device.id], 'motor'),
|
||||||
|
**{k: config[k] for k in FIELDS}})
|
||||||
|
device.link.set_temporary_limits = limits
|
||||||
|
def speed(value, device=device):
|
||||||
|
self.speeds[device.id] = value
|
||||||
|
self.commands.append((device.id, value))
|
||||||
|
device.link.test_speed = speed
|
||||||
|
self.originals = dict(self.configs)
|
||||||
|
layout = '1x1' if count == 2 else '2x2'
|
||||||
|
c.service.drive.value = {'layout': layout, 'revision': 4,
|
||||||
|
'bindings': {slot: {'device_id': d.id, 'uuid': d.identity['uuid']}
|
||||||
|
for slot, d in zip(LAYOUTS[layout], c.devices)}}
|
||||||
|
|
||||||
|
def command(self):
|
||||||
|
command = self.case.command_for_pulse()
|
||||||
|
command['action_id'] = 'vesc.drive.run'
|
||||||
|
command['parameters'].update(erpm=2000, current_a=30, duration_s=3, profile_revision=4, standstill_confirmed=True,
|
||||||
|
device_ids=[d.id for d in self.case.devices])
|
||||||
|
command['deadline_at'] = (datetime.fromisoformat(command['requested_at'])+timedelta(seconds=120)).isoformat()
|
||||||
|
return command
|
||||||
|
|
||||||
|
def test_pair_and_four_motors_run_one_common_interval_and_restore(self):
|
||||||
|
for count in (2, 4):
|
||||||
|
with self.subTest(count=count):
|
||||||
|
self.configure(count)
|
||||||
|
command = self.command()
|
||||||
|
receipt = self.case.service.execute(command)
|
||||||
|
result = receipt['result']
|
||||||
|
self.assertEqual(result['outcome'], 'duration', result)
|
||||||
|
self.assertGreaterEqual(result['rotation_s'], 3)
|
||||||
|
self.assertLess(result['rotation_s'], 3.2)
|
||||||
|
self.assertEqual(set(self.speeds), {d.id for d in self.case.devices})
|
||||||
|
self.assertTrue(result['release_confirmed'])
|
||||||
|
self.assertTrue(result['limits_restored'])
|
||||||
|
self.assertEqual(self.configs, self.originals)
|
||||||
|
before = list(self.commands)
|
||||||
|
self.assertEqual(self.case.service.execute(command), receipt)
|
||||||
|
self.assertEqual(before, self.commands)
|
||||||
|
|
||||||
|
def test_count_starts_only_after_slower_motor_reaches_speed(self):
|
||||||
|
self.motion = lambda d: d is self.case.devices[0] or self.case.clock.now >= 8
|
||||||
|
result = self.case.service.execute(self.command())['result']
|
||||||
|
self.assertEqual(result['outcome'], 'duration')
|
||||||
|
first = next(s['at'] for s in result['samples'] if s['rotation_s'] > 0)
|
||||||
|
self.assertGreater(first, 7.5)
|
||||||
|
self.assertGreaterEqual(result['rotation_s'], 3)
|
||||||
|
|
||||||
|
def test_stationary_peer_never_counts_single_motor_as_joint_rotation(self):
|
||||||
|
self.motion = lambda d: d is self.case.devices[0]
|
||||||
|
result = self.case.service.execute(self.command())['result']
|
||||||
|
self.assertEqual(result['rotation_s'], 0)
|
||||||
|
self.assertIn('не вышел', result['outcome'])
|
||||||
|
self.assertEqual(set(result['release']), {d.id for d in self.case.devices})
|
||||||
|
self.assertEqual(self.configs, self.originals)
|
||||||
|
|
||||||
|
def test_rc_preemption_releases_every_motor_and_latches(self):
|
||||||
|
original = self.case.devices[0].link.test_speed
|
||||||
|
def speed(value):
|
||||||
|
original(value); self.case.changed = True
|
||||||
|
self.case.devices[0].link.test_speed = speed
|
||||||
|
result = self.case.service.execute(self.command())['result']
|
||||||
|
self.assertEqual(result['rotation_s'], 0)
|
||||||
|
self.assertTrue(self.case.service.motor.latched)
|
||||||
|
self.assertTrue(result['release_confirmed'])
|
||||||
|
self.assertFalse(result['limits_restored'])
|
||||||
|
self.assertTrue(list(self.case.service.root.glob('limits_*.json')))
|
||||||
|
self.case.changed = False
|
||||||
|
self.case.service.scan()
|
||||||
|
self.assertEqual(self.configs, self.originals)
|
||||||
|
|
||||||
|
def test_losing_one_motor_stops_joint_timer_and_releases_pair(self):
|
||||||
|
self.motion = lambda d: d is self.case.devices[0] or self.case.clock.now < 7
|
||||||
|
result = self.case.service.execute(self.command())['result']
|
||||||
|
self.assertLess(result['rotation_s'], 3)
|
||||||
|
self.assertIn('перестал удерживать', result['outcome'])
|
||||||
|
self.assertTrue(result['release_confirmed'])
|
||||||
|
self.assertEqual(self.configs, self.originals)
|
||||||
|
|
||||||
|
def test_failed_peer_lease_never_sends_speed_and_keeps_cause(self):
|
||||||
|
original = self.case.devices[1].link.test_command
|
||||||
|
def send(action):
|
||||||
|
if action == 'claim': raise TimeoutError('synthetic lease timeout')
|
||||||
|
original(action)
|
||||||
|
self.case.devices[1].link.test_command = send
|
||||||
|
result = self.case.service.execute(self.command())['result']
|
||||||
|
self.assertEqual(self.commands, [])
|
||||||
|
self.assertEqual(result['failure']['message'], 'synthetic lease timeout')
|
||||||
|
self.assertEqual(set(result['release']), {d.id for d in self.case.devices})
|
||||||
|
self.assertEqual(self.configs, self.originals)
|
||||||
|
|
||||||
|
def test_second_limit_ack_failure_restores_both_without_motion(self):
|
||||||
|
original = self.case.devices[1].link.set_temporary_limits
|
||||||
|
def apply(config):
|
||||||
|
original(config)
|
||||||
|
if len(self.sets) == 2: raise TimeoutError('lost second ACK')
|
||||||
|
self.case.devices[1].link.set_temporary_limits = apply
|
||||||
|
result = self.case.service.execute(self.command())['result']
|
||||||
|
self.assertEqual(self.commands, [])
|
||||||
|
self.assertTrue(result['limits_restored'])
|
||||||
|
self.assertEqual(self.configs, self.originals)
|
||||||
|
|
||||||
|
def test_stale_incomplete_duplicate_or_changed_profile_never_runs(self):
|
||||||
|
command = self.command(); command['parameters']['profile_revision'] -= 1
|
||||||
|
self.assertEqual(self.case.service.execute(command)['state'], 'error')
|
||||||
|
command = self.command(); command['parameters']['device_ids'] *= 2
|
||||||
|
with self.assertRaises(ValueError): self.case.service.execute(command)
|
||||||
|
self.case.service.drive.value['bindings'].pop('right.1')
|
||||||
|
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
|
||||||
|
self.assertEqual(self.commands, [])
|
||||||
|
self.assertEqual(self.sets, [])
|
||||||
|
|
||||||
|
def test_current_bound_is_checked_for_every_motor(self):
|
||||||
|
device = self.case.devices[1]
|
||||||
|
self.configs[device.id] = configuration('motor', {**decode(self.configs[device.id], 'motor'), 'l_current_max': 10})
|
||||||
|
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
|
||||||
|
self.assertEqual(self.commands, [])
|
||||||
|
self.assertEqual(self.sets, [])
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Sensorless idle drift must not become a general motion-check bypass."""
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import test_speed_and_hall
|
||||||
|
from test_motor_test import configuration
|
||||||
|
from runtime.configuration import decode
|
||||||
|
from runtime.motor_test import check_values, LimitExceeded
|
||||||
|
from runtime.protocol import values
|
||||||
|
|
||||||
|
|
||||||
|
class HallStandstillTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.rig = test_speed_and_hall.RunTests('test_native_hall_returns_measured_table_without_applying_it')
|
||||||
|
self.rig.setUp()
|
||||||
|
self.addCleanup(self.rig.doCleanups)
|
||||||
|
self.case = self.rig.case
|
||||||
|
|
||||||
|
def command(self):
|
||||||
|
command = self.case.command(action='vesc.hall.measure')
|
||||||
|
command['parameters'] = {'rig_clear': True, 'native_cycle_confirmed': True,
|
||||||
|
'standstill_confirmed': True, 'sessions': {d.id:d.session for d in self.case.devices}}
|
||||||
|
return command
|
||||||
|
|
||||||
|
def drift(self, device, mode=0, late_duty=False):
|
||||||
|
config = decode(self.rig.configs[device.id], 'motor')
|
||||||
|
self.rig.configs[device.id] = configuration('motor', {**config, 'foc_sensor_mode':mode})
|
||||||
|
query = device.link.query
|
||||||
|
reads = 0
|
||||||
|
def read(code, timeout=2):
|
||||||
|
nonlocal reads
|
||||||
|
raw = query(code, timeout)
|
||||||
|
if code == 4:
|
||||||
|
reads += 1
|
||||||
|
raw = raw[:23] + struct.pack('>i', -160) + raw[27:]
|
||||||
|
if late_duty and reads >= 6:
|
||||||
|
raw = raw[:21] + struct.pack('>h', 2) + raw[23:]
|
||||||
|
return raw
|
||||||
|
device.link.query = read
|
||||||
|
|
||||||
|
def test_sensorless_peer_can_drift_but_raw_evidence_is_retained(self):
|
||||||
|
self.drift(self.case.devices[1])
|
||||||
|
result = self.case.service.execute(self.command())['result']
|
||||||
|
self.assertTrue(result['completed'])
|
||||||
|
self.assertTrue(result['configuration_restored'])
|
||||||
|
self.assertTrue(result['preflight']['standstill_confirmed'])
|
||||||
|
samples = result['preflight']['samples']
|
||||||
|
self.assertEqual(len(samples), 20)
|
||||||
|
peer = [s for s in samples if s['device_id'] == self.case.devices[1].id]
|
||||||
|
self.assertTrue(all(s['values']['erpm'] == -160 for s in peer))
|
||||||
|
self.assertGreaterEqual(peer[-1]['at'] - peer[0]['at'], 0.89)
|
||||||
|
|
||||||
|
def test_sensored_speed_still_blocks_before_any_write(self):
|
||||||
|
self.drift(self.case.devices[1], mode=2)
|
||||||
|
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
|
||||||
|
self.assertIsNone(self.rig.hall_started)
|
||||||
|
self.assertEqual(self.case.sent, [])
|
||||||
|
|
||||||
|
def test_modulation_above_one_wire_quantum_blocks_before_any_write(self):
|
||||||
|
self.drift(self.case.devices[1], late_duty=True)
|
||||||
|
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
|
||||||
|
self.assertIsNone(self.rig.hall_started)
|
||||||
|
self.assertEqual(self.case.sent, [])
|
||||||
|
|
||||||
|
def test_missing_false_and_nonboolean_observation_rejected(self):
|
||||||
|
for confirmation in (None, False, 1, 'true'):
|
||||||
|
command = self.command()
|
||||||
|
if confirmation is None: del command['parameters']['standstill_confirmed']
|
||||||
|
else: command['parameters']['standstill_confirmed'] = confirmation
|
||||||
|
with self.assertRaises(ValueError): self.case.service.execute(command)
|
||||||
|
self.assertEqual(self.case.sent, [])
|
||||||
|
|
||||||
|
def test_sensorless_drift_does_not_bypass_ordinary_motor_test(self):
|
||||||
|
self.drift(self.case.devices[1])
|
||||||
|
self.assertEqual(self.case.service.execute(self.case.command_for_pulse())['state'], 'error')
|
||||||
|
self.assertEqual(self.case.sent, [])
|
||||||
|
|
||||||
|
def test_rc_still_blocks_hall_before_any_write(self):
|
||||||
|
self.drift(self.case.devices[1])
|
||||||
|
self.case.changed = True
|
||||||
|
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
|
||||||
|
self.assertTrue(self.case.service.motor.latched)
|
||||||
|
self.assertEqual(self.case.sent, [])
|
||||||
|
|
||||||
|
def test_electrical_and_finite_guards_cannot_be_replaced_by_observation(self):
|
||||||
|
device = self.case.devices[0]
|
||||||
|
sample = values(device.link.query(4))
|
||||||
|
motor = decode(self.rig.configs[device.id], 'motor')
|
||||||
|
for field, value in (('erpm', math.nan), ('motor_current_a', 1.01),
|
||||||
|
('input_current_a', -1.01), ('duty', .002),
|
||||||
|
('fault_code', 1), ('mos_temperature_c', 66),
|
||||||
|
('input_voltage_v', 61)):
|
||||||
|
with self.subTest(field=field), self.assertRaises(LimitExceeded):
|
||||||
|
check_values({**sample, 'erpm':-160, field:value}, motor=motor,
|
||||||
|
standstill_confirmed=True)
|
||||||
|
|
||||||
|
def test_one_quantized_idle_modulation_step_requires_observed_standstill(self):
|
||||||
|
device = self.case.devices[0]
|
||||||
|
sample = values(device.link.query(4))
|
||||||
|
motor = {**decode(self.rig.configs[device.id], 'motor'), 'foc_sensor_mode':0}
|
||||||
|
for duty in (-.001, 0, .001):
|
||||||
|
idle = {**sample, 'erpm':-160, 'duty':duty}
|
||||||
|
check_values(idle, motor=motor, standstill_confirmed=True)
|
||||||
|
with self.assertRaises(LimitExceeded):
|
||||||
|
check_values(idle, motor=motor)
|
||||||
|
with self.assertRaises(LimitExceeded):
|
||||||
|
check_values({**sample, 'duty':-.002}, motor=motor, standstill_confirmed=True)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Native configuration reads never acquire motor control or change settings."""
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
import test_reader
|
||||||
|
from runtime.limits_view import FIELDS, read_limits
|
||||||
|
|
||||||
|
|
||||||
|
class LimitsTests(unittest.TestCase):
|
||||||
|
def test_native_export_only_and_missing_or_nonfinite_values_rejected(self):
|
||||||
|
class Link:
|
||||||
|
def configuration(self):
|
||||||
|
return {"motor": {"parameters": [{"name": key, "value": 1.0} for key in FIELDS]}}
|
||||||
|
self.assertEqual(set(read_limits(Link())), FIELDS)
|
||||||
|
for bad in (float('nan'), True, '300'):
|
||||||
|
with patch.object(Link, 'configuration', return_value={"motor":{"parameters":[{"name":key,"value":bad} for key in FIELDS]}}):
|
||||||
|
with self.assertRaises(ValueError): read_limits(Link())
|
||||||
|
|
||||||
|
def test_service_read_is_bound_to_uuid_and_cannot_write(self):
|
||||||
|
fixture = test_reader.ServiceTests()
|
||||||
|
fixture.setUp()
|
||||||
|
try:
|
||||||
|
service = fixture.service
|
||||||
|
with patch.object(test_reader.FakeLink, 'configuration', create=True, return_value={"motor":{"parameters":[{"name":key,"value":1.0} for key in FIELDS]}}) as native:
|
||||||
|
response = service.execute(fixture.command(action='vesc.limits.read'))
|
||||||
|
self.assertEqual(response['state'], 'complete')
|
||||||
|
self.assertEqual(set(response['result']['parameters']), FIELDS)
|
||||||
|
native.assert_called_once()
|
||||||
|
self.assertTrue(all(set(link.commands) == {0} for link in test_reader.FakeLink.instances))
|
||||||
|
self.assertEqual(service.drive.value['revision'], 0)
|
||||||
|
finally:
|
||||||
|
fixture.tearDown()
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""No-power read measurements and failure attribution; no USB hardware."""
|
||||||
|
from unittest.mock import patch
|
||||||
|
import unittest
|
||||||
|
import struct
|
||||||
|
import test_motor_test
|
||||||
|
from runtime.link_check import measure, summary
|
||||||
|
|
||||||
|
|
||||||
|
class LinkCheckTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.case=test_motor_test.PulseTests('test_fixed_wire_commands_no_broadcast_and_no_parameters')
|
||||||
|
self.case.setUp(); self.addCleanup(self.case.tearDown)
|
||||||
|
self.reads=[]
|
||||||
|
for device in self.case.devices:
|
||||||
|
original=device.link.query
|
||||||
|
def query(code,timeout=2,original=original,device=device):
|
||||||
|
self.reads.append((device.id,code,timeout))
|
||||||
|
return original(code,timeout)
|
||||||
|
device.link.query=query
|
||||||
|
|
||||||
|
def command(self):
|
||||||
|
c=self.case.command_for_pulse();c['action_id']='vesc.link.check'
|
||||||
|
c['parameters']={'sessions':c['parameters']['sessions']}
|
||||||
|
return c
|
||||||
|
|
||||||
|
def run_check(self):
|
||||||
|
return measure(self.case.service,self.command(),self.case.devices,
|
||||||
|
sleep=self.case.clock.sleep,monotonic=lambda:self.case.clock.now)
|
||||||
|
|
||||||
|
def test_reads_pair_at_group_cadence_without_any_motor_commands(self):
|
||||||
|
result=self.run_check()
|
||||||
|
self.assertEqual(result['outcome'],'complete')
|
||||||
|
self.assertFalse(result['motor_commands_sent'])
|
||||||
|
self.assertEqual(len(self.reads),402)
|
||||||
|
self.assertEqual({r[1] for r in self.reads},{17,31,4})
|
||||||
|
self.assertTrue(all(r[2]==.5 for r in self.reads if r[1]!=17))
|
||||||
|
self.assertEqual(self.case.sent,[])
|
||||||
|
self.assertTrue(all(v['summary']['replies']==200 for v in result['devices'].values()))
|
||||||
|
self.assertGreaterEqual(result['duration_s'],9.9)
|
||||||
|
|
||||||
|
def test_late_replies_are_measured_without_relaxing_motor_budget(self):
|
||||||
|
result=summary([{'elapsed_ms':x} for x in [20,30,55,80,120]])
|
||||||
|
self.assertEqual(result['over_60_ms'],2)
|
||||||
|
self.assertEqual(result['p50_ms'],55)
|
||||||
|
self.assertEqual(result['max_ms'],120)
|
||||||
|
|
||||||
|
def test_no_response_keeps_peer_command_and_native_cause(self):
|
||||||
|
device=self.case.devices[1]
|
||||||
|
original=device.link.query
|
||||||
|
def query(code,timeout=2):
|
||||||
|
if code==17:return original(code,timeout)
|
||||||
|
error=OSError('native read timeout');error.native_rpc={'command':code,'process_alive':True,'attachment_present':True}
|
||||||
|
raise error
|
||||||
|
device.link.query=query
|
||||||
|
result=self.run_check()
|
||||||
|
self.assertEqual(result['outcome'],'read_failed')
|
||||||
|
self.assertEqual(result['failure'][0]['device_id'],device.id)
|
||||||
|
self.assertEqual(result['failure'][0]['command'],31)
|
||||||
|
self.assertTrue(result['failure'][0]['native_rpc']['attachment_present'])
|
||||||
|
self.assertEqual(self.case.sent,[])
|
||||||
|
|
||||||
|
def test_active_receiver_stops_measurement_without_claiming_control(self):
|
||||||
|
self.case.changed=True
|
||||||
|
result=self.run_check()
|
||||||
|
self.assertEqual(result['outcome'],'not_idle')
|
||||||
|
self.assertEqual(self.case.sent,[])
|
||||||
|
self.assertEqual({r[1] for r in self.reads},{17,31})
|
||||||
|
|
||||||
|
def test_receiver_offset_inside_configured_deadband_is_idle(self):
|
||||||
|
for device in self.case.devices:
|
||||||
|
original=device.link.query
|
||||||
|
device.link.query=lambda code,timeout=2,original=original: bytes([31])+struct.pack('>ii',-66000,1466000) if code==31 else original(code,timeout)
|
||||||
|
result=self.run_check()
|
||||||
|
self.assertEqual(result['outcome'],'complete')
|
||||||
|
self.assertTrue(all(abs(d['neutral_band']-.15)<1e-6 for d in result['devices'].values()))
|
||||||
|
self.assertEqual(self.case.sent,[])
|
||||||
|
|
||||||
|
def test_config_failure_is_reported_before_ppm_or_motor_commands(self):
|
||||||
|
self.case.devices[0].link.query=lambda *args,**kwargs: b'bad config'
|
||||||
|
result=self.run_check()
|
||||||
|
self.assertEqual(result['outcome'],'read_failed')
|
||||||
|
self.assertEqual(result['failure'][0]['command'],17)
|
||||||
|
self.assertEqual(self.case.sent,[])
|
||||||
|
self.assertFalse(any(r[1] in (31,4) for r in self.reads))
|
||||||
|
|
||||||
|
def test_service_keeps_idempotent_receipt_and_excludes_other_operations(self):
|
||||||
|
command=self.command()
|
||||||
|
original=measure
|
||||||
|
def fast(*args):return original(*args,sleep=self.case.clock.sleep,monotonic=lambda:self.case.clock.now)
|
||||||
|
with patch('runtime.link_check.measure',side_effect=fast):
|
||||||
|
result=self.case.service.execute(command)
|
||||||
|
self.assertEqual(result['state'],'complete')
|
||||||
|
self.assertEqual(self.case.service.execute(command),result)
|
||||||
|
self.assertEqual(len(self.reads),402)
|
||||||
|
self.case.service.operation_lock.acquire()
|
||||||
|
try:
|
||||||
|
with self.assertRaises(ValueError):self.case.service.execute(self.command())
|
||||||
|
finally:self.case.service.operation_lock.release()
|
||||||
|
|
||||||
|
def test_changed_sessions_rejected_before_reads_and_stop_is_respected(self):
|
||||||
|
command=self.command();command['parameters']['sessions']={}
|
||||||
|
with self.assertRaises(ValueError): measure(self.case.service,command,self.case.devices)
|
||||||
|
self.assertEqual(self.reads,[])
|
||||||
|
command=self.command();self.case.service.motor.cancel()
|
||||||
|
result=measure(self.case.service,command,self.case.devices)
|
||||||
|
self.assertEqual(result['outcome'],'stopped')
|
||||||
|
self.assertEqual(self.reads,[])
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""Synthetic fault-injection tests. No real ports, private config or motors."""
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from runtime.configuration import decode, crc32c
|
||||||
|
from runtime.motor_test import Rejected, LimitExceeded, check_configuration, check_values
|
||||||
|
from runtime.protocol import Decoder, test_packet, ppm, current_packet
|
||||||
|
import test_reader
|
||||||
|
from runtime.protocol import firmware
|
||||||
|
from test_reader import telemetry
|
||||||
|
|
||||||
|
|
||||||
|
def configuration(kind, overrides):
|
||||||
|
code, name = {"motor": (14, "mcconf"), "application": (17, "appconf")}[kind]
|
||||||
|
xml = ET.parse(Path(__file__).parents[1] / "runtime/schemas/5.02" / ("parameters_" + name + ".xml")).getroot()
|
||||||
|
params = {p.tag: p for p in xml.find("Params")}; order = [p.text for p in xml.find("SerOrder")]
|
||||||
|
sig = ''.join(n + params[n].findtext('type', '0') + params[n].findtext('vTx', '0') + ''.join(x.text or '' for x in params[n].findall('enumNames')) for n in order)
|
||||||
|
out = bytes([code]) + crc32c(sig.encode()).to_bytes(4, 'big')
|
||||||
|
for n in order:
|
||||||
|
p=params[n];typ=int(p.findtext('type'));tx=int(p.findtext('vTx','0'));v=overrides.get(n,0)
|
||||||
|
if typ in (4,5):fmt='b'
|
||||||
|
elif typ==6:fmt='B'
|
||||||
|
elif typ==2:fmt={1:'B',2:'b',3:'H',4:'h',5:'I',6:'i'}[tx]
|
||||||
|
else:
|
||||||
|
fmt={7:'h',8:'i',9:'I'}[tx]
|
||||||
|
if tx==9:
|
||||||
|
f,e=math.frexp(abs(v));v=0 if v==0 else ((e+126)<<23)|int((f-0.5)*16777216)|(0x80000000 if v<0 else 0)
|
||||||
|
else:v=round(v*float(p.findtext('vTxDoubleScale','1')))
|
||||||
|
out+=struct.pack('>'+fmt,v)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
class Clock:
|
||||||
|
def __init__(self):self.now=0
|
||||||
|
def monotonic(self):return self.now
|
||||||
|
def sleep(self,value):self.now+=value
|
||||||
|
|
||||||
|
|
||||||
|
class PulseTests(test_reader.ServiceTests):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.configure_controllers()
|
||||||
|
|
||||||
|
def configure_controllers(self):
|
||||||
|
self.clock=Clock();self.service.motor.sleep=self.clock.sleep;self.service.motor.monotonic=self.clock.monotonic
|
||||||
|
self.devices=list(self.service.devices.values());self.changed=False
|
||||||
|
self.sent=[]
|
||||||
|
self.currents=[]
|
||||||
|
self.motor=configuration('motor',{'motor_type':2,'l_current_max':60,'l_in_current_max':55,'l_min_erpm':-60000,'l_max_erpm':60000,'l_max_duty':0.95})
|
||||||
|
for i,device in enumerate(self.devices):
|
||||||
|
raw=b'\0\5\2' + b'75_300_R2\0' + bytes([i+1])*12 + bytes(4)
|
||||||
|
device.identity=firmware(raw)
|
||||||
|
app=configuration('application',{'controller_id':i+10,'app_to_use':1,'timeout_msec':1000,'timeout_brake_current':0,'app_ppm_conf.ctrl_type':4,'app_ppm_conf.hyst':0.15})
|
||||||
|
def query(code,timeout=2,raw=raw,app=app):
|
||||||
|
if code==0:return raw
|
||||||
|
if code==14:return self.motor
|
||||||
|
if code==17:return app
|
||||||
|
if code==4:return telemetry()[:1]+struct.pack('>hhiiiihihiiiiiiB',250,-729,0,0,0,0,0,0,500,0,0,0,0,0,0,0)
|
||||||
|
if code==31:return bytes([31])+struct.pack('>ii',600000 if self.changed else 0,1500000)
|
||||||
|
if code==62:return bytes([62])+bytes(range(10,10+len(self.devices)))
|
||||||
|
raise AssertionError(code)
|
||||||
|
device.link.query=query
|
||||||
|
device.link.test_command=lambda action,i=i:self.sent.append((i,action))
|
||||||
|
def send_current(amps,device=device,i=i):
|
||||||
|
self.currents.append((i,amps))
|
||||||
|
device.link.test_command('current')
|
||||||
|
device.link.test_current=send_current
|
||||||
|
|
||||||
|
def command_for_pulse(self):
|
||||||
|
c=self.command(action='vesc.motor.pulse')
|
||||||
|
c['parameters']={'rig_clear':True,'sessions':{d.id:d.session for d in self.devices},'duration_s':1.5,'current_a':2}
|
||||||
|
return c
|
||||||
|
|
||||||
|
def test_fixed_wire_commands_no_broadcast_and_no_parameters(self):
|
||||||
|
self.assertEqual(Decoder().feed(test_packet('claim')),[bytes([63,0])+struct.pack('>i',250)])
|
||||||
|
self.assertEqual(Decoder().feed(test_packet('current')),[bytes([6])+struct.pack('>i',2000)])
|
||||||
|
self.assertEqual(Decoder().feed(current_packet(5)),[bytes([6])+struct.pack('>i',5000)])
|
||||||
|
with self.assertRaises(KeyError):test_packet('arbitrary')
|
||||||
|
with self.assertRaises(ValueError):ppm(bytes([31])+bytes(7))
|
||||||
|
|
||||||
|
def test_only_selected_motor_receives_current_and_receipt_never_replays(self):
|
||||||
|
c=self.command_for_pulse();r=self.service.execute(c)
|
||||||
|
self.assertEqual(r['state'],'complete',r)
|
||||||
|
self.assertEqual(r['result']['outcome'],'duration')
|
||||||
|
self.assertTrue(r['result']['release_confirmed'])
|
||||||
|
self.assertIn((0,'current'),self.sent);self.assertNotIn((1,'current'),self.sent)
|
||||||
|
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||||
|
self.assertLessEqual(len([x for x in self.sent if x==(0,'current')]),31)
|
||||||
|
before=list(self.sent);self.assertEqual(self.service.execute(c),r);self.assertEqual(self.sent,before)
|
||||||
|
self.assertEqual(len(list(self.service.root.glob('backup_*.json'))),2)
|
||||||
|
|
||||||
|
def test_active_rc_blocks_before_any_command_and_latches(self):
|
||||||
|
self.changed=True;r=self.service.execute(self.command_for_pulse())
|
||||||
|
self.assertEqual(r['state'],'error');self.assertEqual(self.sent,[]);self.assertTrue(self.service.motor.latched)
|
||||||
|
self.changed=False;self.assertEqual(self.service.execute(self.command_for_pulse())['state'],'error')
|
||||||
|
|
||||||
|
def test_rc_preemption_releases_both_and_blocks_next_click(self):
|
||||||
|
def send(action):
|
||||||
|
self.sent.append((0,action))
|
||||||
|
if action=='current':self.changed=True
|
||||||
|
self.devices[0].link.test_command=send
|
||||||
|
r=self.service.execute(self.command_for_pulse())
|
||||||
|
self.assertEqual(r['state'],'complete');self.assertTrue(self.service.motor.latched)
|
||||||
|
self.assertEqual(len([x for x in self.sent if x==(0,'current')]),1)
|
||||||
|
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||||
|
|
||||||
|
def test_missing_second_lease_and_disconnect_release_first(self):
|
||||||
|
def send(action):
|
||||||
|
if action=='claim':raise OSError('Disconnected')
|
||||||
|
self.sent.append((1,action))
|
||||||
|
self.devices[1].link.test_command=send
|
||||||
|
r=self.service.execute(self.command_for_pulse())
|
||||||
|
self.assertNotIn((0,'current'),self.sent);self.assertIn((0,'release'),self.sent)
|
||||||
|
self.assertNotEqual(r['result']['outcome'],'duration')
|
||||||
|
|
||||||
|
def test_bad_schema_stale_peer_and_no_rig_confirmation_never_write(self):
|
||||||
|
self.assertRaises(ValueError,decode,self.motor[:-1],'motor')
|
||||||
|
c=self.command_for_pulse();c['parameters']['sessions'][self.devices[1].id]='stale'
|
||||||
|
self.assertEqual(self.service.execute(c)['state'],'error')
|
||||||
|
c=self.command_for_pulse();c['parameters']['rig_clear']=False
|
||||||
|
with self.assertRaises(ValueError):self.service.execute(c)
|
||||||
|
self.assertEqual(self.sent,[])
|
||||||
|
|
||||||
|
def test_stop_received_before_queued_pulse_cannot_be_cleared_by_start(self):
|
||||||
|
pulse = self.command_for_pulse()
|
||||||
|
result = self.service.execute(self.command(action='vesc.motor.stop'))
|
||||||
|
self.assertTrue(result['result']['stop_requested'])
|
||||||
|
self.assertEqual(self.service.execute(pulse)['state'],'error')
|
||||||
|
self.assertEqual(self.sent,[])
|
||||||
|
self.assertEqual(self.service.execute(self.command_for_pulse())['state'],'complete')
|
||||||
|
|
||||||
|
def test_single_six_and_ten_controllers_target_only_with_durable_backups(self):
|
||||||
|
for count in (1, 6, 10):
|
||||||
|
with self.subTest(count=count):
|
||||||
|
self.attachments = [test_reader.Attachment(f"2-{i+1}", str(i+1), f"ttyACM{i}", "12") for i in range(count)]
|
||||||
|
self.service.scan(); self.configure_controllers()
|
||||||
|
command=self.command_for_pulse()
|
||||||
|
target=self.devices[-1]
|
||||||
|
command['session']={'device_id':target.id,'session_id':target.session}
|
||||||
|
command['parameters']['duration_s']=10
|
||||||
|
result=self.service.execute(command)
|
||||||
|
self.assertEqual(result['state'],'complete',result)
|
||||||
|
self.assertEqual(result['result']['outcome'],'duration')
|
||||||
|
self.assertEqual(result['result']['duration_limit_s'],10)
|
||||||
|
self.assertEqual({i for i,action in self.sent if action=='current'},{count-1})
|
||||||
|
self.assertTrue(result['result']['release_confirmed'])
|
||||||
|
self.assertEqual(len(result['result']['backups']),count)
|
||||||
|
self.assertEqual(len(result['result']['release']),count)
|
||||||
|
self.assertLessEqual(self.clock.now,11.36)
|
||||||
|
|
||||||
|
def test_new_controller_during_preflight_or_pulse_stops_current(self):
|
||||||
|
extra=test_reader.Attachment('3-1','3','ttyACM3','12')
|
||||||
|
self.attachments.append(extra)
|
||||||
|
self.assertEqual(self.service.execute(self.command_for_pulse())['state'],'error')
|
||||||
|
self.assertEqual(self.sent,[])
|
||||||
|
self.attachments.remove(extra)
|
||||||
|
def send(action):
|
||||||
|
self.sent.append((0,action))
|
||||||
|
if action=='current':self.attachments.append(extra)
|
||||||
|
self.devices[0].link.test_command=send
|
||||||
|
result=self.service.execute(self.command_for_pulse())
|
||||||
|
self.assertEqual(result['state'],'complete')
|
||||||
|
self.assertNotEqual(result['result']['outcome'],'duration')
|
||||||
|
self.assertEqual(self.sent.count((0,'current')),1)
|
||||||
|
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||||
|
|
||||||
|
def test_unmanaged_can_peer_on_other_controller_blocks_all_current(self):
|
||||||
|
original=self.devices[1].link.query
|
||||||
|
self.devices[1].link.query=lambda code,timeout=2:bytes([62,99]) if code==62 else original(code,timeout)
|
||||||
|
result=self.service.execute(self.command_for_pulse())
|
||||||
|
self.assertEqual(result['state'],'error')
|
||||||
|
self.assertEqual(self.sent,[])
|
||||||
|
|
||||||
|
def test_slow_multi_controller_cycle_never_sends_torque(self):
|
||||||
|
original=self.devices[1].link.query
|
||||||
|
def delayed(code,timeout=2):
|
||||||
|
if code==31:self.clock.sleep(0.13)
|
||||||
|
return original(code,timeout)
|
||||||
|
self.devices[1].link.query=delayed
|
||||||
|
result=self.service.execute(self.command_for_pulse())
|
||||||
|
self.assertNotEqual(result['result']['outcome'],'duration')
|
||||||
|
self.assertEqual(self.sent,[])
|
||||||
|
|
||||||
|
def test_duration_is_allowlisted_and_stop_interrupts_ten_second_pulse(self):
|
||||||
|
for duration in (True,0,30.01,100,'10'):
|
||||||
|
command=self.command_for_pulse();command['parameters']['duration_s']=duration
|
||||||
|
with self.assertRaises(ValueError):self.service.execute(command)
|
||||||
|
def send(action):
|
||||||
|
self.sent.append((0,action))
|
||||||
|
if action=='current':self.service.motor.cancel()
|
||||||
|
self.devices[0].link.test_command=send
|
||||||
|
command=self.command_for_pulse();command['parameters']['duration_s']=10
|
||||||
|
result=self.service.execute(command)
|
||||||
|
self.assertEqual(result['result']['outcome'],'stopped')
|
||||||
|
self.assertEqual(self.sent.count((0,'current')),1)
|
||||||
|
self.assertLess(self.clock.now,2)
|
||||||
|
|
||||||
|
def test_can_timeout_reports_preflight_failure_without_current(self):
|
||||||
|
original=self.devices[0].link.query
|
||||||
|
def query(code,timeout=2):
|
||||||
|
if code==62:
|
||||||
|
self.assertEqual(timeout,8)
|
||||||
|
raise TimeoutError('synthetic CAN timeout')
|
||||||
|
return original(code,timeout)
|
||||||
|
self.devices[0].link.query=query
|
||||||
|
result=self.service.execute(self.command_for_pulse())
|
||||||
|
self.assertEqual(result['state'],'error')
|
||||||
|
self.assertIn('CAN',result['error'])
|
||||||
|
self.assertEqual(self.sent,[])
|
||||||
|
|
||||||
|
def test_can_inspection_does_not_claim_or_drive_motors(self):
|
||||||
|
result=self.service.execute(self.command(action='vesc.can.read'))
|
||||||
|
self.assertEqual(result['state'],'complete')
|
||||||
|
self.assertEqual(result['result']['can_ids'],[10,11])
|
||||||
|
self.assertEqual(self.sent,[])
|
||||||
|
|
||||||
|
def test_old_coasting_thresholds_no_longer_interrupt_current(self):
|
||||||
|
original=self.devices[0].link.query
|
||||||
|
speeds=iter([0,210,880,1200,3000])
|
||||||
|
def query(code,timeout=2):
|
||||||
|
raw=original(code,timeout)
|
||||||
|
if code==4:
|
||||||
|
speed=next(speeds,3000)
|
||||||
|
raw=raw[:21]+struct.pack('>hi',120 if speed else 0,speed)+raw[27:]
|
||||||
|
return raw
|
||||||
|
self.devices[0].link.query=query
|
||||||
|
command=self.command_for_pulse();command['parameters'].update(current_a=5,duration_s=30)
|
||||||
|
result=self.service.execute(command)['result']
|
||||||
|
self.assertEqual(result['outcome'],'duration')
|
||||||
|
self.assertEqual(result['duration_limit_s'],30)
|
||||||
|
self.assertGreater(len(result['samples']),590)
|
||||||
|
self.assertTrue(all(s['commanded_current_a'] > 0 for s in result['samples']))
|
||||||
|
self.assertEqual(self.sent.count((0,'release')),1)
|
||||||
|
self.assertTrue(result['release_confirmed'])
|
||||||
|
|
||||||
|
def test_current_ramps_from_half_amp_to_entered_ceiling(self):
|
||||||
|
command=self.command_for_pulse();command['parameters'].update(current_a=5,duration_s=10)
|
||||||
|
result=self.service.execute(command)['result']
|
||||||
|
amps=[value for _,value in self.currents]
|
||||||
|
self.assertEqual(amps[0],0.5)
|
||||||
|
self.assertEqual(max(amps),5)
|
||||||
|
self.assertTrue(all(0<=b-a<=0.10001 for a,b in zip(amps,amps[1:])))
|
||||||
|
self.assertEqual(result['outcome'],'duration')
|
||||||
|
self.assertEqual(result['current_ramp_a_per_s'],2)
|
||||||
|
|
||||||
|
def test_thirty_amp_thirty_second_moving_target_and_bounded_receipt(self):
|
||||||
|
original=self.devices[0].link.query
|
||||||
|
tacho=0
|
||||||
|
def query(code,timeout=2):
|
||||||
|
nonlocal tacho
|
||||||
|
raw=original(code,timeout)
|
||||||
|
if code==4 and self.currents:
|
||||||
|
tacho+=4
|
||||||
|
raw=raw[:23]+struct.pack('>i',1200)+raw[27:]
|
||||||
|
raw=raw[:45]+struct.pack('>ii',tacho,tacho)+raw[53:]
|
||||||
|
return raw
|
||||||
|
self.devices[0].link.query=query
|
||||||
|
command=self.command_for_pulse();command['parameters'].update(current_a=30,duration_s=30)
|
||||||
|
result=self.service.execute(command)['result']
|
||||||
|
self.assertEqual(result['outcome'],'duration')
|
||||||
|
self.assertEqual(max(amps for _,amps in self.currents),30)
|
||||||
|
self.assertEqual({i for i,_ in self.currents},{0})
|
||||||
|
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||||
|
self.assertTrue(result['release_confirmed'])
|
||||||
|
import json
|
||||||
|
self.assertLess(len(json.dumps(result).encode()),1024*1024)
|
||||||
|
|
||||||
|
def test_high_current_without_motion_stops_and_does_not_auto_restart(self):
|
||||||
|
command=self.command_for_pulse();command['parameters'].update(current_a=30,duration_s=30)
|
||||||
|
result=self.service.execute(command)['result']
|
||||||
|
self.assertIn('движение не подтверждается',result['outcome'])
|
||||||
|
self.assertLess(result['samples'][-1]['at'],5)
|
||||||
|
self.assertLess(max(amps for _,amps in self.currents),10)
|
||||||
|
self.assertIsNone(result['samples'][-1]['commanded_current_a'])
|
||||||
|
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||||
|
self.assertTrue(result['release_confirmed'])
|
||||||
|
|
||||||
|
def test_reported_speed_alone_cannot_reset_stall_without_tachometer_motion(self):
|
||||||
|
original=self.devices[0].link.query
|
||||||
|
def query(code,timeout=2):
|
||||||
|
raw=original(code,timeout)
|
||||||
|
if code==4 and self.currents:raw=raw[:23]+struct.pack('>i',1200)+raw[27:]
|
||||||
|
return raw
|
||||||
|
self.devices[0].link.query=query
|
||||||
|
command=self.command_for_pulse();command['parameters'].update(current_a=30,duration_s=30)
|
||||||
|
result=self.service.execute(command)['result']
|
||||||
|
self.assertIn('движение не подтверждается',result['outcome'])
|
||||||
|
self.assertTrue(result['release_confirmed'])
|
||||||
|
|
||||||
|
def test_target_configuration_limits_remain_binding(self):
|
||||||
|
self.motor=configuration('motor',{'motor_type':2,'l_current_max':10,'l_in_current_max':10,'l_min_erpm':-2000,'l_max_erpm':2000,'l_max_duty':0.1})
|
||||||
|
command=self.command_for_pulse();command['parameters'].update(current_a=30,duration_s=30)
|
||||||
|
result=self.service.execute(command)
|
||||||
|
self.assertEqual(result['state'],'error')
|
||||||
|
self.assertIn('настроенный предел',result['error'])
|
||||||
|
self.assertEqual(self.sent,[])
|
||||||
|
limits=decode(self.motor,'motor')
|
||||||
|
baseline={'fault_code':0,'input_voltage_v':50,'mos_temperature_c':30,'motor_current_a':0,'erpm':0,'duty':0}
|
||||||
|
for field,value in [('erpm',2001),('duty',0.101),('motor_current_a',10.1)]:
|
||||||
|
with self.assertRaises(LimitExceeded):check_values({**baseline,field:value},moving=True,current_a=10,motor=limits)
|
||||||
|
|
||||||
|
def test_arbitrary_current_cannot_enter_motor_loop(self):
|
||||||
|
for amps in (True,0,0.4,30.001,31,100,'5',float('nan'),float('inf')):
|
||||||
|
command=self.command_for_pulse();command['parameters']['current_a']=amps
|
||||||
|
with self.assertRaises(ValueError):self.service.execute(command)
|
||||||
|
self.assertEqual(self.sent,[])
|
||||||
|
for amps in (0.5,1.7,3,5,10,30):
|
||||||
|
self.assertEqual(Decoder().feed(current_packet(amps)),[bytes([6])+struct.pack('>i',round(amps*1000))])
|
||||||
|
for amps in (False,-1,0,30.001,float('nan'),float('inf')):
|
||||||
|
with self.assertRaises(ValueError):current_packet(amps)
|
||||||
|
|
||||||
|
def test_limit_stop_keeps_triggering_sample_and_releases_every_controller(self):
|
||||||
|
original=self.devices[0].link.query
|
||||||
|
def query(code,timeout=2):
|
||||||
|
raw=original(code,timeout)
|
||||||
|
if code==4 and self.sent.count((0,'current'))==1:
|
||||||
|
raw=raw[:21]+struct.pack('>h',260)+raw[23:]
|
||||||
|
return raw
|
||||||
|
self.devices[0].link.query=query
|
||||||
|
result=self.service.execute(self.command_for_pulse())['result']
|
||||||
|
self.assertIn('заполнение PWM 26 %',result['outcome'])
|
||||||
|
self.assertEqual(result['limit_violation'],{'field':'duty','value':0.26,'minimum':-0.25,'maximum':0.25})
|
||||||
|
self.assertEqual(result['samples'][-1]['devices'][self.devices[0].id]['duty'],0.26)
|
||||||
|
self.assertIsNone(result['samples'][-1]['commanded_current_a'])
|
||||||
|
self.assertEqual(self.sent.count((0,'current')),1)
|
||||||
|
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||||
|
self.assertTrue(result['release_confirmed'])
|
||||||
|
|
||||||
|
def test_each_telemetry_limit_reports_its_own_measured_value(self):
|
||||||
|
baseline={'fault_code':0,'input_voltage_v':50,'mos_temperature_c':30,'motor_current_a':0,'erpm':0,'duty':0}
|
||||||
|
for field,actual in [('fault_code',1),('input_voltage_v',61),('mos_temperature_c',66),('motor_current_a',-8.1),('erpm',6001),('duty',-0.251)]:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
with self.assertRaises(LimitExceeded) as raised:check_values({**baseline,field:actual},moving=True,current_a=5)
|
||||||
|
self.assertEqual(raised.exception.violation['field'],field)
|
||||||
|
self.assertEqual(raised.exception.violation['value'],actual)
|
||||||
|
|
||||||
|
# Parent ServiceTests use the generic firmware/read-only fake.
|
||||||
|
test_two_attachments_promote_independently = None
|
||||||
|
test_uuid_collision_never_aliases_a_controller = None
|
||||||
|
test_reconnect_invalidates_old_session_but_retains_uuid = None
|
||||||
|
test_receipt_replay_never_repeats_serial_query = None
|
||||||
|
test_backup_is_raw_hashed_and_bound_to_identity = None
|
||||||
|
test_arbitrary_writes_parameters_and_expired_requests_rejected = None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__=='__main__':unittest.main()
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Faults at the actual subprocess/RPC boundary, with no serial hardware."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
from runtime.native_link import NativeLink
|
||||||
|
from runtime.serial import Attachment
|
||||||
|
|
||||||
|
|
||||||
|
class NativeBoundaryTests(unittest.TestCase):
|
||||||
|
def link(self, handler):
|
||||||
|
value = object.__new__(NativeLink)
|
||||||
|
value.attachment = Attachment('2-1', '11', 'ttyACM0', '12')
|
||||||
|
value.buffer = b""; value.sequence = 0; value.hall_pending = False
|
||||||
|
value.check = lambda:None
|
||||||
|
script = 'import sys,json,time\nfor line in sys.stdin:\n r=json.loads(line)\n ' + handler + '\n'
|
||||||
|
value.process = subprocess.Popen([sys.executable, '-u', '-c', script],
|
||||||
|
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=0)
|
||||||
|
self.addCleanup(value.close)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def test_success_returns_upstream_payload_and_keeps_one_process(self):
|
||||||
|
link = self.link('print(json.dumps({"id":r["id"],"ok":True,"result":{"payload":"BA=="}}),flush=True)')
|
||||||
|
pid = link.process.pid
|
||||||
|
self.assertEqual(link.query(4),b'\x04')
|
||||||
|
self.assertEqual(link.query(4),b'\x04')
|
||||||
|
self.assertEqual(link.process.pid,pid)
|
||||||
|
self.assertEqual(link.sequence,2)
|
||||||
|
trace=link.last_rpc
|
||||||
|
self.assertEqual(trace["stage"],"complete")
|
||||||
|
stages=[trace[k] for k in ("attachment_before_ms","request_write_ms","native_response_ms","attachment_after_ms")]
|
||||||
|
self.assertTrue(all(value>=0 for value in stages))
|
||||||
|
self.assertLessEqual(sum(stages),trace["elapsed_ms"]+.1)
|
||||||
|
|
||||||
|
def test_wrong_identity_closes_stream_and_cannot_replay_motor_command(self):
|
||||||
|
link = self.link('print(json.dumps({"id":r["id"]+1,"ok":True,"result":{}}),flush=True)')
|
||||||
|
with self.assertRaises(ValueError):link.test_current(5)
|
||||||
|
self.assertFalse(link.alive)
|
||||||
|
with self.assertRaises(OSError):link.test_current(5)
|
||||||
|
|
||||||
|
def test_missing_ack_terminates_process_without_automatic_retry(self):
|
||||||
|
link = self.link('time.sleep(5)')
|
||||||
|
process = link.process
|
||||||
|
with self.assertRaises(TimeoutError):link.rpc('lease',timeout=.03)
|
||||||
|
self.assertFalse(link.alive)
|
||||||
|
self.assertIsNotNone(process.poll())
|
||||||
|
self.assertEqual(link.sequence,1)
|
||||||
|
|
||||||
|
def test_attachment_change_closes_owner_before_sending(self):
|
||||||
|
link = self.link('print(json.dumps({"id":r["id"],"ok":True,"result":{}}),flush=True)')
|
||||||
|
link.check = lambda: (_ for _ in ()).throw(OSError('changed attachment'))
|
||||||
|
with self.assertRaises(OSError):link.rpc('engine')
|
||||||
|
self.assertFalse(link.alive)
|
||||||
|
|
||||||
|
def test_native_error_and_malformed_result_close_owner(self):
|
||||||
|
for response in ('{"id":r["id"],"ok":False,"error":"disconnected"}',
|
||||||
|
'{"id":r["id"],"ok":True,"result":[]}'):
|
||||||
|
with self.subTest(response=response):
|
||||||
|
link=self.link('print(json.dumps('+response+'),flush=True)')
|
||||||
|
with self.assertRaises((OSError,ValueError)):link.rpc('engine')
|
||||||
|
self.assertFalse(link.alive)
|
||||||
|
|
||||||
|
def test_blocked_pipe_closes_owner(self):
|
||||||
|
link=self.link('time.sleep(5)')
|
||||||
|
with patch('runtime.native_link.select.select',return_value=([],[],[])):
|
||||||
|
with self.assertRaises(TimeoutError):link.rpc('engine')
|
||||||
|
self.assertFalse(link.alive)
|
||||||
|
|
||||||
|
def test_timeout_retains_command_budget_and_attachment_state(self):
|
||||||
|
link=self.link('print(json.dumps({"id":r["id"],"ok":False,"error":"Native query timed out or disconnected"}),flush=True)')
|
||||||
|
with self.assertRaises(OSError) as caught: link.query(31,timeout=.06)
|
||||||
|
trace=caught.exception.native_rpc
|
||||||
|
self.assertEqual(trace['command'],31)
|
||||||
|
self.assertEqual(trace['attachment'], {'usb':'2-1','address':'11','tty':'ttyACM0'})
|
||||||
|
self.assertEqual(trace['timeout_ms'],60)
|
||||||
|
self.assertTrue(trace['process_alive'])
|
||||||
|
self.assertTrue(trace['attachment_present'])
|
||||||
|
self.assertGreater(trace['elapsed_ms'],0)
|
||||||
|
self.assertFalse(link.alive)
|
||||||
|
|
||||||
|
def test_native_packet_diagnostics_survive_fail_closed_boundary(self):
|
||||||
|
detail = {"request_emitted": True, "packets_sent": 1, "packets_received": 0,
|
||||||
|
"serial_bytes_written": 6, "port_connected": True, "serial_error": 0,
|
||||||
|
"events": [{"event": "serial_written", "bytes": 6, "at_ms": 0.2}]}
|
||||||
|
link = self.link('print(json.dumps({"id":r["id"],"ok":False,"error":"Native query timed out or disconnected","diagnostics":'+repr(detail)+'}),flush=True)')
|
||||||
|
with self.assertRaises(OSError) as caught: link.query(31, timeout=.06)
|
||||||
|
self.assertEqual(caught.exception.native_rpc['transport'], detail)
|
||||||
|
self.assertEqual(link.sequence, 1)
|
||||||
|
self.assertFalse(link.alive)
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Synthetic protocol and ownership tests; never opens a real serial port."""
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "src"))
|
||||||
|
from runtime import SCHEMA
|
||||||
|
from runtime.protocol import Decoder, firmware, request, values
|
||||||
|
from runtime.serial import Attachment
|
||||||
|
from runtime.service import Service
|
||||||
|
|
||||||
|
|
||||||
|
def frame(payload):
|
||||||
|
header = bytes([2, len(payload)]) if len(payload) < 256 else b"\x03" + len(payload).to_bytes(2, "big")
|
||||||
|
return header + payload + binascii.crc_hqx(payload, 0).to_bytes(2, "big") + b"\x03"
|
||||||
|
|
||||||
|
|
||||||
|
def fw(index=1):
|
||||||
|
return b"\x00\x06\x06SYNTHETIC\x00" + bytes([index]) * 12 + b"\x00\x00\x00\x00"
|
||||||
|
|
||||||
|
|
||||||
|
def telemetry():
|
||||||
|
return b"\x04" + struct.pack(">hhiiiihihiiiiiiB", 215, 190, -123, 456, 0, 0, 123, 600, 481,
|
||||||
|
100, 0, 200, 0, 30, 30, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class ProtocolTests(unittest.TestCase):
|
||||||
|
def test_transmit_has_no_motor_or_write_escape_hatch(self):
|
||||||
|
for code in range(256):
|
||||||
|
if code in (0, 4, 14, 17, 31, 62):
|
||||||
|
self.assertEqual(Decoder().feed(request(code)), [bytes([code])])
|
||||||
|
else:
|
||||||
|
with self.assertRaises(ValueError): request(code)
|
||||||
|
for value in (True, b"\0", "0", -1, 256):
|
||||||
|
with self.assertRaises(ValueError): request(value)
|
||||||
|
|
||||||
|
def test_fragmented_and_combined_frames(self):
|
||||||
|
samples = [fw(), bytes([14]) + bytes(range(256)) * 4, telemetry()]
|
||||||
|
decoder = Decoder(); actual = []
|
||||||
|
for byte in b"noise" + b"".join(map(frame, samples)):
|
||||||
|
actual += decoder.feed(bytes([byte]))
|
||||||
|
self.assertEqual(actual, samples)
|
||||||
|
|
||||||
|
def test_crc_and_bounds(self):
|
||||||
|
bad = bytearray(frame(fw())); bad[-2] ^= 1
|
||||||
|
self.assertEqual(Decoder().feed(bad), [])
|
||||||
|
self.assertEqual(Decoder().feed(b"\x04\xff\xff\xff" + frame(fw())), [fw()])
|
||||||
|
with self.assertRaises(ValueError): Decoder().feed(bytes(30000))
|
||||||
|
|
||||||
|
def test_identity_requires_uuid_and_bounded_name(self):
|
||||||
|
self.assertEqual(firmware(fw())["uuid"], "01" * 12)
|
||||||
|
for data in (b"", fw()[:5], fw()[:20], b"\0\6\6" + b"x" * 150, fw(0)):
|
||||||
|
with self.assertRaises(ValueError): firmware(data)
|
||||||
|
|
||||||
|
def test_telemetry_scales_signed_values_and_truncation(self):
|
||||||
|
result = values(telemetry())
|
||||||
|
self.assertEqual(result["motor_current_a"], -1.23)
|
||||||
|
self.assertEqual(result["input_voltage_v"], 48.1)
|
||||||
|
self.assertEqual(result["erpm"], 600)
|
||||||
|
self.assertNotIn("timeout", result)
|
||||||
|
for length in range(54):
|
||||||
|
with self.assertRaises(ValueError): values(telemetry()[:length])
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLink:
|
||||||
|
instances = []
|
||||||
|
identities = {}
|
||||||
|
|
||||||
|
def __init__(self, attachment):
|
||||||
|
self.attachment = attachment; self.commands = []; self.closed = False
|
||||||
|
self.instances.append(self)
|
||||||
|
|
||||||
|
def query(self, code, timeout=2):
|
||||||
|
request(code)
|
||||||
|
self.commands.append(code)
|
||||||
|
if code == 0: return fw(self.identities.get(self.attachment.usb, int(self.attachment.address)))
|
||||||
|
if code == 4: return telemetry()
|
||||||
|
return bytes([code]) + b"\x12\x34\x56\x78opaque-config"
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.closed = True
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.attachments = [Attachment("1-2", "1", "ttyACM0", "12"), Attachment("1-3", "2", "ttyACM1", "12")]
|
||||||
|
FakeLink.instances = []; FakeLink.identities = {}
|
||||||
|
self.service = Service(self.temp.name, lambda: self.attachments, FakeLink)
|
||||||
|
self.service.scan()
|
||||||
|
|
||||||
|
def tearDown(self): self.temp.cleanup()
|
||||||
|
|
||||||
|
def command(self, item=None, action="vesc.telemetry.read"):
|
||||||
|
item = item or self.service.inventory("node_synthetic")["items"][0]
|
||||||
|
now = datetime.now(timezone.utc); identifier = "op_" + uuid.uuid4().hex
|
||||||
|
return {"api_version": SCHEMA, "kind": "OperationRequest", "operation_id": identifier,
|
||||||
|
"idempotency_key": identifier, "session": {"session_id": item["snapshot"]["context"]["session_id"], "device_id": item["id"]},
|
||||||
|
"requested_at": now.isoformat(), "deadline_at": (now + timedelta(seconds=60)).isoformat(),
|
||||||
|
"action_id": action, "parameters": {}}
|
||||||
|
|
||||||
|
def test_two_attachments_promote_independently(self):
|
||||||
|
items = self.service.inventory("node_synthetic")["items"]
|
||||||
|
self.assertEqual(len({i["id"] for i in items}), 2)
|
||||||
|
self.assertTrue(all(i["id"] != i["attachment_id"] and i["verified"] for i in items))
|
||||||
|
result = self.service.execute(self.command(items[1]))
|
||||||
|
self.assertEqual(result["state"], "complete")
|
||||||
|
self.assertEqual(FakeLink.instances[0].commands, [0])
|
||||||
|
self.assertEqual(FakeLink.instances[1].commands, [0, 0, 4])
|
||||||
|
|
||||||
|
def test_uuid_collision_never_aliases_a_controller(self):
|
||||||
|
FakeLink.identities = {"1-2": 1, "1-3": 1}
|
||||||
|
for device in self.service.devices.values(): device.close()
|
||||||
|
self.service.scan()
|
||||||
|
items = self.service.inventory("node_synthetic")["items"]
|
||||||
|
self.assertEqual(len({i["id"] for i in items}), 2)
|
||||||
|
self.assertTrue(all(not i["verified"] for i in items))
|
||||||
|
with self.assertRaises(ValueError): self.service.execute(self.command(items[0]))
|
||||||
|
|
||||||
|
def test_reconnect_invalidates_old_session_but_retains_uuid(self):
|
||||||
|
old = self.service.inventory("node_synthetic")["items"][0]; command = self.command(old)
|
||||||
|
self.attachments = self.attachments[1:]; self.service.scan()
|
||||||
|
self.assertTrue(FakeLink.instances[0].closed)
|
||||||
|
FakeLink.identities["1-2"] = 1
|
||||||
|
self.attachments.append(Attachment("1-2", "3", "ttyACM4", "12")); self.service.scan()
|
||||||
|
current = next(i for i in self.service.inventory("node_synthetic")["items"] if i["id"] == old["id"])
|
||||||
|
self.assertNotEqual(current["snapshot"]["context"]["session_id"], old["snapshot"]["context"]["session_id"])
|
||||||
|
with self.assertRaises(ValueError): self.service.execute(command)
|
||||||
|
|
||||||
|
def test_receipt_replay_never_repeats_serial_query(self):
|
||||||
|
command = self.command(); first = self.service.execute(command)
|
||||||
|
before = list(FakeLink.instances[0].commands)
|
||||||
|
self.assertEqual(self.service.execute(command), first)
|
||||||
|
self.assertEqual(FakeLink.instances[0].commands, before)
|
||||||
|
command["action_id"] = "vesc.config.backup"
|
||||||
|
with self.assertRaises(ValueError): self.service.execute(command)
|
||||||
|
|
||||||
|
def test_native_process_exit_invalidates_session_before_reconnection(self):
|
||||||
|
old = self.service.inventory("node_synthetic")["items"][0]
|
||||||
|
command = self.command(old)
|
||||||
|
process = FakeLink.instances[0]
|
||||||
|
process.alive = False
|
||||||
|
self.service.scan()
|
||||||
|
current = next(i for i in self.service.inventory("node_synthetic")["items"] if i["id"] == old["id"])
|
||||||
|
self.assertTrue(process.closed)
|
||||||
|
self.assertNotEqual(current["snapshot"]["context"]["session_id"], old["snapshot"]["context"]["session_id"])
|
||||||
|
with self.assertRaises(ValueError): self.service.execute(command)
|
||||||
|
|
||||||
|
def test_backup_is_raw_hashed_and_bound_to_identity(self):
|
||||||
|
result = self.service.execute(self.command(action="vesc.config.backup"))
|
||||||
|
self.assertEqual(result["state"], "complete")
|
||||||
|
value = result["result"]
|
||||||
|
self.assertFalse(value["decoded"])
|
||||||
|
self.assertEqual(base64.b64decode(value["configs"]["motor"]["payload"])[0], 14)
|
||||||
|
backups = list(Path(self.temp.name).glob("backup_*.json"))
|
||||||
|
self.assertEqual(json.loads(backups[0].read_text()), value)
|
||||||
|
self.assertEqual(backups[0].stat().st_mode & 0o777, 0o600)
|
||||||
|
self.assertEqual(FakeLink.instances[0].commands, [0, 0, 14, 17, 0])
|
||||||
|
|
||||||
|
def test_arbitrary_writes_parameters_and_expired_requests_rejected(self):
|
||||||
|
for action in ("start", "stop", "settings.apply", "raw", "firmware.write"):
|
||||||
|
with self.assertRaises(ValueError): self.service.execute(self.command(action=action))
|
||||||
|
command = self.command(); command["parameters"] = {"packet": "anything"}
|
||||||
|
with self.assertRaises(ValueError): self.service.execute(command)
|
||||||
|
command = self.command(); command["deadline_at"] = command["requested_at"]
|
||||||
|
with self.assertRaises(ValueError): self.service.execute(command)
|
||||||
|
self.assertTrue(all(link.commands == [0] for link in FakeLink.instances))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": unittest.main()
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Firmware neutral-band regression and retained native failure evidence."""
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import test_motor_test
|
||||||
|
from test_motor_test import configuration
|
||||||
|
from runtime.configuration import decode
|
||||||
|
from runtime.receiver import active, neutral_band
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiverTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.case=test_motor_test.PulseTests('test_fixed_wire_commands_no_broadcast_and_no_parameters')
|
||||||
|
self.case.setUp(); self.addCleanup(self.case.tearDown)
|
||||||
|
|
||||||
|
def offset(self, device, level, band=None):
|
||||||
|
original=device.link.query
|
||||||
|
def query(code,timeout=2):
|
||||||
|
if code==31:return bytes([31])+struct.pack('>ii',int(level*1e6),1466000)
|
||||||
|
raw=original(code,timeout)
|
||||||
|
if code==17 and band is not None:
|
||||||
|
return configuration('application',{**decode(raw,'application'),'app_ppm_conf.hyst':band})
|
||||||
|
return raw
|
||||||
|
device.link.query=query
|
||||||
|
|
||||||
|
def test_inside_deadband_allows_test_without_changing_ppm_configuration(self):
|
||||||
|
c=self.case
|
||||||
|
for device in c.devices:self.offset(device,-.066)
|
||||||
|
before=[d.link.query(17) for d in c.devices]
|
||||||
|
result=c.service.execute(c.command_for_pulse())
|
||||||
|
self.assertEqual(result['state'],'complete',result)
|
||||||
|
self.assertEqual(result['result']['outcome'],'duration')
|
||||||
|
self.assertFalse(c.service.motor.latched)
|
||||||
|
self.assertEqual(before,[d.link.query(17) for d in c.devices])
|
||||||
|
|
||||||
|
def test_each_peer_uses_its_own_fresh_band_and_active_input_blocks_motion(self):
|
||||||
|
c=self.case
|
||||||
|
for device in c.devices:self.offset(device,-.066)
|
||||||
|
self.assertEqual(c.service.execute(c.command_for_pulse())['state'],'complete')
|
||||||
|
c.sent.clear()
|
||||||
|
self.offset(c.devices[1],-.066,.05)
|
||||||
|
result=c.service.execute(c.command_for_pulse())
|
||||||
|
self.assertEqual(result['state'],'error')
|
||||||
|
self.assertTrue(c.service.motor.latched)
|
||||||
|
self.assertEqual(c.sent,[])
|
||||||
|
|
||||||
|
def test_unknown_band_and_invalid_input_do_not_assume_neutral(self):
|
||||||
|
with self.assertRaises(ValueError):self.case.service.motor.receiver_active('missing',0)
|
||||||
|
for level,band in [(float('nan'),.15),(0,float('nan')),(0,0),(0,.8)]:
|
||||||
|
with self.assertRaises(ValueError):active(level,band)
|
||||||
|
self.assertFalse(active(.15,.15))
|
||||||
|
self.assertTrue(active(-.150001,.15))
|
||||||
|
|
||||||
|
def test_native_read_and_preflight_failures_keep_transport_diagnostics(self):
|
||||||
|
c=self.case
|
||||||
|
for action,code in [('vesc.input.read',31),('vesc.motor.pulse',17)]:
|
||||||
|
with self.subTest(action=action):
|
||||||
|
device=c.devices[0]; original=device.link.query
|
||||||
|
trace={'command':code,'stage':'native_response','transport':{'request_emitted':True,'packets_received':0}}
|
||||||
|
def query(actual,timeout=2):
|
||||||
|
if actual==code:
|
||||||
|
error=OSError('native timeout');error.native_rpc=trace;error.native_history=[trace]
|
||||||
|
raise error
|
||||||
|
return original(actual,timeout)
|
||||||
|
device.link.query=query
|
||||||
|
command=c.command_for_pulse() if action=='vesc.motor.pulse' else c.command(action=action)
|
||||||
|
result=c.service.execute(command)
|
||||||
|
self.assertEqual(result['state'],'error')
|
||||||
|
self.assertEqual(result['result']['failure']['native_rpc'],trace)
|
||||||
|
saved=json.loads((c.service.root/(command['operation_id']+'.json')).read_text())
|
||||||
|
self.assertEqual(saved['receipt'],result)
|
||||||
|
self.assertEqual(c.service.execute(command),result)
|
||||||
|
self.assertEqual(c.sent,[])
|
||||||
|
device.link.query=original
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import unittest
|
||||||
|
from runtime.remote_control import InputLease
|
||||||
|
|
||||||
|
def envelope(identifier='a'*32,seq=1,**kw):
|
||||||
|
return dict(id=identifier,sequence=seq,ttl_ms=300,left=1,right=-1,
|
||||||
|
settings=dict(standstill_confirmed=True,current_a=30,max_erpm=2000),**kw)
|
||||||
|
|
||||||
|
class LeaseTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.now=0
|
||||||
|
self.lease=InputLease(lambda:self.now)
|
||||||
|
|
||||||
|
def test_no_duplicate_renewal_no_late_resume(self):
|
||||||
|
self.assertTrue(self.lease.accept(envelope()))
|
||||||
|
self.now=.2;self.assertFalse(self.lease.accept(envelope()))
|
||||||
|
self.now=.301;self.assertFalse(self.lease.live())
|
||||||
|
self.assertFalse(self.lease.accept(envelope(seq=2)))
|
||||||
|
self.assertFalse(self.lease.live())
|
||||||
|
|
||||||
|
def test_stop_is_terminal_even_with_newer_frames(self):
|
||||||
|
self.lease.accept(envelope());self.lease.stop()
|
||||||
|
self.assertFalse(self.lease.accept(envelope(seq=20)))
|
||||||
|
self.assertEqual(self.lease.demand,(0,0))
|
||||||
|
|
||||||
|
def test_identity_change_stops_instead_of_stealing(self):
|
||||||
|
self.lease.accept(envelope())
|
||||||
|
self.assertFalse(self.lease.accept(envelope('b'*32)))
|
||||||
|
self.assertFalse(self.lease.live())
|
||||||
|
|
||||||
|
def test_invalid_limits_and_nan(self):
|
||||||
|
for key,value in [('ttl_ms',401),('ttl_ms',0),('left',float('nan')),('right',2),('sequence',True)]:
|
||||||
|
item=envelope();item[key]=value
|
||||||
|
with self.subTest(key=key,value=value),self.assertRaises(ValueError):self.lease.accept(item)
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
import threading
|
||||||
|
from runtime.remote_control import ControlEnded, RemoteControl
|
||||||
|
|
||||||
|
|
||||||
|
class PreparationHandoffTests(unittest.TestCase):
|
||||||
|
"""The observer and control worker share one hardware owner, not a queue."""
|
||||||
|
def setUp(self):
|
||||||
|
self.now = [0.]
|
||||||
|
self.attempted = threading.Event()
|
||||||
|
self.operation = threading.Lock()
|
||||||
|
self.operation.acquire()
|
||||||
|
case = self
|
||||||
|
|
||||||
|
class ObservedLock:
|
||||||
|
def acquire(self, *args, **kwargs):
|
||||||
|
case.attempted.set()
|
||||||
|
return case.operation.acquire(*args, **kwargs)
|
||||||
|
|
||||||
|
def release(self):
|
||||||
|
case.operation.release()
|
||||||
|
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def run(*args, **kwargs):
|
||||||
|
self.calls.append('prepare')
|
||||||
|
raise ControlEnded('synthetic completion')
|
||||||
|
|
||||||
|
profile = {'layout': '1x1', 'revision': 1, 'bindings': {
|
||||||
|
'left.1': {'device_id': 'left'}, 'right.1': {'device_id': 'right'}}}
|
||||||
|
service = SimpleNamespace(operation_lock=ObservedLock(), lock=threading.Lock(),
|
||||||
|
motor=SimpleNamespace(run=run), drive=SimpleNamespace(value=profile),
|
||||||
|
devices={'left': SimpleNamespace(link=object()), 'right': SimpleNamespace(link=object())})
|
||||||
|
self.remote = RemoteControl(service)
|
||||||
|
self.remote.lease = InputLease(lambda: self.now[0])
|
||||||
|
self.relay = 'b' * 32
|
||||||
|
self.remote.feed({'watch': True, 'command': None, 'relay_id': self.relay})
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
self.remote.feed({'watch': True, 'command': envelope(), 'relay_id': self.relay})
|
||||||
|
self.assertTrue(self.attempted.wait(1))
|
||||||
|
|
||||||
|
def finish(self):
|
||||||
|
self.operation.release()
|
||||||
|
self.remote.thread.join(1)
|
||||||
|
self.assertFalse(self.remote.thread.is_alive())
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.remote.lease.stop()
|
||||||
|
if self.operation.locked():
|
||||||
|
self.operation.release()
|
||||||
|
if self.remote.thread:
|
||||||
|
self.remote.thread.join(1)
|
||||||
|
|
||||||
|
def test_existing_read_finishes_before_preparation(self):
|
||||||
|
self.start()
|
||||||
|
self.assertEqual(self.remote.state, 'preparing')
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
self.finish()
|
||||||
|
self.assertEqual(self.calls, ['prepare'])
|
||||||
|
self.assertEqual(self.remote.state, 'stopped')
|
||||||
|
self.assertFalse(self.operation.locked())
|
||||||
|
|
||||||
|
def test_stop_while_waiting_never_starts_preparation(self):
|
||||||
|
self.start()
|
||||||
|
self.remote.feed({'watch': True, 'command': None, 'relay_id': self.relay})
|
||||||
|
self.finish()
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
self.assertEqual(self.remote.state, 'stopped')
|
||||||
|
|
||||||
|
def test_expired_input_does_not_start_when_lock_is_released(self):
|
||||||
|
self.start()
|
||||||
|
self.now[0] = .301
|
||||||
|
self.finish()
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
self.assertFalse(self.remote.lease.live())
|
||||||
|
|
||||||
|
def test_long_operation_does_not_queue_preparation(self):
|
||||||
|
self.start()
|
||||||
|
self.remote.thread.join(1)
|
||||||
|
self.assertFalse(self.remote.thread.is_alive())
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
self.assertEqual(self.remote.state, 'fault')
|
||||||
|
self.assertTrue(self.operation.locked())
|
||||||
|
|
||||||
|
def test_observer_yields_to_waiting_control_worker(self):
|
||||||
|
self.start()
|
||||||
|
self.attempted.clear()
|
||||||
|
self.remote.observe()
|
||||||
|
# The observer does not even try to reacquire ownership.
|
||||||
|
# Its test devices intentionally have no serial read methods.
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
self.finish()
|
||||||
|
self.assertEqual(self.calls, ['prepare'])
|
||||||
|
|
||||||
|
class DriveBoundaryTests(unittest.TestCase):
|
||||||
|
"""Synthetic two-controller run: verify the actual output loop and cleanup."""
|
||||||
|
def run_drive(self, scenario, *, broken_read=False, through_prepare=False, minimum_erpm=900):
|
||||||
|
now=[0.]; logs=[]; restored=[]
|
||||||
|
profile={'layout':'1x1','revision':1,'bindings':{
|
||||||
|
'left.1':{'device_id':'left','uuid':'left'},
|
||||||
|
'right.1':{'device_id':'right','uuid':'right'}}}
|
||||||
|
service=SimpleNamespace(drive=SimpleNamespace(value=profile))
|
||||||
|
remote=RemoteControl(service)
|
||||||
|
remote._publish=lambda *args:None
|
||||||
|
remote.lease=InputLease(lambda:now[0]); remote.lease.accept(envelope())
|
||||||
|
remote.lease.demand=(0,0)
|
||||||
|
def value():return dict(fault_code=0,input_voltage_v=48,mos_temperature_c=25,
|
||||||
|
motor_current_a=0,input_current_a=0,erpm=0,duty=0,tachometer=0)
|
||||||
|
class Link:
|
||||||
|
def __init__(self,name):self.name=name
|
||||||
|
def query(self,code,timeout):
|
||||||
|
if broken_read and self.name=='right' and now[0]>=.2:raise TimeoutError('lost right controller')
|
||||||
|
if code==4:
|
||||||
|
result=value()
|
||||||
|
if scenario=='coasting' and self.name=='right' and .4<=now[0]<.8:result['erpm']=1000
|
||||||
|
return result
|
||||||
|
return {'level':.8 if scenario=='receiver' and .2<=now[0]<.5 else 0}
|
||||||
|
def test_command(self,name):logs.append((now[0],self.name,name,0))
|
||||||
|
def test_speed(self,rpm):logs.append((now[0],self.name,'rpm',rpm))
|
||||||
|
devices=[SimpleNamespace(id=name,link=Link(name)) for name in ('left','right')]
|
||||||
|
owner=SimpleNamespace(stop=threading.Event(),latched=False,active=False,mode=None)
|
||||||
|
def state(s):owner.latched=s=='rc'
|
||||||
|
owner.state=state
|
||||||
|
owner.receiver_active=lambda _,level:abs(level)>.15
|
||||||
|
owner.limits=SimpleNamespace(apply=lambda d,*a:dict(l_current_max=35,l_max_erpm=100000,l_min_erpm=-100000,l_max_duty=.95,
|
||||||
|
s_pid_min_erpm=minimum_erpm[d.id] if isinstance(minimum_erpm,dict) else minimum_erpm),restore=lambda d:restored.append(d.id))
|
||||||
|
def sleep(delta):
|
||||||
|
now[0]+=delta
|
||||||
|
if now[0]>=2:owner.stop.set()
|
||||||
|
if remote.lease.live():
|
||||||
|
if scenario!='expire': remote.lease.until=now[0]+.4
|
||||||
|
remote.lease.demand=((-1,-1) if scenario=='reverse' and now[0]>=.4 else (1,1))
|
||||||
|
if scenario=='small': remote.lease.demand=(.2,-.2)
|
||||||
|
if scenario=='turn': remote.lease.demand=(-1,1)
|
||||||
|
if scenario=='release' and now[0]>=.4: remote.lease.demand=(0,0)
|
||||||
|
if scenario in ('turn-forward','coasting'):
|
||||||
|
remote.lease.demand=(-1,1) if now[0]<.4 else (1,1)
|
||||||
|
if scenario=='paused-reverse':
|
||||||
|
remote.lease.demand=(1,1) if now[0]<.4 else (0,0) if now[0]<1.2 else (-1,-1)
|
||||||
|
if scenario=='cancel-reverse':
|
||||||
|
remote.lease.demand=(-1,-1) if .4<=now[0]<.6 else (1,1)
|
||||||
|
owner.sleep=sleep
|
||||||
|
command={'parameters':{'current_a':30,'erpm':2000}}
|
||||||
|
originals={d.id:{'motor':b'config'} for d in devices}
|
||||||
|
service.motor=owner;service.operation_lock=threading.Lock();service.lock=threading.Lock()
|
||||||
|
service.devices={d.id:d for d in devices}
|
||||||
|
owner.run=lambda command,devices,target,remote:remote.drive(owner,command,devices,devices,originals,lambda:None)
|
||||||
|
error=None
|
||||||
|
with patch('runtime.remote_control.time.monotonic',lambda:now[0]),patch('runtime.remote_control.values',lambda v:v),patch('runtime.remote_control.ppm',lambda v:v):
|
||||||
|
try:
|
||||||
|
if through_prepare: remote._prepare(envelope())
|
||||||
|
else: remote.drive(owner,command,devices,devices,originals,lambda:None)
|
||||||
|
except (ValueError,TimeoutError) as e:error=e
|
||||||
|
return logs,restored,remote,owner,error
|
||||||
|
|
||||||
|
def test_start_enters_each_configured_pid_range_without_dead_ramp(self):
|
||||||
|
log,_,_,_,error=self.run_drive('forward',minimum_erpm={'left':900,'right':1100.1})
|
||||||
|
self.assertIsNone(error)
|
||||||
|
for name,minimum in [('left',900),('right',1101)]:
|
||||||
|
commands=[v for v in log if v[1]==name and v[2]=='rpm']
|
||||||
|
self.assertLessEqual(commands[0][0],.2)
|
||||||
|
self.assertEqual(commands[0][3],minimum)
|
||||||
|
self.assertTrue(all(minimum<=v[3]<=2000 for v in commands))
|
||||||
|
for before,after in zip(commands,commands[1:]):
|
||||||
|
self.assertLessEqual(after[3]-before[3],600*(after[0]-before[0])+1e-6)
|
||||||
|
|
||||||
|
def test_turn_starts_with_opposite_signs_at_pid_threshold(self):
|
||||||
|
log,_,_,_,error=self.run_drive('turn')
|
||||||
|
self.assertIsNone(error)
|
||||||
|
for name,sign in [('left',-1),('right',1)]:
|
||||||
|
first=next(v for v in log if v[1]==name and v[2]=='rpm')
|
||||||
|
self.assertEqual(first[3],sign*900)
|
||||||
|
|
||||||
|
def test_subthreshold_request_is_released_never_amplified(self):
|
||||||
|
log,_,_,_,error=self.run_drive('small')
|
||||||
|
self.assertIsNone(error)
|
||||||
|
self.assertFalse(any(v[2]=='rpm' for v in log))
|
||||||
|
|
||||||
|
def test_release_still_has_no_ramp_or_minimum_speed(self):
|
||||||
|
log,_,_,_,error=self.run_drive('release')
|
||||||
|
self.assertIsNone(error)
|
||||||
|
self.assertTrue(any(v[2]=='rpm' for v in log))
|
||||||
|
self.assertFalse(any(v[2]=='rpm' and v[0]>=.4 for v in log))
|
||||||
|
|
||||||
|
def test_invalid_or_unreachable_pid_threshold_never_claims_output(self):
|
||||||
|
for minimum in [-1,float('nan'),float('inf'),2000.1]:
|
||||||
|
with self.subTest(minimum=minimum):
|
||||||
|
log,_,_,_,error=self.run_drive('forward',minimum_erpm=minimum)
|
||||||
|
self.assertIsInstance(error,ValueError)
|
||||||
|
self.assertFalse(any(v[2] in ('rpm','claim') for v in log))
|
||||||
|
|
||||||
|
def test_turn_to_forward_releases_both_and_restarts_in_same_cycle(self):
|
||||||
|
log,_,_,_,error=self.run_drive('turn-forward')
|
||||||
|
self.assertIsNone(error)
|
||||||
|
restarts={name:next(v[0] for v in log if v[1]==name and v[2]=='rpm' and v[0]>=.4)
|
||||||
|
for name in ('left','right')}
|
||||||
|
self.assertEqual(restarts['left'],restarts['right'])
|
||||||
|
self.assertGreaterEqual(restarts['left'],.9)
|
||||||
|
self.assertFalse(any(v[2]=='rpm' and .4<=v[0]<restarts['left'] for v in log))
|
||||||
|
self.assertTrue(all(v[3]>0 for v in log if v[2]=='rpm' and v[0]>=restarts['left']))
|
||||||
|
|
||||||
|
def test_shared_reversal_waits_for_every_motor_to_be_quiet(self):
|
||||||
|
log,_,_,_,error=self.run_drive('coasting')
|
||||||
|
self.assertIsNone(error)
|
||||||
|
first=next(v[0] for v in log if v[2]=='rpm' and v[0]>=.4)
|
||||||
|
self.assertGreaterEqual(first,1.3)
|
||||||
|
self.assertFalse(any(v[2]=='rpm' and .4<=v[0]<1.3 for v in log))
|
||||||
|
|
||||||
|
def test_neutral_pause_counts_before_reversal_command(self):
|
||||||
|
log,_,_,_,error=self.run_drive('paused-reverse')
|
||||||
|
self.assertIsNone(error)
|
||||||
|
first=next(v[0] for v in log if v[2]=='rpm' and v[3]<0)
|
||||||
|
self.assertLess(first,1.4)
|
||||||
|
|
||||||
|
def test_cancelled_reversal_never_emits_old_direction(self):
|
||||||
|
log,_,_,_,error=self.run_drive('cancel-reverse')
|
||||||
|
self.assertIsNone(error)
|
||||||
|
self.assertFalse(any(v[2]=='rpm' and v[3]<0 for v in log))
|
||||||
|
self.assertTrue(any(v[2]=='rpm' and v[0]>=.6 for v in log))
|
||||||
|
|
||||||
|
def test_expired_browser_lease_releases_every_controller(self):
|
||||||
|
log,restored,remote,owner,error=self.run_drive('expire')
|
||||||
|
self.assertIsNotNone(error)
|
||||||
|
self.assertFalse(any(row[2]=='rpm' and row[0]>=.3 for row in log))
|
||||||
|
self.assertEqual(set(restored),{'left','right'})
|
||||||
|
self.assertTrue(remote.release_confirmed)
|
||||||
|
self.assertTrue(any(row[2]=='rpm' for row in log))
|
||||||
|
for name in ('left','right'):self.assertEqual([v[2] for v in log if v[1]==name][-1],'release')
|
||||||
|
|
||||||
|
def test_terminal_lease_reports_stopped_after_verified_cleanup(self):
|
||||||
|
log,restored,remote,owner,error=self.run_drive('expire',through_prepare=True)
|
||||||
|
self.assertIsNone(error)
|
||||||
|
self.assertEqual(remote.state,'stopped')
|
||||||
|
self.assertIsNone(remote.message)
|
||||||
|
self.assertTrue(remote.release_confirmed)
|
||||||
|
self.assertTrue(any(row[2]=='rpm' for row in log))
|
||||||
|
self.assertEqual(set(restored),{'left','right'})
|
||||||
|
self.assertFalse(remote.service.operation_lock.locked())
|
||||||
|
|
||||||
|
def test_session_wrapper_preserves_unconfirmed_release_fault(self):
|
||||||
|
_,_,remote,owner,error=self.run_drive('lost',broken_read=True,through_prepare=True)
|
||||||
|
self.assertEqual(remote.state,'fault')
|
||||||
|
self.assertFalse(remote.release_confirmed)
|
||||||
|
self.assertIn('не подтверждено',remote.message)
|
||||||
|
self.assertFalse(remote.service.operation_lock.locked())
|
||||||
|
|
||||||
|
def test_receiver_first_gesture_holds_zero_until_neutral(self):
|
||||||
|
log,restored,remote,owner,error=self.run_drive('receiver')
|
||||||
|
self.assertIsNone(error)
|
||||||
|
self.assertTrue(any(v[2]=='rpm' for v in log))
|
||||||
|
self.assertFalse(any(v[2]=='rpm' and v[0]>=.2 for v in log))
|
||||||
|
self.assertTrue(owner.latched)
|
||||||
|
self.assertEqual(remote.state,'receiver')
|
||||||
|
self.assertGreaterEqual(max(v[0] for v in log),.9)
|
||||||
|
|
||||||
|
def test_reversal_has_zero_interval(self):
|
||||||
|
log,_,_,_,error=self.run_drive('reverse')
|
||||||
|
self.assertIsNone(error)
|
||||||
|
first_negative=min(v[0] for v in log if v[2]=='rpm' and v[3]<0)
|
||||||
|
self.assertGreaterEqual(first_negative,.9)
|
||||||
|
self.assertFalse(any(v[2]=='rpm' and .4<=v[0]<.9 for v in log))
|
||||||
|
|
||||||
|
def test_one_port_failure_stops_both_and_does_not_claim_confirmed_release(self):
|
||||||
|
log,restored,remote,owner,error=self.run_drive('lost',broken_read=True)
|
||||||
|
self.assertIsInstance(error,TimeoutError)
|
||||||
|
self.assertFalse(any(v[2]=='rpm' and v[0]>=.2 for v in log))
|
||||||
|
self.assertFalse(remote.release_confirmed)
|
||||||
|
self.assertTrue(owner.latched)
|
||||||
|
self.assertEqual(remote.state,'fault')
|
||||||
|
self.assertIn('не подтверждено',remote.message)
|
||||||
|
self.assertEqual(set(restored),{'left','right'})
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Signed upstream speed commands retain timing, preflight and RC interlocks."""
|
||||||
|
import struct
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import test_speed_and_hall
|
||||||
|
import test_group_test
|
||||||
|
from test_motor_test import configuration
|
||||||
|
from runtime.configuration import decode
|
||||||
|
from runtime.protocol import Decoder, speed_packet
|
||||||
|
from runtime.speed_hold import SpeedHold
|
||||||
|
|
||||||
|
|
||||||
|
class ReverseTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.rig = test_speed_and_hall.RunTests('test_thirty_seconds_excludes_ramp_and_settle_and_restores_limits')
|
||||||
|
self.rig.setUp()
|
||||||
|
self.addCleanup(self.rig.doCleanups)
|
||||||
|
self.case = self.rig.case
|
||||||
|
|
||||||
|
def command(self):
|
||||||
|
command = self.rig.run_command()
|
||||||
|
command['parameters']['erpm'] = -3000
|
||||||
|
return command
|
||||||
|
|
||||||
|
def test_reverse_holds_thirty_seconds_with_signed_ramp_and_restores(self):
|
||||||
|
result = self.case.service.execute(self.command())['result']
|
||||||
|
self.assertEqual(result['outcome'], 'duration', result)
|
||||||
|
self.assertGreaterEqual(result['rotation_s'], 30)
|
||||||
|
self.assertGreater(result['samples'][-1]['at'], 35)
|
||||||
|
speeds = [v for _, v in self.rig.speeds]
|
||||||
|
self.assertEqual(speeds[0], -1)
|
||||||
|
self.assertEqual(speeds[-1], -3000)
|
||||||
|
self.assertTrue(all(-3000 <= v < 0 for v in speeds))
|
||||||
|
self.assertTrue(all(-31 <= b-a <= 0 for a, b in zip(speeds, speeds[1:])))
|
||||||
|
self.assertTrue(result['release_confirmed'])
|
||||||
|
self.assertTrue(result['limits_restored'])
|
||||||
|
self.assertEqual(self.rig.configs, self.rig.originals)
|
||||||
|
self.assertEqual(len(result['preflight']['samples']), 20)
|
||||||
|
|
||||||
|
def test_missing_false_or_nonboolean_stop_confirmation_never_writes(self):
|
||||||
|
for confirmation in (None, False, 1, 'true'):
|
||||||
|
command = self.command()
|
||||||
|
if confirmation is None: del command['parameters']['standstill_confirmed']
|
||||||
|
else: command['parameters']['standstill_confirmed'] = confirmation
|
||||||
|
with self.assertRaises(ValueError): self.case.service.execute(command)
|
||||||
|
self.assertEqual(self.rig.speeds, [])
|
||||||
|
self.assertEqual(self.case.sent, [])
|
||||||
|
|
||||||
|
def test_unsigned_size_bounds_are_checked_for_both_directions(self):
|
||||||
|
for erpm in (0, 299, -299, 3001, -3001, True, float('nan'), float('inf')):
|
||||||
|
command = self.command(); command['parameters']['erpm'] = erpm
|
||||||
|
with self.assertRaises(ValueError): self.case.service.execute(command)
|
||||||
|
self.assertEqual(self.rig.speeds, [])
|
||||||
|
|
||||||
|
def test_reverse_respects_configured_negative_limit(self):
|
||||||
|
target = self.case.devices[0]
|
||||||
|
motor = decode(self.rig.configs[target.id], 'motor')
|
||||||
|
self.rig.configs[target.id] = configuration('motor', {**motor, 'l_min_erpm': -1000})
|
||||||
|
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
|
||||||
|
self.assertEqual(self.rig.speeds, [])
|
||||||
|
|
||||||
|
def test_sensorless_idle_observation_rejects_modulation_above_one_quantum(self):
|
||||||
|
peer = self.case.devices[1]
|
||||||
|
motor = decode(self.rig.configs[peer.id], 'motor')
|
||||||
|
self.rig.configs[peer.id] = configuration('motor', {**motor, 'foc_sensor_mode': 0})
|
||||||
|
original = peer.link.query
|
||||||
|
reads = 0
|
||||||
|
def query(code, timeout=2):
|
||||||
|
nonlocal reads
|
||||||
|
raw = original(code, timeout)
|
||||||
|
if code == 4:
|
||||||
|
reads += 1
|
||||||
|
raw = raw[:23] + struct.pack('>i', -160) + raw[27:]
|
||||||
|
if reads >= 6: raw = raw[:21] + struct.pack('>h', 2) + raw[23:]
|
||||||
|
return raw
|
||||||
|
peer.link.query = query
|
||||||
|
result = self.case.service.execute(self.command())
|
||||||
|
self.assertEqual(result['state'], 'error')
|
||||||
|
self.assertEqual(self.rig.speeds, [])
|
||||||
|
self.assertEqual(self.rig.sets, [])
|
||||||
|
|
||||||
|
def test_attended_sensorless_peer_drift_allows_test_and_explicit_release(self):
|
||||||
|
peer = self.case.devices[1]
|
||||||
|
motor = decode(self.rig.configs[peer.id], 'motor')
|
||||||
|
self.rig.configs[peer.id] = configuration('motor', {**motor, 'foc_sensor_mode': 0})
|
||||||
|
query = peer.link.query
|
||||||
|
def read(code, timeout=2):
|
||||||
|
raw = query(code, timeout)
|
||||||
|
return raw[:23] + struct.pack('>i', -160) + raw[27:] if code == 4 else raw
|
||||||
|
peer.link.query = read
|
||||||
|
self.case.service.motor.state('rc')
|
||||||
|
release = self.case.command_for_pulse()
|
||||||
|
release['action_id'] = 'vesc.control.release'
|
||||||
|
release['parameters']['standstill_confirmed'] = True
|
||||||
|
receipt = self.case.service.execute(release)
|
||||||
|
self.assertEqual(receipt['state'], 'complete', receipt)
|
||||||
|
self.assertFalse(self.case.service.motor.latched)
|
||||||
|
self.assertEqual(self.rig.speeds, [])
|
||||||
|
result = self.case.service.execute(self.command())['result']
|
||||||
|
self.assertEqual(result['outcome'], 'duration', result)
|
||||||
|
self.assertTrue(any(s['values']['erpm'] == -160 for s in result['preflight']['samples']))
|
||||||
|
|
||||||
|
def test_reverse_rc_preemption_latches_without_restart(self):
|
||||||
|
speed = self.case.devices[0].link.test_speed
|
||||||
|
def send(value):
|
||||||
|
speed(value)
|
||||||
|
self.case.changed = True
|
||||||
|
self.case.devices[0].link.test_speed = send
|
||||||
|
result = self.case.service.execute(self.command())['result']
|
||||||
|
self.assertEqual(len(self.rig.speeds), 1)
|
||||||
|
self.assertTrue(self.case.service.motor.latched)
|
||||||
|
self.assertEqual(result['rotation_s'], 0)
|
||||||
|
self.assertTrue(result['release_confirmed'])
|
||||||
|
|
||||||
|
|
||||||
|
class ReverseClockTests(unittest.TestCase):
|
||||||
|
def test_wrong_direction_never_counts_even_with_changing_tachometer(self):
|
||||||
|
hold = SpeedHold(-3000, 30, 0)
|
||||||
|
for i in range(151): _, _, error = hold.update(i/10, {'erpm':3000, 'tachometer':i})
|
||||||
|
self.assertEqual(hold.rotation_s, 0)
|
||||||
|
self.assertIsNone(hold.hold_started)
|
||||||
|
self.assertIn('не вышел', error)
|
||||||
|
|
||||||
|
def test_signed_wire_is_int32_not_absolute_value(self):
|
||||||
|
self.assertEqual(Decoder().feed(speed_packet(-3000)), [bytes([8])+struct.pack('>i', -3000)])
|
||||||
|
|
||||||
|
def test_common_reverse_holds_all_motors_and_restores(self):
|
||||||
|
rig = test_group_test.GroupTests('test_pair_and_four_motors_run_one_common_interval_and_restore')
|
||||||
|
rig.setUp(); self.addCleanup(rig.doCleanups)
|
||||||
|
command = rig.command(); command['parameters']['erpm'] = -3000
|
||||||
|
result = rig.case.service.execute(command)['result']
|
||||||
|
self.assertEqual(result['outcome'], 'duration', result)
|
||||||
|
self.assertGreaterEqual(result['rotation_s'], 3)
|
||||||
|
self.assertTrue(all(-3000 <= v < 0 for _, v in rig.commands))
|
||||||
|
self.assertTrue(result['limits_restored'])
|
||||||
|
self.assertEqual(rig.configs, rig.originals)
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"""Fault injection for true hold time, volatile limits and native Hall lifecycle."""
|
||||||
|
import struct
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
import test_motor_test
|
||||||
|
from test_motor_test import configuration
|
||||||
|
from runtime.configuration import decode
|
||||||
|
from runtime.protocol import Decoder, speed_packet, hall_packet
|
||||||
|
from runtime.temporary_limits import FIELDS, packet
|
||||||
|
from runtime.speed_hold import SpeedHold
|
||||||
|
from runtime.hall_detection import parse_result
|
||||||
|
|
||||||
|
|
||||||
|
class RunTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.case = test_motor_test.PulseTests('test_fixed_wire_commands_no_broadcast_and_no_parameters')
|
||||||
|
self.case.setUp()
|
||||||
|
self.addCleanup(self.case.tearDown)
|
||||||
|
c = self.case
|
||||||
|
self.configs = {}
|
||||||
|
self.speeds = []
|
||||||
|
self.moving = True
|
||||||
|
self.tacho = 0
|
||||||
|
self.hall_started = None
|
||||||
|
self.sets = []
|
||||||
|
for device in c.devices:
|
||||||
|
self.configs[device.id] = configuration('motor', {**decode(c.motor,'motor'),
|
||||||
|
'l_current_min':-60,'l_current_max_scale':1,'l_current_min_scale':1})
|
||||||
|
original = device.link.query
|
||||||
|
def query(code,timeout=2,original=original,device=device):
|
||||||
|
if code == 14: return self.configs[device.id]
|
||||||
|
raw = original(code,timeout)
|
||||||
|
if code == 4 and device is c.devices[0]:
|
||||||
|
if self.hall_started is not None and c.clock.now - self.hall_started >= 12:
|
||||||
|
device.link.hall_result = bytes([28,255,1,34,67,100,134,167,255,0])
|
||||||
|
if self.moving and self.speeds:
|
||||||
|
self.tacho += 6
|
||||||
|
rpm = round(self.speeds[-1][1])
|
||||||
|
raw = raw[:23]+struct.pack('>i',rpm)+raw[27:]
|
||||||
|
raw = raw[:45]+struct.pack('>ii',self.tacho,self.tacho)+raw[53:]
|
||||||
|
return raw
|
||||||
|
device.link.query = query
|
||||||
|
def set_limits(config,device=device):
|
||||||
|
self.sets.append((device.id,config))
|
||||||
|
old = decode(self.configs[device.id],'motor')
|
||||||
|
self.configs[device.id] = configuration('motor',{**old,**{k:config[k] for k in FIELDS}})
|
||||||
|
device.link.set_temporary_limits = set_limits
|
||||||
|
device.link.test_speed = lambda speed,device=device:self.speeds.append((device.id,speed))
|
||||||
|
device.link.hall_result = None
|
||||||
|
device.link.hall_pending = False
|
||||||
|
def hall(device=device):
|
||||||
|
self.hall_started = c.clock.now
|
||||||
|
device.link.hall_pending = True
|
||||||
|
device.link.detect_hall = hall
|
||||||
|
self.originals = dict(self.configs)
|
||||||
|
|
||||||
|
def run_command(self):
|
||||||
|
command = self.case.command_for_pulse()
|
||||||
|
command['action_id'] = 'vesc.motor.run'
|
||||||
|
command['parameters'].update(erpm=1200,current_a=30,duration_s=30,standstill_confirmed=True)
|
||||||
|
from datetime import datetime,timedelta
|
||||||
|
command['deadline_at']=(datetime.fromisoformat(command['requested_at'])+timedelta(seconds=90)).isoformat()
|
||||||
|
return command
|
||||||
|
|
||||||
|
def test_thirty_seconds_excludes_ramp_and_settle_and_restores_limits(self):
|
||||||
|
result=self.case.service.execute(self.run_command())['result']
|
||||||
|
self.assertEqual(result['outcome'],'duration',result['outcome'])
|
||||||
|
self.assertGreaterEqual(result['rotation_s'],30)
|
||||||
|
self.assertLess(result['rotation_s'],30.1)
|
||||||
|
self.assertGreater(result['samples'][-1]['at'],32)
|
||||||
|
self.assertTrue(result['release_confirmed'])
|
||||||
|
self.assertTrue(result['limits_restored'])
|
||||||
|
self.assertEqual(self.configs,self.originals)
|
||||||
|
self.assertEqual({device for device,_ in self.speeds},{self.case.devices[0].id})
|
||||||
|
applied=self.sets[0][1]
|
||||||
|
self.assertAlmostEqual(applied['l_current_max']*applied['l_current_max_scale'],30)
|
||||||
|
self.assertAlmostEqual(applied['l_current_min']*applied['l_current_min_scale'],-30)
|
||||||
|
self.assertFalse(list(self.case.service.root.glob('limits_*.json')))
|
||||||
|
|
||||||
|
def test_stationary_motor_never_counts_command_time_as_rotation(self):
|
||||||
|
self.moving=False
|
||||||
|
result=self.case.service.execute(self.run_command())['result']
|
||||||
|
self.assertEqual(result['rotation_s'],0)
|
||||||
|
self.assertIn('не вышел',result['outcome'])
|
||||||
|
self.assertGreaterEqual(result['samples'][-1]['at'],15)
|
||||||
|
self.assertTrue(result['limits_restored'])
|
||||||
|
|
||||||
|
def test_lost_ack_after_limit_write_restores_before_any_speed_command(self):
|
||||||
|
target=self.case.devices[0]
|
||||||
|
original=target.link.set_temporary_limits
|
||||||
|
def send(config):
|
||||||
|
original(config)
|
||||||
|
if len(self.sets)==1:raise TimeoutError('lost ACK after applying')
|
||||||
|
target.link.set_temporary_limits=send
|
||||||
|
result=self.case.service.execute(self.run_command())['result']
|
||||||
|
self.assertEqual(self.speeds,[])
|
||||||
|
self.assertTrue(result['limits_restored'])
|
||||||
|
self.assertEqual(self.configs,self.originals)
|
||||||
|
|
||||||
|
def test_pending_limits_recovered_after_interruption_without_motor_command(self):
|
||||||
|
target=self.case.devices[0]
|
||||||
|
self.case.service.motor.limits.apply(target,self.originals[target.id],5)
|
||||||
|
self.assertTrue(self.case.service.motor.limits.pending(target))
|
||||||
|
self.case.service.scan()
|
||||||
|
self.assertEqual(self.configs,self.originals)
|
||||||
|
self.assertEqual(self.speeds,[])
|
||||||
|
self.assertFalse(self.case.service.motor.limits.pending(target))
|
||||||
|
|
||||||
|
def test_external_configuration_change_never_overwritten_by_recovery(self):
|
||||||
|
target=self.case.devices[0]
|
||||||
|
self.case.service.motor.limits.apply(target,self.originals[target.id],5)
|
||||||
|
self.configs[target.id]=configuration('motor',{**decode(self.configs[target.id],'motor'),'foc_motor_r':0.1})
|
||||||
|
before=self.configs[target.id]
|
||||||
|
self.case.service.scan()
|
||||||
|
self.assertEqual(before,self.configs[target.id])
|
||||||
|
self.assertTrue(self.case.service.motor.limits.pending(target))
|
||||||
|
self.assertEqual(self.case.service.execute(self.run_command())['state'],'error')
|
||||||
|
self.assertEqual(self.speeds,[])
|
||||||
|
|
||||||
|
def test_native_hall_returns_measured_table_without_applying_it(self):
|
||||||
|
command=self.case.command(action='vesc.hall.measure')
|
||||||
|
command['parameters']={'rig_clear':True,'standstill_confirmed':True,'native_cycle_confirmed':True,
|
||||||
|
'sessions':{d.id:d.session for d in self.case.devices}}
|
||||||
|
receipt=self.case.service.execute(command)
|
||||||
|
self.assertEqual(receipt['state'],'complete',receipt)
|
||||||
|
result=receipt['result']
|
||||||
|
self.assertTrue(result['completed'])
|
||||||
|
self.assertTrue(result['measurement']['valid_six_states'])
|
||||||
|
self.assertTrue(result['configuration_restored'])
|
||||||
|
self.assertTrue(result['release_confirmed'])
|
||||||
|
self.assertFalse(result['configuration_written'])
|
||||||
|
self.assertEqual(self.configs,self.originals)
|
||||||
|
self.assertEqual(self.speeds,[])
|
||||||
|
self.assertFalse((self.case.service.root/'hall-pending.json').exists())
|
||||||
|
started=self.hall_started
|
||||||
|
self.assertEqual(self.case.service.execute(command),receipt)
|
||||||
|
self.assertEqual(started,self.hall_started)
|
||||||
|
|
||||||
|
def test_native_hall_requires_its_own_explicit_confirmation(self):
|
||||||
|
command=self.case.command(action='vesc.hall.measure')
|
||||||
|
command['parameters']={'rig_clear':True,'standstill_confirmed':True,'native_cycle_confirmed':False,
|
||||||
|
'sessions':{d.id:d.session for d in self.case.devices}}
|
||||||
|
with self.assertRaises(ValueError):self.case.service.execute(command)
|
||||||
|
self.assertIsNone(self.hall_started)
|
||||||
|
|
||||||
|
def test_rc_preemption_releases_speed_control_and_restores_limits(self):
|
||||||
|
original=self.case.devices[0].link.test_speed
|
||||||
|
def speed(value):
|
||||||
|
original(value)
|
||||||
|
self.case.changed=True
|
||||||
|
self.case.devices[0].link.test_speed=speed
|
||||||
|
result=self.case.service.execute(self.run_command())['result']
|
||||||
|
self.assertEqual(len(self.speeds),1)
|
||||||
|
self.assertEqual(result['rotation_s'],0)
|
||||||
|
self.assertTrue(self.case.service.motor.latched)
|
||||||
|
# Restoring while a receiver actively commands would enlarge RC torque;
|
||||||
|
# keep the lower limits until neutral, then recover without auto-start.
|
||||||
|
self.assertFalse(result['limits_restored'])
|
||||||
|
self.case.changed=False
|
||||||
|
self.case.service.scan()
|
||||||
|
self.assertEqual(self.configs,self.originals)
|
||||||
|
self.assertTrue(self.case.service.motor.latched)
|
||||||
|
self.assertEqual(len(self.speeds),1)
|
||||||
|
|
||||||
|
def test_hall_result_timeout_latches_and_never_starts_a_second_cycle(self):
|
||||||
|
self.case.devices[0].link.detect_hall=lambda:None
|
||||||
|
command=self.case.command(action='vesc.hall.measure')
|
||||||
|
command['parameters']={'rig_clear':True,'standstill_confirmed':True,'native_cycle_confirmed':True,
|
||||||
|
'sessions':{d.id:d.session for d in self.case.devices}}
|
||||||
|
result=self.case.service.execute(command)['result']
|
||||||
|
self.assertFalse(result['completed'])
|
||||||
|
self.assertTrue((self.case.service.root/'hall-pending.json').exists())
|
||||||
|
self.assertTrue(self.case.service.motor.latched)
|
||||||
|
self.assertEqual(self.case.service.execute(self.run_command())['state'],'error')
|
||||||
|
|
||||||
|
|
||||||
|
class HoldClockTests(unittest.TestCase):
|
||||||
|
def test_interrupted_motion_and_usb_gap_are_excluded(self):
|
||||||
|
hold=SpeedHold(1200,30,0)
|
||||||
|
for i in range(40):hold.update(i/10,{'erpm':1200,'tachometer':i})
|
||||||
|
before=hold.rotation_s
|
||||||
|
hold.update(4.5,{'erpm':1200,'tachometer':50})
|
||||||
|
self.assertEqual(hold.rotation_s,before)
|
||||||
|
hold.update(4.6,{'erpm':0,'tachometer':50})
|
||||||
|
self.assertEqual(hold.rotation_s,before)
|
||||||
|
_,_,error=hold.update(6.7,{'erpm':0,'tachometer':50})
|
||||||
|
self.assertIn('перестал удерживать',error)
|
||||||
|
|
||||||
|
def test_speed_without_changing_tachometer_never_starts_timer(self):
|
||||||
|
hold=SpeedHold(1200,30,0)
|
||||||
|
for i in range(151):_,_,error=hold.update(i/10,{'erpm':1200,'tachometer':0})
|
||||||
|
self.assertEqual(hold.rotation_s,0)
|
||||||
|
self.assertIsNone(hold.hold_started)
|
||||||
|
self.assertIn('не вышел',error)
|
||||||
|
|
||||||
|
def test_exact_native_wire_no_flash_no_can_and_hall_failure(self):
|
||||||
|
self.assertEqual(Decoder().feed(speed_packet(1200)),[bytes([8])+struct.pack('>i',1200)])
|
||||||
|
self.assertEqual(Decoder().feed(hall_packet()),[bytes([28])+struct.pack('>i',5000)])
|
||||||
|
wire=Decoder().feed(packet(dict.fromkeys(FIELDS,1.0)))[0]
|
||||||
|
self.assertEqual(wire[:5],bytes([48,0,0,1,0]))
|
||||||
|
self.assertEqual(len(wire),45)
|
||||||
|
failure=parse_result(bytes([28,255,255,255,100,255,255,255,255,1]))
|
||||||
|
self.assertFalse(failure['valid_six_states'])
|
||||||
|
self.assertEqual(failure['observed_states'],[3])
|
||||||
|
for value in (float('nan'),float('inf'),-3001,3001,True):
|
||||||
|
with self.assertRaises(ValueError):speed_packet(value)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Regression for deterministic package mtimes and root-created Python caches."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import py_compile
|
||||||
|
import runpy
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class UpgradeTests(unittest.TestCase):
|
||||||
|
def test_same_size_source_with_old_pyc_loads_new_version_after_installer_step(self):
|
||||||
|
clear = runpy.run_path(str(Path(__file__).parents[1] / "packaging/clear_runtime_cache.py"))["clear"]
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
root = Path(tmp); package = root / "runtime"; package.mkdir()
|
||||||
|
init = package / "__init__.py"; init.write_text('VERSION="0.1.0"\n'); os.utime(init,(0,0))
|
||||||
|
py_compile.compile(str(init),doraise=True)
|
||||||
|
init.write_text('VERSION="0.2.0"\n');os.utime(init,(0,0))
|
||||||
|
backup=root/'backup.json';backup.write_text('preserve')
|
||||||
|
code='import sys;sys.path.insert(0,sys.argv[1]);import runtime;print(runtime.VERSION)'
|
||||||
|
def version():return subprocess.check_output([sys.executable,'-I','-B','-c',code,str(root)],text=True).strip()
|
||||||
|
self.assertEqual(version(),'0.1.0')
|
||||||
|
clear(root);clear(root)
|
||||||
|
self.assertEqual(version(),'0.2.0')
|
||||||
|
self.assertEqual(backup.read_text(),'preserve')
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""VESC configuration storage on Core; serial execution remains on the board."""
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Immutable VESC backups, shared by Core and the packaged board plugin.
|
||||||
|
|
||||||
|
Storage has no hardware or restore authority. Versions survive transient
|
||||||
|
operation receipt cleanup, reboots and controller disconnects.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
|
||||||
|
def validate(value):
|
||||||
|
if not isinstance(value, dict) or len(json.dumps(value)) > 40000:
|
||||||
|
raise ValueError("Invalid configuration backup")
|
||||||
|
if value.get("schema") != "missioncore.vesc.config-backup/v1":
|
||||||
|
raise ValueError("Unsupported backup format")
|
||||||
|
if not re.fullmatch(r"op_[0-9a-f]{32}", value.get("operation_id", "")):
|
||||||
|
raise ValueError("Invalid backup identity")
|
||||||
|
identity = value["identity"]
|
||||||
|
uid = identity["uuid"]
|
||||||
|
if not re.fullmatch(r"[0-9a-f]{24}", uid) or int(uid, 16) == 0:
|
||||||
|
raise ValueError("Invalid controller identity")
|
||||||
|
expected = "vesc_" + hashlib.sha256(("uuid:" + uid).encode()).hexdigest()[:32]
|
||||||
|
if value.get("device_id") != expected:
|
||||||
|
raise ValueError("Backup controller mismatch")
|
||||||
|
if datetime.fromisoformat(value["observed_at"].replace("Z", "+00:00")).tzinfo is None:
|
||||||
|
raise ValueError("Backup time is missing its timezone")
|
||||||
|
if set(value["configs"]) != {"motor", "application"}:
|
||||||
|
raise ValueError("Incomplete backup")
|
||||||
|
for key, command in (("motor", 14), ("application", 17)):
|
||||||
|
blob = value["configs"][key]
|
||||||
|
data = base64.b64decode(blob["payload"], validate=True)
|
||||||
|
if (blob.get("encoding") != "base64" or not 5 <= len(data) <= 10000
|
||||||
|
or data[0] != command or blob["bytes"] != len(data)
|
||||||
|
or blob["sha256"] != hashlib.sha256(data).hexdigest()
|
||||||
|
or blob["signature_hex"] != data[1:5].hex()):
|
||||||
|
raise ValueError("Backup integrity check failed")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def metadata(sequence, value):
|
||||||
|
return {"sequence": sequence, "id": value["operation_id"], "device_id": value["device_id"],
|
||||||
|
"observed_at": value["observed_at"], "firmware": value["identity"]["version"],
|
||||||
|
"configs": {key: {"sha256": blob["sha256"], "bytes": blob["bytes"]}
|
||||||
|
for key, blob in value["configs"].items()}}
|
||||||
|
|
||||||
|
|
||||||
|
class Archive:
|
||||||
|
def __init__(self, root):
|
||||||
|
root = Path(root)
|
||||||
|
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
if root.is_symlink() or root.stat().st_mode & 0o077:
|
||||||
|
raise ValueError("Configuration archive must be private")
|
||||||
|
self.path = root / "configurations.sqlite3"
|
||||||
|
if self.path.is_symlink():
|
||||||
|
raise ValueError("Invalid configuration archive path")
|
||||||
|
with self.connect() as db:
|
||||||
|
db.execute("CREATE TABLE IF NOT EXISTS versions (seq INTEGER PRIMARY KEY AUTOINCREMENT, "
|
||||||
|
"node TEXT NOT NULL, device TEXT NOT NULL, operation TEXT NOT NULL, "
|
||||||
|
"body TEXT NOT NULL, UNIQUE(node,device,operation))")
|
||||||
|
self.path.chmod(0o600)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def connect(self):
|
||||||
|
db = sqlite3.connect(self.path, timeout=10)
|
||||||
|
try:
|
||||||
|
db.execute("PRAGMA synchronous=FULL")
|
||||||
|
with db:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def add(self, node, value):
|
||||||
|
validate(value)
|
||||||
|
body = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||||
|
with self.connect() as db:
|
||||||
|
existing = db.execute("SELECT body FROM versions WHERE node=? AND device=? AND operation=?",
|
||||||
|
(node, value["device_id"], value["operation_id"])).fetchone()
|
||||||
|
if existing:
|
||||||
|
if existing[0] != body:
|
||||||
|
raise ValueError("An immutable configuration version changed")
|
||||||
|
return
|
||||||
|
db.execute("INSERT INTO versions(node,device,operation,body) VALUES(?,?,?,?)",
|
||||||
|
(node, value["device_id"], value["operation_id"], body))
|
||||||
|
|
||||||
|
def listing(self, node, device, before=0):
|
||||||
|
if type(before) is not int or before < 0:
|
||||||
|
raise ValueError("Invalid archive cursor")
|
||||||
|
with self.connect() as db:
|
||||||
|
rows = db.execute("SELECT seq,body FROM versions WHERE node=? AND device=? "
|
||||||
|
"AND (?=0 OR seq<?) ORDER BY seq DESC LIMIT 51",
|
||||||
|
(node, device, before, before)).fetchall()
|
||||||
|
return {"items": [metadata(seq, json.loads(body)) for seq, body in rows[:50]],
|
||||||
|
"next": str(rows[49][0]) if len(rows) > 50 else None}
|
||||||
|
|
||||||
|
def read(self, node, device, operation):
|
||||||
|
with self.connect() as db:
|
||||||
|
row = db.execute("SELECT body FROM versions WHERE node=? AND device=? AND operation=?",
|
||||||
|
(node, device, operation)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("Configuration version not found")
|
||||||
|
return json.loads(row[0])
|
||||||
|
|
||||||
|
def export(self, node, after):
|
||||||
|
if type(after) is not int or after < 0:
|
||||||
|
raise ValueError("Invalid archive cursor")
|
||||||
|
with self.connect() as db:
|
||||||
|
rows = db.execute("SELECT seq,body FROM versions WHERE node=? AND seq>? ORDER BY seq LIMIT 4",
|
||||||
|
(node, after)).fetchall()
|
||||||
|
return {"after": after, "items": [{"sequence": seq, "backup": json.loads(body)} for seq, body in rows],
|
||||||
|
"next": rows[-1][0] if rows else after}
|
||||||
|
|
||||||
|
def receive(self, node, batch):
|
||||||
|
if not isinstance(batch, dict) or len(json.dumps(batch)) > 165000:
|
||||||
|
raise ValueError("Invalid archive batch")
|
||||||
|
items, last = batch.get("items"), batch.get("after")
|
||||||
|
if not isinstance(items, list) or len(items) > 4 or type(last) is not int or last < 0:
|
||||||
|
raise ValueError("Invalid archive batch")
|
||||||
|
for row in items:
|
||||||
|
seq = row["sequence"]
|
||||||
|
if type(seq) is not int or seq <= last:
|
||||||
|
raise ValueError("Invalid archive sequence")
|
||||||
|
self.add(node, row["backup"])
|
||||||
|
last = seq
|
||||||
|
if batch.get("next") != last:
|
||||||
|
raise ValueError("Invalid archive acknowledgement")
|
||||||
|
return last
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
@@ -36,6 +37,8 @@ class FleetRegistry:
|
|||||||
from .monitor import MonitorReceiver
|
from .monitor import MonitorReceiver
|
||||||
|
|
||||||
self.monitor = MonitorReceiver(root)
|
self.monitor = MonitorReceiver(root)
|
||||||
|
from .rover_control import RoverControl
|
||||||
|
self.rover_control = RoverControl()
|
||||||
self.trust = CoreTrust(root)
|
self.trust = CoreTrust(root)
|
||||||
path = root / "fleet.sqlite3"
|
path = root / "fleet.sqlite3"
|
||||||
if path.is_symlink():
|
if path.is_symlink():
|
||||||
@@ -50,6 +53,16 @@ class FleetRegistry:
|
|||||||
"node_id TEXT UNIQUE NOT NULL, body TEXT NOT NULL)"
|
"node_id TEXT UNIQUE NOT NULL, body TEXT NOT NULL)"
|
||||||
)
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
from k1link.device_plugins.vesc.archive import Archive
|
||||||
|
|
||||||
|
self.vesc_archive = Archive(root / "vesc-configurations")
|
||||||
|
# Preserve pre-archive releases' completed backups before the ordinary
|
||||||
|
# short-lived operation receipts are pruned.
|
||||||
|
for row in self.rows():
|
||||||
|
for receipt in row.get("sensor_commands", {}).values():
|
||||||
|
if receipt.get("state") == "complete" and receipt.get("command", {}).get("action_id") == "vesc.config.backup":
|
||||||
|
with suppress(ValueError, KeyError, TypeError):
|
||||||
|
self.vesc_archive.add(row["node_id"], receipt["result"])
|
||||||
self.previews: dict[str, dict] = {}
|
self.previews: dict[str, dict] = {}
|
||||||
self.listeners: dict[str, object] = {}
|
self.listeners: dict[str, object] = {}
|
||||||
self.stop = threading.Event()
|
self.stop = threading.Event()
|
||||||
@@ -359,7 +372,11 @@ class FleetRegistry:
|
|||||||
if not isinstance(key, Ed25519PublicKey):
|
if not isinstance(key, Ed25519PublicKey):
|
||||||
return 403, {"error": "Unauthorized Node"}
|
return 403, {"error": "Unauthorized Node"}
|
||||||
node_id = public_id("node_", key)
|
node_id = public_id("node_", key)
|
||||||
|
entered = time.monotonic()
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
waited = time.monotonic() - entered
|
||||||
|
if waited > .025:
|
||||||
|
logging.getLogger(__name__).warning("fleet timing path=%s lock_ms=%.1f", path, waited*1000)
|
||||||
row = next((item for item in self.rows() if item["node_id"] == node_id), None)
|
row = next((item for item in self.rows() if item["node_id"] == node_id), None)
|
||||||
if (
|
if (
|
||||||
row is None
|
row is None
|
||||||
@@ -390,7 +407,7 @@ class FleetRegistry:
|
|||||||
)
|
)
|
||||||
self.save(row)
|
self.save(row)
|
||||||
return 200, {"ok": True}
|
return 200, {"ok": True}
|
||||||
if path != "/v1/node/heartbeat":
|
if path not in ("/v1/node/heartbeat", "/v1/node/rover", "/v1/node/rover-stream"):
|
||||||
return 404, {"error": "Unknown operation"}
|
return 404, {"error": "Unknown operation"}
|
||||||
# Endpoint revisions prevent an old in-flight heartbeat or lost ack
|
# Endpoint revisions prevent an old in-flight heartbeat or lost ack
|
||||||
# from reversing a migration. Use the actual listener, not a claimed IP.
|
# from reversing a migration. Use the actual listener, not a claimed IP.
|
||||||
@@ -411,11 +428,33 @@ class FleetRegistry:
|
|||||||
row["core_address"] = address
|
row["core_address"] = address
|
||||||
row["binding"]["endpoint"] = value["core_endpoint"]
|
row["binding"]["endpoint"] = value["core_endpoint"]
|
||||||
row["binding"]["endpoint_revision"] = revision
|
row["binding"]["endpoint_revision"] = revision
|
||||||
|
if path in ("/v1/node/rover", "/v1/node/rover-stream"):
|
||||||
|
if row["enrollment"] != "paired":
|
||||||
|
return 410, {"error": "Pairing incomplete"}
|
||||||
|
if path.endswith("-stream"):
|
||||||
|
relay = value.get("relay_id")
|
||||||
|
if not isinstance(relay, str) or len(relay) != 32:
|
||||||
|
return 400, {"error": "Invalid relay"}
|
||||||
|
return 200, self.rover_control.stream(node_id, relay)
|
||||||
|
return 200, self.rover_control.exchange(node_id, value)
|
||||||
binding = ExecutionBinding.model_validate(value["execution_binding"])
|
binding = ExecutionBinding.model_validate(value["execution_binding"])
|
||||||
if binding.node_id != node_id or binding.platform.value != "linux":
|
if binding.node_id != node_id or binding.platform.value != "linux":
|
||||||
return 400, {"error": "Invalid Node inventory"}
|
return 400, {"error": "Invalid Node inventory"}
|
||||||
sensor_state = sensors.validate_inventory(value, node_id)
|
sensor_state = sensors.validate_inventory(value, node_id)
|
||||||
sensor_response = sensors.heartbeat(row, value)
|
sensor_response = sensors.heartbeat(row, value)
|
||||||
|
archive_ack = None
|
||||||
|
archive_started = time.monotonic()
|
||||||
|
try:
|
||||||
|
if "vesc_configurations" in value:
|
||||||
|
archive_ack = self.vesc_archive.receive(node_id, value["vesc_configurations"])
|
||||||
|
for receipt in row.get("sensor_commands", {}).values():
|
||||||
|
if receipt.get("state") == "complete" and receipt.get("command", {}).get("action_id") == "vesc.config.backup":
|
||||||
|
self.vesc_archive.add(node_id, receipt["result"])
|
||||||
|
except (ValueError, KeyError, TypeError, sqlite3.Error, OSError):
|
||||||
|
# No archive acknowledgement until durable storage succeeds.
|
||||||
|
# Archive failure must never delay a control/stop response.
|
||||||
|
archive_ack = None
|
||||||
|
archive_elapsed = time.monotonic() - archive_started
|
||||||
enrollment_response = self.device_enrollment.heartbeat(row, value)
|
enrollment_response = self.device_enrollment.heartbeat(row, value)
|
||||||
host = value.get("host")
|
host = value.get("host")
|
||||||
if (
|
if (
|
||||||
@@ -441,7 +480,12 @@ class FleetRegistry:
|
|||||||
"until": time.time() + 3600,
|
"until": time.time() + 3600,
|
||||||
}
|
}
|
||||||
row["binding"]["client_pem"] = self.trust.leaf(node_id, key)
|
row["binding"]["client_pem"] = self.trust.leaf(node_id, key)
|
||||||
|
save_started = time.monotonic()
|
||||||
self.save(row)
|
self.save(row)
|
||||||
|
save_elapsed = time.monotonic() - save_started
|
||||||
|
if archive_elapsed > .025 or save_elapsed > .025:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"fleet heartbeat timing archive_ms=%.1f save_ms=%.1f", archive_elapsed*1000, save_elapsed*1000)
|
||||||
monitor_ack = None
|
monitor_ack = None
|
||||||
try:
|
try:
|
||||||
monitor_ack = self.monitor.submit(node_id, value.get("monitor"))
|
monitor_ack = self.monitor.submit(node_id, value.get("monitor"))
|
||||||
@@ -449,6 +493,7 @@ class FleetRegistry:
|
|||||||
# Telemetry persistence failure must not block device control.
|
# Telemetry persistence failure must not block device control.
|
||||||
pass
|
pass
|
||||||
return 200, {
|
return 200, {
|
||||||
|
"vesc_configurations_ack": archive_ack,
|
||||||
"monitor_ack": monitor_ack,
|
"monitor_ack": monitor_ack,
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"client_pem": row["binding"]["client_pem"],
|
"client_pem": row["binding"]["client_pem"],
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ ACTIONS = {
|
|||||||
"files.list",
|
"files.list",
|
||||||
"recovery.configure",
|
"recovery.configure",
|
||||||
"power.wake",
|
"power.wake",
|
||||||
|
"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",
|
||||||
}
|
}
|
||||||
MAX_INVENTORY_ITEMS = 500
|
MAX_INVENTORY_ITEMS = 500
|
||||||
MAX_SENSOR_STATE_BYTES = 3 * 1024 * 1024
|
MAX_SENSOR_STATE_BYTES = 3 * 1024 * 1024
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ class NodeChannelServer(ThreadingHTTPServer):
|
|||||||
|
|
||||||
class NodeChannelHandler(BaseHTTPRequestHandler):
|
class NodeChannelHandler(BaseHTTPRequestHandler):
|
||||||
protocol_version = "HTTP/1.1"
|
protocol_version = "HTTP/1.1"
|
||||||
|
# Header and JSON writes must not wait for a delayed ACK on the paired
|
||||||
|
# command channel. StreamRequestHandler applies TCP_NODELAY to the socket.
|
||||||
|
disable_nagle_algorithm = True
|
||||||
|
|
||||||
def log_message(self, *_args):
|
def log_message(self, *_args):
|
||||||
pass
|
pass
|
||||||
@@ -74,6 +77,31 @@ class NodeChannelHandler(BaseHTTPRequestHandler):
|
|||||||
except (ValueError, KeyError, TypeError):
|
except (ValueError, KeyError, TypeError):
|
||||||
self.close_connection = True
|
self.close_connection = True
|
||||||
status, data = 400, {"error": "Invalid request"}
|
status, data = 400, {"error": "Invalid request"}
|
||||||
|
if self.path == "/v1/node/rover-stream" and status == 200:
|
||||||
|
self.close_connection = True
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "application/x-ndjson")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Connection", "close")
|
||||||
|
self.end_headers()
|
||||||
|
try:
|
||||||
|
while not self.server.registry.stop.is_set():
|
||||||
|
self.wfile.write(json.dumps(data, allow_nan=False).encode()+b"\n")
|
||||||
|
if not data.get("watch"):
|
||||||
|
break
|
||||||
|
# Every frame rechecks the same certificate and binding.
|
||||||
|
# No queued intent and no lease renewal from a keepalive.
|
||||||
|
self.server.registry.rover_control.wait_for_intent(
|
||||||
|
value["node_id"], value["relay_id"], data.get("command"))
|
||||||
|
status, data = self.server.registry.receive(
|
||||||
|
self.connection.getpeercert(binary_form=True), self.path,
|
||||||
|
value, address=self.server.address,
|
||||||
|
)
|
||||||
|
if status != 200:
|
||||||
|
break
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return
|
||||||
encoded = json.dumps(data).encode()
|
encoded = json.dumps(data).encode()
|
||||||
self.send_response(status)
|
self.send_response(status)
|
||||||
self.send_header("Content-Type", "application/json")
|
self.send_header("Content-Type", "application/json")
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router
|
|||||||
from k1link.web.artifact_health_api import build_artifact_health_router
|
from k1link.web.artifact_health_api import build_artifact_health_router
|
||||||
from k1link.web.compute_contour_api import build_compute_contour_router
|
from k1link.web.compute_contour_api import build_compute_contour_router
|
||||||
from k1link.web.fleet_api import router as fleet_router
|
from k1link.web.fleet_api import router as fleet_router
|
||||||
|
from k1link.web.rover_api import router as rover_router
|
||||||
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
from k1link.web.device_plugin_composition import load_installed_device_plugins
|
||||||
from k1link.web.e30_engineering_api import build_e30_engineering_router
|
from k1link.web.e30_engineering_api import build_e30_engineering_router
|
||||||
from k1link.web.e30_human_review_api import build_e30_human_review_router
|
from k1link.web.e30_human_review_api import build_e30_human_review_router
|
||||||
@@ -1003,6 +1004,7 @@ app.add_middleware(ResponseCompressionMiddleware, minimum_size=1_024, compressle
|
|||||||
|
|
||||||
|
|
||||||
app.include_router(fleet_router)
|
app.include_router(fleet_router)
|
||||||
|
app.include_router(rover_router)
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import asyncio
|
|||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from typing import Annotated
|
from typing import Annotated, Literal
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, StrictBool
|
||||||
|
|
||||||
from k1link.fleet.registry import FleetRegistry
|
from k1link.fleet.registry import FleetRegistry
|
||||||
from k1link.fleet.trust import PairingError
|
from k1link.fleet.trust import PairingError
|
||||||
@@ -51,6 +51,42 @@ class AddRequest(BaseModel):
|
|||||||
router = APIRouter(prefix="/api/v1/fleet", tags=["fleet"])
|
router = APIRouter(prefix="/api/v1/fleet", tags=["fleet"])
|
||||||
|
|
||||||
|
|
||||||
|
class BoardLayoutChange(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
section: Literal["computer", "settings", "devices"]
|
||||||
|
open: StrictBool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{vehicle_id}/board-layout")
|
||||||
|
def board_layout_read(vehicle_id: str, response: Response,
|
||||||
|
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
||||||
|
from k1link.fleet.board_layout import read
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
try:
|
||||||
|
with fleet.lock:
|
||||||
|
fleet.find(vehicle_id)
|
||||||
|
return read(fleet.root, vehicle_id)
|
||||||
|
except PairingError as error:
|
||||||
|
raise HTTPException(404, str(error)) from None
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise HTTPException(500, "Не удалось прочитать раскладку аппарата.") from None
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{vehicle_id}/board-layout")
|
||||||
|
def board_layout_patch(vehicle_id: str, body: BoardLayoutChange, response: Response,
|
||||||
|
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
||||||
|
from k1link.fleet.board_layout import update
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
try:
|
||||||
|
with fleet.lock:
|
||||||
|
fleet.find(vehicle_id)
|
||||||
|
return update(fleet.root, vehicle_id, body.section, body.open)
|
||||||
|
except PairingError as error:
|
||||||
|
raise HTTPException(404, str(error)) from None
|
||||||
|
except (OSError, ValueError):
|
||||||
|
raise HTTPException(500, "Не удалось сохранить раскладку аппарата.") from None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{vehicle_id}/monitor")
|
@router.get("/{vehicle_id}/monitor")
|
||||||
def board_monitor(vehicle_id: str, response: Response,
|
def board_monitor(vehicle_id: str, response: Response,
|
||||||
fleet: Annotated[FleetRegistry, Depends(local_operator)],
|
fleet: Annotated[FleetRegistry, Depends(local_operator)],
|
||||||
@@ -141,6 +177,30 @@ def sensor_command(
|
|||||||
raise HTTPException(409, str(error)) from None
|
raise HTTPException(409, str(error)) from None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{vehicle_id}/devices/{device_id}/configurations")
|
||||||
|
def vesc_configurations(vehicle_id: str, device_id: str, response: Response,
|
||||||
|
fleet: Annotated[FleetRegistry, Depends(local_operator)], before: int = 0):
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
try:
|
||||||
|
with fleet.lock:
|
||||||
|
node = fleet.find(vehicle_id)["node_id"]
|
||||||
|
return fleet.vesc_archive.listing(node, device_id, before)
|
||||||
|
except (PairingError, ValueError):
|
||||||
|
raise HTTPException(404, "История конфигураций недоступна.") from None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{vehicle_id}/devices/{device_id}/configurations/{version_id}")
|
||||||
|
def vesc_configuration(vehicle_id: str, device_id: str, version_id: str, response: Response,
|
||||||
|
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
||||||
|
response.headers["Cache-Control"] = "no-store"
|
||||||
|
try:
|
||||||
|
with fleet.lock:
|
||||||
|
node = fleet.find(vehicle_id)["node_id"]
|
||||||
|
return fleet.vesc_archive.read(node, device_id, version_id)
|
||||||
|
except (PairingError, ValueError):
|
||||||
|
raise HTTPException(404, "Версия конфигурации не найдена.") from None
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{vehicle_id}/devices/operations/{operation_id}")
|
@router.get("/{vehicle_id}/devices/operations/{operation_id}")
|
||||||
def sensor_operation(
|
def sensor_operation(
|
||||||
vehicle_id: str, operation_id: str, fleet: Annotated[FleetRegistry, Depends(local_operator)]
|
vehicle_id: str, operation_id: str, fleet: Annotated[FleetRegistry, Depends(local_operator)]
|
||||||
|
|||||||
@@ -84,6 +84,25 @@ def cert(row):
|
|||||||
serialization.Encoding.DER
|
serialization.Encoding.DER
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_rover_stream_requires_current_pairing_and_binding(setup):
|
||||||
|
fleet, _, _, _ = setup
|
||||||
|
public, _ = create(setup)
|
||||||
|
fleet.advance(public["id"])
|
||||||
|
row = fleet.find(public["id"])
|
||||||
|
body = {"schema": SCHEMA, "node_id": row["node_id"],
|
||||||
|
"binding_id": row["binding"]["binding_id"], "relay_id": "a"*32}
|
||||||
|
certificate = cert(row)
|
||||||
|
status, result = fleet.receive(certificate, "/v1/node/rover-stream", body)
|
||||||
|
assert status == 200
|
||||||
|
assert result['command'] is None
|
||||||
|
assert result['control_clock']['instance'] == fleet.rover_control.clock_id
|
||||||
|
status, _ = fleet.receive(certificate, "/v1/node/rover-stream", {**body, "binding_id": "other"})
|
||||||
|
assert status == 410
|
||||||
|
row['enrollment'] = 'revoked'
|
||||||
|
fleet.save(row)
|
||||||
|
status, _ = fleet.receive(certificate, "/v1/node/rover-stream", body)
|
||||||
|
assert status == 410
|
||||||
|
|
||||||
|
|
||||||
def test_migrated_heartbeat_preserves_identity_and_old_reply_cannot_reverse(setup):
|
def test_migrated_heartbeat_preserves_identity_and_old_reply_cannot_reverse(setup):
|
||||||
fleet, _, _, _ = setup
|
fleet, _, _, _ = setup
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import base64
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.device_plugins.vesc.archive import Archive
|
||||||
|
|
||||||
|
|
||||||
|
def backup():
|
||||||
|
uid = "01" * 12
|
||||||
|
configs = {}
|
||||||
|
for name, command in (("motor", 14), ("application", 17)):
|
||||||
|
raw = bytes([command]) + b"test-synthetic-config"
|
||||||
|
configs[name] = {"encoding": "base64", "payload": base64.b64encode(raw).decode(),
|
||||||
|
"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest(),
|
||||||
|
"signature_hex": raw[1:5].hex()}
|
||||||
|
return {"schema": "missioncore.vesc.config-backup/v1", "operation_id": "op_" + uuid4().hex,
|
||||||
|
"device_id": "vesc_" + hashlib.sha256(("uuid:" + uid).encode()).hexdigest()[:32],
|
||||||
|
"identity": {"uuid": uid, "version": "6.06"}, "configs": configs,
|
||||||
|
"observed_at": datetime.now(UTC).isoformat()}
|
||||||
|
|
||||||
|
|
||||||
|
def test_immutable_versions_survive_restart_and_do_not_cross_node_scope(tmp_path):
|
||||||
|
archive = Archive(tmp_path / "archive")
|
||||||
|
value = backup()
|
||||||
|
archive.add("node-a", value)
|
||||||
|
archive.add("node-a", copy.deepcopy(value))
|
||||||
|
reopened = Archive(tmp_path / "archive")
|
||||||
|
assert len(reopened.listing("node-a", value["device_id"])["items"]) == 1
|
||||||
|
assert reopened.read("node-a", value["device_id"], value["operation_id"]) == value
|
||||||
|
assert reopened.listing("node-b", value["device_id"])["items"] == []
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
reopened.read("node-b", value["device_id"], value["operation_id"])
|
||||||
|
changed = copy.deepcopy(value)
|
||||||
|
changed["observed_at"] = datetime.now(UTC).isoformat()
|
||||||
|
with pytest.raises(ValueError, match="immutable"):
|
||||||
|
reopened.add("node-a", changed)
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_or_misattributed_payload_is_never_archived(tmp_path):
|
||||||
|
archive = Archive(tmp_path / "archive")
|
||||||
|
value = backup()
|
||||||
|
value["configs"]["motor"]["sha256"] = "0" * 64
|
||||||
|
with pytest.raises(ValueError, match="integrity"):
|
||||||
|
archive.add("a", value)
|
||||||
|
value = backup()
|
||||||
|
value["device_id"] = "vesc_" + "0" * 32
|
||||||
|
with pytest.raises(ValueError, match="mismatch"):
|
||||||
|
archive.add("a", value)
|
||||||
|
assert archive.export("a", 0)["items"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_bounded_replication_replays_without_losing_versions_and_paginates(tmp_path):
|
||||||
|
board, core = Archive(tmp_path / "board"), Archive(tmp_path / "core")
|
||||||
|
values = [backup() for _ in range(53)]
|
||||||
|
for value in values:
|
||||||
|
board.add("local", value)
|
||||||
|
after = 0
|
||||||
|
while True:
|
||||||
|
batch = board.export("local", after)
|
||||||
|
assert len(batch["items"]) <= 4
|
||||||
|
assert core.receive("paired-node", batch) == batch["next"]
|
||||||
|
assert core.receive("paired-node", batch) == batch["next"]
|
||||||
|
if batch["next"] == after:
|
||||||
|
break
|
||||||
|
after = batch["next"]
|
||||||
|
page = core.listing("paired-node", values[0]["device_id"])
|
||||||
|
assert len(page["items"]) == 50 and page["next"]
|
||||||
|
older = core.listing("paired-node", values[0]["device_id"], int(page["next"]))
|
||||||
|
assert len(older["items"]) == 3 and older["next"] is None
|
||||||
|
assert len({item["id"] for item in page["items"] + older["items"]}) == 53
|
||||||
Reference in New Issue
Block a user