feat(vesc): integrate native calibration diagnostics and configuration archives
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user