feat(fleet): preserve operator VESC integration before final driver merge
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
# VESC onboard plugin — profile 0.4.0
|
||||
|
||||
**Native release in preparation:** 0.4.0 / Node 0.8.30-1 uses the actual VESC Tool 7.00 C++ engine for USB ownership, commands, configuration codecs and Hall measurement. Installation and physical qualification are still pending.
|
||||
|
||||
**Previous release status, 2026-09-23:** 0.3.0 / Node 0.8.29-1 is an uninstalled,
|
||||
withheld experiment. The owner requires the actual upstream VESC Tool as the
|
||||
onboard engine behind Mission Core controls. Do not deploy the separate Python
|
||||
Hall workflow below as the intended calibration backend. Hardware currently
|
||||
runs 0.2.5 / Node 0.8.28-1. The verified native adapter and canonical procedure
|
||||
are documented in [the native engine plan](../../docs/node/18_VESC_TOOL_NATIVE_BACKEND.md).
|
||||
|
||||
This increment discovers USB candidates, reads protocol identity and telemetry,
|
||||
saves opaque motor/application configuration backups and provides bounded
|
||||
identification pulses and user-assigned drive positions. It is not complete
|
||||
VESC Tool functional parity and does not implement continuous driving,
|
||||
configuration writes, calibration, CAN forwarding, custom configuration,
|
||||
firmware update or script execution.
|
||||
|
||||
## Placement and ownership
|
||||
|
||||
One `mission-core-vesc` service owns the serial descriptors. The Node model
|
||||
registry admits only its Unix socket and explicit domain operations. Local Node UI
|
||||
and paired Core use the same `SensorUiContribution` and operation journal.
|
||||
`VESC Tool` is a text action in the existing device row, opening the existing
|
||||
Detail slot. No workspace, root, visual control or motor icon was added.
|
||||
|
||||
Candidates must match `0483:5740` and `ChibiOS/RT Virtual COM Port`. USB serial
|
||||
is never their durable identity: every candidate starts with a transport-local
|
||||
attachment including USB devnum. Only a valid firmware reply with a nonzero
|
||||
12-byte UUID establishes a stable ID. Duplicate protocol UUIDs remain separate
|
||||
provisional rows and cannot receive read operations. Replug renews the session.
|
||||
Names and physical Left/Right roles must not be inferred from tty numbering.
|
||||
|
||||
The read-only serial path admits command bytes 0, 4, 14, 17, 31 and 62, with no
|
||||
arguments: identity, values, motor config, application config, decoded PPM and
|
||||
CAN discovery. The separate fixed test commands are described below. No
|
||||
keepalive, detect, config write, terminal or arbitrary packet API exists. Firmware identity is checked again before each operation;
|
||||
backup verifies it at the end as well. CRC/framing/length and response limits
|
||||
are enforced. Every port is held with flock/TIOCEXCL; an already running external
|
||||
tool must release it before this service is prepared.
|
||||
|
||||
## Compatibility and backup
|
||||
|
||||
Wire reference is the official [VESC Tool source](https://github.com/vedderb/vesc_tool/tree/dc53c658cbb89a947246034f7a00149cf79abdfc),
|
||||
specifically packet.cpp, commands.cpp and datatypes.h. The reference identifies
|
||||
itself as test version 7.01; it is not installed by this profile. The first
|
||||
reader implements the common values prefix for motor firmware major 5–7 and
|
||||
hardware type 0 (or an older reply without hardware type). Exact board/firmware
|
||||
qualification remains a hardware acceptance result, not a claim from a version
|
||||
number alone. Unknown layouts may identify themselves but are not decoded.
|
||||
|
||||
Config replies are preserved as received, including command byte and signature,
|
||||
with SHA-256 and identity/version metadata. Their fields are not decoded without
|
||||
the exact firmware schema. The backups are not XML files importable into Tool,
|
||||
and this profile provides no restore action. Motor and application reads are
|
||||
sequential, not an atomic snapshot against other controller interfaces.
|
||||
|
||||
Read receipts and backups live privately in `/var/lib/mission-core-vesc`.
|
||||
Operation IDs bind the exact request; retries retrieve receipts. An interrupted
|
||||
request remains unknown rather than being silently replayed. This service's
|
||||
host storage is bounded and does not delete backups to make space. GUI telemetry
|
||||
is an explicitly requested timestamped snapshot, not a control loop or a
|
||||
waveform recorder. ERPM is not mechanical shaft RPM.
|
||||
|
||||
## Packaging and first preparation
|
||||
|
||||
Node 0.8.22-1 carries this plugin from `packaging/payload.py`; no ad-hoc files,
|
||||
global Python packages or separate Qt installation are required. The source
|
||||
build pin is Design Guideline `8dd9190573d6616024ef01b9b34bf90b72960f44`.
|
||||
The existing versioned owner installer installs Node. Its local Ubuntu sudo
|
||||
prompt belongs to the owner; no password is handled by Core or an agent.
|
||||
|
||||
The device's Prepare action starts only
|
||||
`mission-core-node-vesc-prepare.service`, via the existing Node polkit rule.
|
||||
The shipped profile creates the dedicated account, installs the exact-candidate
|
||||
udev rule, applies it to matching attached ttys and starts the read service.
|
||||
It does not add the operator or Node to dialout or disable ModemManager globally.
|
||||
Node retains PrivateDevices. The adapter has no capabilities, private network,
|
||||
read-only system files, bounded memory/tasks and a cdc_acm device cgroup rule.
|
||||
`modprobe@cdc_acm` ensures the named ttyACM group exists before cgroup resolution
|
||||
on cold boot; [systemd DeviceAllow](https://www.freedesktop.org/software/systemd/man/latest/systemd.resource-control.html#DeviceAllow=)
|
||||
uses group names from `/proc/devices`, not `char-<major-number>`.
|
||||
|
||||
Before package replacement, the installer refuses an active preparation and
|
||||
stops the reader. A prepared profile restarts through its shipped job afterward.
|
||||
Removal stops/disables the service and removes only a byte-matching owned udev
|
||||
rule. Private backups and account identity are retained. Rolling Node back to
|
||||
0.8.21-3 disables VESC support; do not claim an older package can restore the new
|
||||
UI or firmware configuration. No VESC firmware was changed by this profile.
|
||||
|
||||
## Validation
|
||||
|
||||
Synthetic tests cover every denied transmit byte, fragmented/coalesced/corrupt
|
||||
frames, missing identities, scales, duplicate IDs, two independent attachments,
|
||||
replug sessions, receipts, config hashes and denied write actions. Node tests
|
||||
cover UUID promotion and preservation of camera guards. Full Node and Core
|
||||
checks are required alongside Linux package qualification and actual hardware
|
||||
reads. Test success is not clean-Ubuntu or motor calibration acceptance.
|
||||
|
||||
On the Mini, `qmake`, `qmake6` and `cmake` were absent in the read-only build
|
||||
inventory. This first increment therefore uses the bounded reader fallback from
|
||||
the implementation plan. Headless extraction of the full upstream engine has
|
||||
not been demonstrated; it remains a separate build/compatibility task for the
|
||||
remaining Tool feature matrix.
|
||||
|
||||
|
||||
## 0.2.1 — per-controller identification pulse and immutable archive
|
||||
|
||||
Node 0.8.24-1 adds a fixed 2 A pulse on one selected VESC, with a duration
|
||||
chosen from 1.5, 5 or 10 seconds. Discovery and session validation accept up
|
||||
to 128 directly attached controllers per board; there is no two-motor role enum.
|
||||
Names are the existing UUID-bound device names, scoped to their board.
|
||||
Synthetic 1/6/10-controller tests do not establish physical USB capacity.
|
||||
This is not a vehicle drive controller, completed RC arbiter, or calibration.
|
||||
All attached UUID sessions, firmware 5.02 / 75_300_R2, FOC, PPM Duty Cycle,
|
||||
neutral input for one second and a zero-current failsafe are mandatory.
|
||||
Official 5.02 schemas are included unchanged with their upstream license.
|
||||
Before any torque, motor and application configurations of every attached
|
||||
controller are archived durably, and each CAN segment is checked for unmanaged
|
||||
peers. Attachments are rechecked throughout the pulse. Each
|
||||
controller receives its own 250 ms volatile app-output lease (CAN-forward
|
||||
flag false). Other controllers receive zero current; the target receives only 2 A.
|
||||
A receiver command, serial fault, telemetry limit or local Stop ends the test.
|
||||
No config/firmware write, arbitrary current, arbitrary packet or CAN broadcast
|
||||
is exposed. Replaying an operation ID never repeats physical work.
|
||||
|
||||
Firmware PPM pulses reset the global timeout even during app-output pause;
|
||||
therefore the design relies on the expiring local app-output lease returning
|
||||
to the existing PPM neutral/missing-pulse behavior, not solely on USB timeout.
|
||||
RC activity latches further test requests until an explicit neutral release.
|
||||
This is a test-session guard, not continuous production RC takeover monitoring.
|
||||
Neutral PPM alone cannot prove transmitter/link availability. Host-independent
|
||||
lease behavior follows the pinned firmware source; real stop/failsafe
|
||||
qualification is still required and must not be claimed from synthetic tests.
|
||||
|
||||
Versions live in a private SQLite archive, are replicated via existing pairing
|
||||
with ACK after durable Core storage, and remain downloadable when a controller
|
||||
is offline. The Node and Core use the same detail component. The native VESC
|
||||
Tool 7.00 engineering build is separate: it has not acquired serial ownership
|
||||
or been integrated for calibration.
|
||||
|
||||
Package upgrades remove only generated bytecode below the installed VESC
|
||||
payload before preparation starts. Deterministic source mtimes can otherwise
|
||||
validate stale same-size `.pyc` files even with `python -B`; a regression test
|
||||
reproduces the failed 0.8.23-1 upgrade and verifies this installer-owned fix.
|
||||
|
||||
## 0.2.2 — entered test values and drive positions
|
||||
|
||||
Node 0.8.25-1 carries numeric current and duration fields. The board advertises
|
||||
and independently enforces 0.5–5 A and 0.5–10 seconds for this identification
|
||||
mode. These are software bounds, not controller or motor nameplate
|
||||
ratings. Existing 60 A motor / 55 A battery configuration is not evidence that
|
||||
the rig can safely sustain those currents. Above 2 A the test coasts at
|
||||
400 electrical RPM and resumes current below 250, with a separate 800 ERPM
|
||||
abort threshold. Requested current is also bounded by the read configuration.
|
||||
Receipts distinguish successfully sent current commands from sampled cycles
|
||||
that ended before transmission. These bounds do not admit maximum-power tests.
|
||||
|
||||
The existing VESC detail offers one board-wide drive profile: 1×1 means left
|
||||
and right (two motors); 2×2 means left front, left rear, right front and right
|
||||
rear (four motors). Directions are relative to forward vehicle motion. Position
|
||||
is manually assigned after physical identification and persists by controller
|
||||
UUID, not USB address. The same component is used on Node and paired Core.
|
||||
Revision checks prevent stale updates; an occupied position cannot be stolen,
|
||||
and rear assignments must be explicitly removed before shrinking to 1×1.
|
||||
Changing profile/position is local metadata and sends no VESC command.
|
||||
Discovery itself remains independent of these two admitted layout presets.
|
||||
|
||||
## 0.2.3 — explain blocked tests and retain stop evidence
|
||||
|
||||
Numeric fields show validation errors beside invalid values. The action area
|
||||
explains why Start is unavailable, including confirmation reset after a test.
|
||||
A telemetry-bound stop retains the triggering sample and its field, measured
|
||||
value and unchanged bounds. A completed current pulse never proves physical
|
||||
rotation; the owner must observe the motor before assigning its position.
|
||||
|
||||
## 0.2.4 — gradual current in identification mode
|
||||
|
||||
The confirmed right motor crossed800ERPM within0.245s of a5A step. The next
|
||||
profile starts and resumes at0.5A, increasing by approximately1A/s toward the
|
||||
entered ceiling. Soft coasting triggers at200ERPM or4%PWM and only resumes
|
||||
below100ERPM and2%PWM. It applies to every allowed current, while the hard
|
||||
abort thresholds remain unchanged. This is an identification pulse governor,
|
||||
not a vehicle speed controller. Command receipts record actual requested
|
||||
current per sample, including ramp/coasting;5A input is a ceiling.
|
||||
|
||||
|
||||
## 0.2.5 — entered current up to 30 A and time up to 30 seconds
|
||||
|
||||
At the owner's request the raised-rig test accepts 0.5–30 A and 0.5–30 s.
|
||||
These are software admission bounds, not motor/controller nameplate ratings.
|
||||
The selected controller's configured motor and input current limits still bind.
|
||||
Positive current starts at0.5A and ramps by2A/s; the entered value is a current
|
||||
ceiling, not a speed request. No configuration or firmware write is performed.
|
||||
|
||||
The former200ERPM/4%PWM coast/restart loop was causing the observed right-motor
|
||||
steps and pauses. This increment removes that automatic cycling. Current is
|
||||
maintained until time expires, Stop/RC/link interruption, or a telemetry limit.
|
||||
The test ends at6000ERPM or25%PWM (or a lower configured speed/duty limit),
|
||||
without automatic re-acceleration. A no-load motor can reach a speed limit
|
||||
before the entered time:30seconds is the maximum duration, not a promise of
|
||||
constant-speed rotation. Current feedback has bounded overshoot tolerance,
|
||||
capped by the configured motor current limit. Fault, voltage, temperature,
|
||||
identity/topology, neutral RC, expiring per-device leases and release checks
|
||||
remain active.
|
||||
|
||||
Above5A, a2-second interval without at least three net electrical tachometer
|
||||
steps at60ERPM ends the test. This uses the existing Hall/FOC estimate; it is
|
||||
not independent mechanical feedback or certified thermal protection, especially
|
||||
with a damaged sensor connection. It prevents continuing to raise commanded
|
||||
current while the reported rotor remains stationary. Sustained vehicle control,
|
||||
calibration and native VESC Tool parity remain separate unfinished work.
|
||||
|
||||
The owner clarified the diagnostic roles: LEFT is the problematic motor;
|
||||
RIGHT works normally from RC and is physically assigned right.1. Short steps
|
||||
in the earlier Core test must not be recorded as a right-motor defect. Every
|
||||
powered engineering experiment is coordinated with the owner at launch time.
|
||||
|
||||
|
||||
## 0.3.0 — measured speed hold and native Hall measurement
|
||||
|
||||
`vesc.motor.run` calls firmware speed PID (`COMM_SET_RPM`), ramps the setpoint
|
||||
at 600 ERPM/s and counts time only after one second within 15% of the requested
|
||||
speed with a changing VESC tachometer. The UI exposes speed 300–3000 ERPM,
|
||||
motor-current ceiling 0.5–30 A and **rotation time** 0.5–30 s. Startup is bounded
|
||||
by 15 s; losing speed for 2 s ends the run. No automatic restart. FOC telemetry
|
||||
is not an independent physical encoder; the operator compares visible motion.
|
||||
The old `vesc.motor.pulse` action remains compatible but is not used by this UI.
|
||||
|
||||
Before a speed command, `COMM_SET_MCCONF_TEMP` applies current scaling to both
|
||||
positive and braking motor current. Store/CAN/divide flags are false. ACK and
|
||||
full configuration readback precede torque. A durable UUID-bound journal exists
|
||||
before the first write. Cleanup restores the exact original configuration,
|
||||
verified byte-for-byte. A lost ACK, disconnection or process interruption leaves
|
||||
the journal pending; discovery retries restoration only with a matching identity,
|
||||
zero current, neutral receiver and unchanged unrelated configuration. An external
|
||||
configuration change is never overwritten. No flash or application write is used.
|
||||
The operation transport permits 90 s, including preflight and acceleration.
|
||||
|
||||
`vesc.hall.measure` is the native FW 5.02 `COMM_DETECT_HALL_FOC` procedure also
|
||||
used by VESC Tool. It uses fixed 5 A, sweeps three electrical turns forward and
|
||||
three backwards, returns the observed table and restores its prior configuration.
|
||||
The table is **not automatically applied**. The firmware locks `mc_interface`
|
||||
during this approximately 12 s cycle; USB current-zero and receiver input cannot
|
||||
interrupt it. The UI requires a separate observed-rig/physical-power-cut
|
||||
acknowledgement and explicitly describes this limitation. Unknown completion
|
||||
latches authority and blocks another powered operation. Measurement, samples and
|
||||
backup references remain in the receipt.
|
||||
|
||||
These operations do not constitute the full VESC Tool desktop UI. The product
|
||||
entry is labelled “Настройка VESC” until complete native application session
|
||||
integration is shipped. Full R/L/flux calibration, table application and
|
||||
configuration restore remain separate unimplemented work.
|
||||
@@ -0,0 +1,51 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {Button,LoadingRegion,ResourceList,ResourceRow,SettingsCard} from '@nodedc/ui-react';
|
||||
import type {ConfigurationVersion,SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||
|
||||
export function VescBackups({deviceId,archive,revision}: {deviceId:string;archive:SensorTransport['configurationArchive'];revision:string|null}) {
|
||||
const [reload,setReload]=useState(0);
|
||||
const [items,setItems]=useState<ConfigurationVersion[]>([]);
|
||||
const [next,setNext]=useState<string|null>(null);
|
||||
const [loading,setLoading]=useState(false);
|
||||
const [error,setError]=useState<string|null>(null);
|
||||
const [download,setDownload]=useState<string|null>(null);
|
||||
useEffect(()=>{
|
||||
let active=true;
|
||||
setItems([]);setNext(null);setError(null);
|
||||
if(!archive)return;
|
||||
setLoading(true);
|
||||
archive.list(deviceId).then(result=>{if(active){setItems(result.items);setNext(result.next);}})
|
||||
.catch(()=>{if(active)setError('Не удалось загрузить историю конфигураций.');})
|
||||
.finally(()=>{if(active)setLoading(false);});
|
||||
return()=>{active=false;};
|
||||
},[deviceId,archive,revision,reload]);
|
||||
async function more(){
|
||||
if(!archive||!next||loading)return;
|
||||
setLoading(true);setError(null);
|
||||
try{const result=await archive.list(deviceId,next);setItems(current=>[...current,...result.items.filter(item=>!current.some(old=>old.id===item.id))]);setNext(result.next);}
|
||||
catch{setError('Не удалось загрузить следующие версии.');}
|
||||
finally{setLoading(false);}
|
||||
}
|
||||
async function save(id:string){
|
||||
if(!archive||download)return;
|
||||
setDownload(id);setError(null);
|
||||
try{
|
||||
const value=await archive.read(deviceId,id);
|
||||
const url=URL.createObjectURL(new Blob([JSON.stringify(value,null,2)+'\n'],{type:'application/json'}));
|
||||
const link=document.createElement('a');link.href=url;link.download=`${deviceId}-${id}.json`;link.click();
|
||||
setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||
}catch{setError('Не удалось скачать выбранную версию.');}
|
||||
finally{setDownload(null);}
|
||||
}
|
||||
return <SettingsCard title="История конфигураций" description="Сохранённые версии остаются на борту и передаются в Core при подключении." actions={<Button disabled={loading||!archive} onClick={()=>setReload(value=>value+1)}>Обновить историю</Button>}>
|
||||
<LoadingRegion loading={loading&&!items.length} label="Загрузка версий конфигурации">
|
||||
{error&&<p role="alert">{error}</p>}
|
||||
{!loading&&!items.length&&!error&&<p>Сохранённых версий пока нет.</p>}
|
||||
<ResourceList aria-label="Версии конфигурации VESC">{items.map(item=><li key={item.id}>
|
||||
<ResourceRow title={new Date(item.observed_at).toLocaleString()} description={`Прошивка ${item.firmware} · мотор и входы`}
|
||||
actions={<Button disabled={download!==null} loading={download===item.id} onClick={()=>void save(item.id)}>Скачать</Button>}/>
|
||||
</li>)}</ResourceList>
|
||||
</LoadingRegion>
|
||||
{next&&<Button loading={loading} disabled={loading} onClick={()=>void more()}>Ещё версии</Button>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {Button,InspectorSelectField,ResourceList,ResourceRow,SettingsCard} from '@nodedc/ui-react';
|
||||
import type {SensorBoardSettingsProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform,type Sensor} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {drivePositions,vescStatus,type DriveProfile} from './model';
|
||||
import {VescLimits} from './VescLimits';
|
||||
|
||||
export function VescBoardSettings({inventory,transport,enabled,refresh,failure,openDevice}:SensorBoardSettingsProps){
|
||||
const controllers=inventory?.items.filter(device=>device.kind==='vesc.controller')??[];
|
||||
const profile=controllers.map(device=>vescStatus(device).drive_profile).filter((value):value is DriveProfile=>!!value).sort((a,b)=>b.revision-a.revision)[0];
|
||||
const anchor=controllers.find(device=>device.online&&device.verified&&vescStatus(device).board_settings_supported);
|
||||
const [busy,setBusy]=useState(false);const running=useRef(false);
|
||||
const active=inventory?.operations?.some(value=>['queued','running'].includes(value.state)&&controllers.some(device=>device.id===value.device_id));
|
||||
const blocked=!enabled||!anchor||busy||active||!profile;
|
||||
const positions=drivePositions(profile?.layout??null);
|
||||
async function change(device:Sensor,action:string,parameters:Record<string,unknown>){
|
||||
if(blocked||running.current||!profile)return;
|
||||
running.current=true;setBusy(true);failure(null);
|
||||
try{await perform(transport,device,action,{revision:profile.revision,...parameters});await refresh();}
|
||||
catch(error){failure(error);await refresh();}finally{running.current=false;setBusy(false);}
|
||||
}
|
||||
return <div className="sensor-content">
|
||||
<SettingsCard title="Привод" description="Профиль аппарата и расположение его моторов.">
|
||||
<InspectorSelectField label="Профиль привода" value={profile?.layout??''} disabled={blocked} options={[
|
||||
{value:'',label:'Выберите профиль',disabled:true},
|
||||
{value:'1x1',label:'1×1 · 2 мотора',disabled:!!profile?.bindings['left.2']||!!profile?.bindings['right.2']},
|
||||
{value:'2x2',label:'2×2 · 4 мотора'},
|
||||
]} onChange={layout=>{if(anchor)void change(anchor,'vesc.drive.layout',{layout});}}/>
|
||||
{!profile&&<p>{inventory?'Подключите VESC, чтобы получить профиль привода с борта.':'Получение профиля привода…'}</p>}
|
||||
{profile&&!anchor&&<p>Профиль показан по последним сведениям с борта. Для изменения нужна связь с VESC и актуальное бортовое приложение.</p>}
|
||||
{profile?.layout&&<>
|
||||
<p>Стороны — по направлению движения вперёд. Назначения сохраняются автоматически и остаются с контроллером при смене USB-порта.</p>
|
||||
{Object.entries(positions).map(([slot,label])=>{
|
||||
const bound=profile.bindings[slot];
|
||||
const missing=bound&&!controllers.some(device=>device.id===bound.device_id);
|
||||
return <InspectorSelectField key={slot} label={label} value={bound?.device_id??''} disabled={blocked} options={[
|
||||
{value:'',label:'Не назначен'},
|
||||
...controllers.map(device=>({value:device.id,label:device.name+(!device.online?' · нет связи':''),disabled:!device.online||!device.verified||Object.entries(profile.bindings).some(([other,binding])=>other!==slot&&binding.device_id===device.id)})),
|
||||
...(missing?[{value:bound.device_id,label:`VESC ${bound.uuid.slice(0,6).toUpperCase()} · нет связи`,disabled:true}]:[]),
|
||||
]} onChange={id=>{
|
||||
const target=id?controllers.find(device=>device.id===id):anchor;
|
||||
if(!target)return;
|
||||
void change(target,id?'vesc.drive.assign':'vesc.drive.unassign',id?{layout:profile.layout,slot}:{slot});
|
||||
}}/>;
|
||||
})}
|
||||
<ResourceList aria-label="Настройка назначенных моторов">{Object.entries(profile.bindings).map(([slot,binding])=>{
|
||||
const target=controllers.find(device=>device.id===binding.device_id);
|
||||
return <li key={slot}><ResourceRow title={positions[slot]??slot} description={target?.name??`VESC ${binding.uuid.slice(0,6).toUpperCase()}`} actions={<Button disabled={!target?.prepared||busy||active} onClick={()=>openDevice(binding.device_id)}>Настройка мотора</Button>}/></li>;
|
||||
})}</ResourceList>
|
||||
</>}
|
||||
</SettingsCard>
|
||||
<VescLimits controllers={controllers} transport={transport} enabled={enabled&&!busy&&!active} failure={failure}/>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {Button,Checker,ResourceList,ResourceRow,SettingsCard,TextField} from '@nodedc/ui-react';
|
||||
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {vescStatus} from './model';
|
||||
|
||||
interface CalibrationResult {
|
||||
completed:boolean;success:boolean;configuration_verified:boolean;release_confirmed:boolean;
|
||||
native:{success?:boolean;code?:number;sensor_mode?:number;parameters?:Record<string,number>};
|
||||
}
|
||||
|
||||
export function VescCalibration({device,transport,enabled,refresh,failure,blocked,onBusyChange}:SensorDetailProps&{blocked:boolean;onBusyChange:(busy:boolean)=>void}){
|
||||
const [loss,setLoss]=useState('50');
|
||||
const [confirmed,setConfirmed]=useState(false);
|
||||
const [busy,setBusy]=useState(false);
|
||||
const [result,setResult]=useState<CalibrationResult|null>(null);
|
||||
const running=useRef(false);
|
||||
const limits=vescStatus(device).foc_calibration;
|
||||
const power=Number(loss);
|
||||
const valid=!!limits&&loss.trim()!==''&&Number.isFinite(power)&&power>=limits.min_power_loss_w&&power<=limits.max_power_loss_w;
|
||||
const available=enabled&&device.online&&device.verified&&device.vesc_status?.test_supported===true&&!!limits;
|
||||
async function calibrate(){
|
||||
if(running.current||blocked||!available||!confirmed||!valid)return;
|
||||
running.current=true;setBusy(true);onBusyChange(true);setResult(null);failure(null);
|
||||
try{
|
||||
const inventory=await transport.inventory();
|
||||
const controllers=inventory.items.filter(item=>item.kind==='vesc.controller'&&item.online);
|
||||
const target=controllers.find(item=>item.id===device.id);
|
||||
if(inventory.fresh===false||!target||controllers.some(item=>!item.verified))throw new Error('Нужна свежая связь со всеми VESC борта.');
|
||||
setResult(await perform<CalibrationResult>(transport,target,'vesc.foc.calibrate',{
|
||||
rig_clear:true,native_cycle_confirmed:true,max_power_loss_w:power,
|
||||
sessions:Object.fromEntries(controllers.map(item=>[item.id,item.snapshot.context.session_id])),
|
||||
},300000));
|
||||
}catch(error){failure(error);}
|
||||
finally{running.current=false;setBusy(false);onBusyChange(false);setConfirmed(false);await refresh();}
|
||||
}
|
||||
const parameters=result?.native.parameters;
|
||||
const calibrated=result?.native.success===true&&result.configuration_verified;
|
||||
return <SettingsCard title="Калибровка мотора" description={`${device.name} · штатное автоопределение FOC в VESC Tool.`}>
|
||||
<p>Мастер измеряет сопротивление, индуктивность и магнитный поток, определяет датчики и записывает параметры выбранного мотора. Версии до и после сохраняются в истории; настройки аккумулятора и пульта сохраняются.</p>
|
||||
<TextField type="number" label="Допустимые потери в моторе, Вт" value={loss} onChange={event=>setLoss(event.target.value)} disabled={busy||blocked||!limits} min={limits?.min_power_loss_w} max={limits?.max_power_loss_w} step="5" aria-invalid={!valid} description="Параметр нагрева для мастера VESC Tool, не номинальная мощность мотора. По нему мастер выбирает токи измерения; предел тока проверки вращения здесь не применяется."/>
|
||||
<p>Мотор будет двигаться и разгоняться. На прошивке 5.02 процедуру нельзя прервать кнопкой или пультом — только отключением силового питания. Оставьте пульт выключенным и приводы вывешенными до завершения; цикл может занять до трёх минут.</p>
|
||||
<Checker checked={confirmed} onChange={setConfirmed} disabled={busy||blocked} label="Наблюдаю мотор, питание могу отключить"/>
|
||||
<Button disabled={!available||blocked||!confirmed||!valid||busy} loading={busy} onClick={()=>void calibrate()}>Откалибровать мотор</Button>
|
||||
{busy&&<p role="status">Подготовка и калибровка VESC Tool. Дождитесь результата и снятия тока.</p>}
|
||||
{result&&<>
|
||||
<p role="status">{calibrated?'Параметры мотора измерены, записаны и проверены.':result.completed?`Калибровка не принята. Код VESC: ${result.native.code??'не получен'}.`:'Завершение калибровки не подтверждено. Проверьте состояние мотора и питание.'} {result.release_confirmed?'Снятие тока подтверждено.':'Снятие тока пока не подтверждено. Перед следующей проверкой верните управление после нейтрали.'} {!result.configuration_verified&&' Проверка конфигурации не завершена; новое движение заблокировано.'}</p>
|
||||
{calibrated&&<>
|
||||
<p>{result.native.sensor_mode===0?'Выбран режим без датчиков. Холлы не определились; качество запуска нужно проверить вращением.':result.native.sensor_mode===2?'Определены датчики Холла.':'Определён энкодер.'}</p>
|
||||
{parameters&&<ResourceList aria-label="Результат калибровки">
|
||||
{([['foc_motor_r','Сопротивление',1000,'мОм'],['foc_motor_l','Индуктивность',1e6,'мкГн'],['foc_motor_flux_linkage','Магнитный поток',1000,'мВб'],['l_current_max','Предел тока мотора',1,'А']] as const).map(([key,title,scale,unit])=><li key={key}><ResourceRow title={title} description={`${(parameters[key]*scale).toLocaleString('ru-RU',{maximumFractionDigits:3})} ${unit}`}/></li>)}
|
||||
</ResourceList>}
|
||||
</>}
|
||||
</>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Button,LoadingRegion,ResourceList,ResourceRow,SettingsCard,StatusBadge} from '@nodedc/ui-react';
|
||||
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {vescLabel,vescStatus,type VescTelemetry} from './model';
|
||||
import {VescBackups} from './VescBackups';
|
||||
import {VescMotor} from './VescMotor';
|
||||
import {VescHall} from './VescHall';
|
||||
import {VescCalibration} from './VescCalibration';
|
||||
import {VescLink} from './VescLink';
|
||||
|
||||
const fields=[['input_voltage_v','Напряжение питания','В'],['input_current_a','Ток питания','А'],
|
||||
['motor_current_a','Ток мотора','А'],['erpm','Электрические обороты','ERPM'],['duty','Заполнение PWM',''],
|
||||
['mos_temperature_c','Температура контроллера','°C'],['motor_temperature_c','Температура мотора','°C'],
|
||||
['fault_code','Код ошибки',''],['can_id','CAN ID',''],['timeout','Тайм-аут управления',''],
|
||||
['kill_switch','Вход аварийного останова','']] as const;
|
||||
|
||||
export function VescDetail(props:SensorDetailProps){
|
||||
const {device,transport,enabled,back,refresh,failure}=props;
|
||||
const status=vescStatus(device);const label=vescLabel(device,enabled);
|
||||
const [telemetry,setTelemetry]=useState<VescTelemetry|null>(status.telemetry);
|
||||
const [pending,setPending]=useState<string|null>(null);
|
||||
const [saved,setSaved]=useState<string|null>(null);
|
||||
const [motorBusy,setMotorBusy]=useState(false);
|
||||
const [hallBusy,setHallBusy]=useState(false);
|
||||
const [calibrationBusy,setCalibrationBusy]=useState(false);
|
||||
const [linkBusy,setLinkBusy]=useState(false);
|
||||
const poweredBusy=motorBusy||hallBusy||calibrationBusy||linkBusy;
|
||||
const running=useRef(false);const mounted=useRef(true);
|
||||
const available=enabled&&device.online&&device.verified&&status.readable;
|
||||
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]);
|
||||
async function read(action:'vesc.telemetry.read'|'vesc.config.backup'){
|
||||
if(running.current||poweredBusy||!available)return;
|
||||
running.current=true;setPending(action);failure(null);
|
||||
try{
|
||||
const result=await perform<Record<string,unknown>>(transport,device,action);
|
||||
if(!mounted.current)return;
|
||||
if(action==='vesc.telemetry.read')setTelemetry(result as unknown as VescTelemetry);
|
||||
else {
|
||||
setSaved(String(result.observed_at));
|
||||
}
|
||||
await refresh();
|
||||
}catch(error){if(mounted.current)failure(error);}
|
||||
finally{running.current=false;if(mounted.current)setPending(null);}
|
||||
}
|
||||
const backupAt=saved??status.backup?.observed_at;
|
||||
return <div className="sensor-content">
|
||||
<div><Button onClick={back}>К устройствам</Button></div>
|
||||
<SettingsCard title={`${device.name} · Настройка VESC`} description={`${device.model} · ${device.connection_label??'USB'}`}
|
||||
actions={<StatusBadge tone={label.tone}>{label.label}</StatusBadge>}>
|
||||
{!enabled||!device.online?<p>Нет свежей связи с контроллером.</p>:status.message?<p>{status.message}</p>:null}
|
||||
{status.identity?<ResourceList aria-label="Контроллер VESC">
|
||||
<li><ResourceRow title="Прошивка" description={status.identity.version}/></li>
|
||||
<li><ResourceRow title="Аппаратная версия" description={status.identity.hardware}/></li>
|
||||
<li><ResourceRow title="UUID" description={status.identity.uuid.toUpperCase()}/></li>
|
||||
{status.identity.test_firmware!==null&&status.identity.test_firmware>0&&<li><ResourceRow title="Тестовая прошивка" description={String(status.identity.test_firmware)}/></li>}
|
||||
</ResourceList>:<p>Аппаратный идентификатор ещё не подтверждён.</p>}
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Показания контроллера" description={telemetry?`Снимок: ${new Date(telemetry.observed_at).toLocaleString()}`:'Получите текущие значения и код ошибки.'}
|
||||
actions={<Button disabled={!available||pending!==null||poweredBusy} loading={pending==='vesc.telemetry.read'} onClick={()=>void read('vesc.telemetry.read')}>Обновить показания</Button>}>
|
||||
<LoadingRegion loading={pending==='vesc.telemetry.read'&&!telemetry} label="Чтение показаний VESC">
|
||||
{telemetry?<ResourceList aria-label="Показания VESC">{fields.map(([key,title,unit])=>{
|
||||
const value=telemetry.values[key];if(value===undefined)return null;
|
||||
return <li key={key}><ResourceRow title={title} description={typeof value==='boolean'?(value?'Активен':'Не активен'):`${Number(value).toLocaleString(undefined,{maximumFractionDigits:3})}${unit?' '+unit:''}`}/></li>;
|
||||
})}</ResourceList>:<p>Показания ещё не прочитаны.</p>}
|
||||
</LoadingRegion>
|
||||
<p>ERPM — электрические обороты. Обороты вала зависят от числа пар полюсов мотора.</p>
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Резервная копия конфигурации" description="Сохраните текущие параметры мотора и входов новой версией."
|
||||
actions={<Button disabled={!available||pending!==null||poweredBusy} loading={pending==='vesc.config.backup'} onClick={()=>void read('vesc.config.backup')}>Сохранить версию</Button>}>
|
||||
{backupAt&&<p>Последняя копия: {new Date(backupAt).toLocaleString()}</p>}
|
||||
<p>Копия привязана к UUID и прошивке. Версии до и после калибровки остаются в истории.</p>
|
||||
</SettingsCard>
|
||||
<VescLink {...props} blocked={motorBusy||hallBusy||calibrationBusy||pending!==null} onBusyChange={setLinkBusy}/>
|
||||
<VescCalibration {...props} blocked={linkBusy||motorBusy||hallBusy||pending!==null} onBusyChange={setCalibrationBusy}/>
|
||||
<VescHall {...props} blocked={linkBusy||motorBusy||calibrationBusy||pending!==null} onBusyChange={setHallBusy}/>
|
||||
<VescMotor {...props} blocked={linkBusy||hallBusy||calibrationBusy||pending!==null} onBusyChange={setMotorBusy}/>
|
||||
<VescBackups deviceId={device.id} archive={transport.configurationArchive} revision={backupAt??null}/>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {Button,Checker,SettingsCard} from '@nodedc/ui-react';
|
||||
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {vescStatus} from './model';
|
||||
|
||||
interface HallResult {
|
||||
completed:boolean;configuration_restored:boolean;release_confirmed:boolean;
|
||||
measurement:null|{valid_six_states:boolean;observed_states:number[];hall_table:number[]};
|
||||
}
|
||||
|
||||
export function VescHall({device,transport,enabled,refresh,failure,blocked,onBusyChange}:SensorDetailProps&{blocked:boolean;onBusyChange:(busy:boolean)=>void}){
|
||||
const [confirmed,setConfirmed]=useState(false);
|
||||
const [busy,setBusy]=useState(false);
|
||||
const [result,setResult]=useState<HallResult|null>(null);
|
||||
const running=useRef(false);
|
||||
const available=enabled&&device.online&&device.verified&&device.vesc_status?.test_supported===true&&!!vescStatus(device).hall_measurement;
|
||||
async function measure(){
|
||||
if(running.current||blocked||!available||!confirmed)return;
|
||||
running.current=true;setBusy(true);onBusyChange(true);setResult(null);failure(null);
|
||||
try{
|
||||
const inventory=await transport.inventory();
|
||||
const controllers=inventory.items.filter(item=>item.kind==='vesc.controller'&&item.online);
|
||||
const target=controllers.find(item=>item.id===device.id);
|
||||
if(inventory.fresh===false||!target||controllers.some(item=>!item.verified))throw new Error('Нужна свежая связь со всеми VESC борта.');
|
||||
setResult(await perform<HallResult>(transport,target,'vesc.hall.measure',{
|
||||
rig_clear:true,native_cycle_confirmed:true,
|
||||
...(vescStatus(target).hall_measurement?.standstill_confirmation_required?{standstill_confirmed:true}:{}),
|
||||
sessions:Object.fromEntries(controllers.map(item=>[item.id,item.snapshot.context.session_id])),
|
||||
},60000));
|
||||
}catch(error){failure(error);}
|
||||
finally{running.current=false;setBusy(false);onBusyChange(false);setConfirmed(false);await refresh();}
|
||||
}
|
||||
return <SettingsCard title="Датчики Холла" description={`${device.name} · штатное измерение VESC Tool, 5 А.`}>
|
||||
<p>Мотор медленно смещается в обе стороны около 12 секунд. Измерение проверяет состояния датчиков и получает таблицу их положения; новая таблица автоматически не записывается.</p>
|
||||
<p>На прошивке 5.02 этот цикл нельзя прервать кнопкой или пультом. Для немедленной остановки нужно отключить силовое питание. Пульт должен оставаться выключенным, все приводы — вывешенными. Перед запуском убедитесь, что все моторы полностью остановились: в бессенсорном режиме показание оборотов на остановленном моторе может быть ненулевым.</p>
|
||||
<Checker checked={confirmed} onChange={setConfirmed} disabled={busy||blocked} label="Все моторы остановлены, наблюдаю"/>
|
||||
<Button disabled={!available||blocked||!confirmed||busy} loading={busy} onClick={()=>void measure()}>Измерить датчики Холла</Button>
|
||||
{busy&&<p role="status">Подготовка и штатное измерение датчиков. Дождитесь результата.</p>}
|
||||
{result&&<p role="status">{!result.completed?'Завершение измерения не подтверждено. Проверьте мотор и питание.':result.measurement?.valid_six_states?'Измерение получило шесть состояний Холла.':'Не удалось получить полную таблицу Холла. Возможны отсутствие движения или неисправность датчиков/соединения.'} {result.release_confirmed?'Снятие тока подтверждено.':'Снятие тока не подтверждено.'} {result.configuration_restored?'Исходная конфигурация сохранена.':'Возврат исходной конфигурации не подтверждён.'}</p>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {Button,ResourceList,ResourceRow,SettingsCard} from '@nodedc/ui-react';
|
||||
import {perform,type Sensor,type SensorTransport} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {vescStatus} from './model';
|
||||
|
||||
interface Limits {observed_at:string;identity?:{uuid:string};parameters:Record<string,number>}
|
||||
const number=(value:number|undefined)=>value===undefined?'—':value.toLocaleString('ru-RU',{maximumFractionDigits:2});
|
||||
export function VescLimits({controllers,transport,enabled,failure}:{controllers:Sensor[];transport:SensorTransport;enabled:boolean;failure:(error:unknown)=>void}){
|
||||
const [values,setValues]=useState<Record<string,Limits>>({});
|
||||
const [busy,setBusy]=useState(false);const running=useRef(false);
|
||||
const readable=controllers.filter(device=>device.online&&device.verified&&vescStatus(device).readable&&vescStatus(device).board_settings_supported);
|
||||
async function read(){
|
||||
if(!enabled||running.current||!readable.length)return;
|
||||
running.current=true;setBusy(true);failure(null);setValues({});
|
||||
try{for(const device of readable){const value=await perform<Limits>(transport,device,'vesc.limits.read');setValues(current=>({...current,[device.id]:value}));}}
|
||||
catch(error){failure(error);}finally{running.current=false;setBusy(false);}
|
||||
}
|
||||
return <SettingsCard title="Ограничения контроллеров" description="Текущие настройки VESC. Чтение не запускает моторы и не меняет конфигурацию." actions={<Button disabled={!enabled||busy||!readable.length} loading={busy} onClick={()=>void read()}>Прочитать ограничения</Button>}>
|
||||
<p>Ток мотора задаёт тягу; ток батареи ограничивает потребление. Эти настройки действуют и при управлении с пульта. Паспортные пределы моторов, контроллеров и батареи проверяются отдельно.</p>
|
||||
{controllers.map(device=>{
|
||||
const value=values[device.id];if(!value)return null;
|
||||
const p=value.parameters;
|
||||
const fields=[
|
||||
['Ток мотора · разгон / торможение',`${number(p.l_current_max)} / ${number(p.l_current_min)} А`],
|
||||
['Масштаб тока · разгон / торможение',`${number(p.l_current_max_scale*100)} / ${number(p.l_current_min_scale*100)} %`],
|
||||
['Ток батареи · потребление / рекуперация',`${number(p.l_in_current_max)} / ${number(p.l_in_current_min)} А`],
|
||||
['Диапазон электрических оборотов',`${number(p.l_min_erpm)} … ${number(p.l_max_erpm)} ERPM`],
|
||||
['Максимальная мощность',p.l_watt_max>=1500000?'Отдельный предел не задан':`${number(p.l_watt_max)} Вт`],
|
||||
['Максимальный duty cycle',`${number(p.l_max_duty*100)} %`],
|
||||
];
|
||||
return <SettingsCard key={device.id} title={device.name} description={`Прочитано ${new Date(value.observed_at).toLocaleString('ru-RU')}`}><ResourceList aria-label={`Ограничения ${device.name}`}>{fields.map(([title,description])=><li key={title}><ResourceRow title={title} description={description}/></li>)}</ResourceList></SettingsCard>;
|
||||
})}
|
||||
{!Object.keys(values).length&&<p>Прочитайте значения для подключённых контроллеров.</p>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Button,ResourceList,ResourceRow,SettingsCard} from '@nodedc/ui-react';
|
||||
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {vescStatus} from './model';
|
||||
|
||||
interface LinkResult {
|
||||
outcome:string;duration_s:number;
|
||||
devices:Record<string,{name:string;summary:{replies:number;p95_ms?:number;max_ms?:number;over_60_ms?:number}}>;
|
||||
}
|
||||
export function VescLink({device,transport,enabled,failure,refresh,blocked,onBusyChange}:SensorDetailProps&{blocked:boolean;onBusyChange:(busy:boolean)=>void}){
|
||||
const [busy,setBusy]=useState(false);const [result,setResult]=useState<LinkResult|null>(null);
|
||||
const running=useRef(false);const mounted=useRef(true);
|
||||
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]);
|
||||
const available=enabled&&device.online&&device.verified&&vescStatus(device).link_check_supported;
|
||||
async function check(){
|
||||
if(running.current||blocked||!available)return;
|
||||
running.current=true;setBusy(true);onBusyChange(true);failure(null);setResult(null);
|
||||
try{
|
||||
const inventory=await transport.inventory();
|
||||
const peers=inventory.items.filter(item=>item.kind==='vesc.controller');
|
||||
const selected=peers.find(item=>item.id===device.id);
|
||||
if(inventory.fresh===false||!selected||peers.some(item=>!item.online||!item.verified))throw new Error('Обновите связь со всеми VESC борта.');
|
||||
const sessions=Object.fromEntries(peers.map(item=>[item.id,item.snapshot.context.session_id]));
|
||||
const value=await perform<LinkResult>(transport,selected,'vesc.link.check',{sessions},45000);
|
||||
if(mounted.current)setResult(value);
|
||||
await refresh();
|
||||
}catch(error){if(mounted.current)failure(error);}
|
||||
finally{running.current=false;if(mounted.current){setBusy(false);onBusyChange(false);}}
|
||||
}
|
||||
if(!vescStatus(device).link_check_supported)return null;
|
||||
const text=result?.outcome==='complete'?'Все ответы получены. Эта проверка не подтверждает связь во время вращения.':
|
||||
result?.outcome==='not_idle'?'Измерение прекращено: есть команда с пульта или движение мотора.':
|
||||
result?.outcome==='read_failed'?'Ответ контроллера не получен. Проверка прервана.':
|
||||
'Измерение не завершено.';
|
||||
return <SettingsCard title="Связь с контроллерами" description="Проверяет ответы всех VESC борта около 10 секунд. Моторы должны стоять; команды вращения и изменения настроек не отправляются."
|
||||
actions={<Button disabled={!available||blocked||busy} loading={busy} onClick={()=>void check()}>Проверить связь</Button>}>
|
||||
{busy&&<p role="status">Измеряется время ответа контроллеров.</p>}
|
||||
{result&&<><p role="status">{text}</p><ResourceList aria-label="Связь VESC">{Object.entries(result.devices).map(([id,item])=><li key={id}><ResourceRow title={item.name}
|
||||
description={`${item.summary.replies} ответов${item.summary.max_ms===undefined?'':` · 95% не дольше ${Math.ceil(item.summary.p95_ms??0)} мс · максимум ${Math.ceil(item.summary.max_ms)} мс`}`}/></li>)}</ResourceList></>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Button,Checker,Select,TextField,SettingsCard} from '@nodedc/ui-react';
|
||||
import type {SensorDetailProps} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {perform} from '../../../../packages/sensor-ui/src/contracts';
|
||||
import {testInput,speedInput,rotationResult,vescStatus,driveTestIds,drivePositions} from './model';
|
||||
|
||||
export function VescMotor({device,transport,enabled,refresh,failure,blocked=false,onBusyChange}:SensorDetailProps&{blocked?:boolean;onBusyChange?:(busy:boolean)=>void}){
|
||||
const [duration,setDuration]=useState('30');
|
||||
const [current,setCurrent]=useState('30');
|
||||
const [speed,setSpeed]=useState('2000');
|
||||
const [direction,setDirection]=useState('forward');
|
||||
const [scope,setScope]=useState('single');
|
||||
const status=vescStatus(device),profile=status.drive_profile;
|
||||
const driveIds=driveTestIds(profile,device.id);
|
||||
const group=scope==='profile';
|
||||
const groupAvailable=status.group_test_supported===true&&driveIds.length>1;
|
||||
const [clear,setClear]=useState(false);const [busy,setBusy]=useState(false);
|
||||
const [result,setResult]=useState<string|null>(null);const [stopping,setStopping]=useState(false);
|
||||
const [rc,setRc]=useState(device.vesc_status?.rc_latched===true);
|
||||
const running=useRef(false);const mounted=useRef(true);
|
||||
useEffect(()=>{mounted.current=true;return()=>{mounted.current=false;};},[]);
|
||||
const limits=vescStatus(device).test_limits;
|
||||
const speedLimits=vescStatus(device).speed_limits;
|
||||
const speedValue=speedInput(speed,speedLimits);
|
||||
const input=testInput(current,duration,limits);
|
||||
const available=!!limits&&!!speedLimits&&enabled&&!blocked&&device.online&&device.verified&&device.vesc_status?.test_supported===true;
|
||||
const valid=input.valid&&!speedValue.error&&(direction==='forward'||speedLimits?.reverse_supported===true)&&(!group||groupAvailable);
|
||||
const blockedReason=busy?'Подготовка и проверка выполняются. Дождитесь результата или остановите проверку.':
|
||||
!enabled||!device.online?'Для запуска нужна свежая связь с бортом и VESC.':
|
||||
!device.verified?'Контроллер ещё не определён. Обновите устройства.':
|
||||
!available?'Проверка вращения для этого VESC сейчас недоступна на борту.':
|
||||
!valid?'Исправьте значения в отмеченных полях.':
|
||||
!clear?'Для запуска подтвердите, что все приводы остановлены, вывешены и вращение свободно.':null;
|
||||
async function execute(release=false){
|
||||
if(running.current||!available||!clear||!valid)return;
|
||||
running.current=true;setBusy(true);onBusyChange?.(true);setResult(null);failure(null);
|
||||
try{
|
||||
const inventory=await transport.inventory();
|
||||
const controllers=inventory.items.filter(item=>item.kind==='vesc.controller'&&item.online);
|
||||
const target=controllers.find(item=>item.id===device.id);
|
||||
if(inventory.fresh===false||!target||controllers.some(item=>!item.verified))throw new Error('Нужна свежая связь со всеми подключёнными VESC этого борта.');
|
||||
if(group&&!release&&(vescStatus(target).drive_profile?.revision!==profile?.revision||driveIds.some(id=>!controllers.some(item=>item.id===id))))throw new Error('Профиль или состав моторов изменился. Обновите карточку.');
|
||||
const value=await perform<{outcome?:string;rotation_s?:number;release_confirmed?:boolean;limits_restored?:boolean}>(transport,target,release?'vesc.control.release':group?'vesc.drive.run':'vesc.motor.run',{
|
||||
rig_clear:true,duration_s:input.durationS,current_a:input.currentA,
|
||||
...(release?{}:{erpm:speedValue.erpm*(direction==='reverse'?-1:1)}),
|
||||
...(vescStatus(target).speed_limits?.standstill_confirmation_required?{standstill_confirmed:true}:{}),
|
||||
...(!release&&group?{profile_revision:profile?.revision,device_ids:driveIds}:{}),
|
||||
sessions:Object.fromEntries(controllers.map(item=>[item.id,item.snapshot.context.session_id])),
|
||||
},group&&!release?120000:90000);
|
||||
if(mounted.current){
|
||||
setResult(release?'Управление возвращено для проверки.':rotationResult(value));
|
||||
if(release)setRc(false);
|
||||
}
|
||||
}catch(error){if(mounted.current)failure(error);}
|
||||
finally{
|
||||
running.current=false;
|
||||
onBusyChange?.(false);
|
||||
if(mounted.current){setBusy(false);setClear(false);await refresh();}
|
||||
}
|
||||
}
|
||||
async function stop(){
|
||||
if(stopping)return;setStopping(true);
|
||||
try{const value=await perform<{interruptible?:boolean}>(transport,device,'vesc.motor.stop',{},15000);if(mounted.current)setResult(value.interruptible===false?'Идёт штатный цикл измерения VESC. Немедленная остановка возможна отключением питания.':'Остановка запрошена. Ожидаем результат проверки.');}
|
||||
catch(error){if(mounted.current)failure(error);}
|
||||
finally{if(mounted.current)setStopping(false);}
|
||||
}
|
||||
useEffect(()=>setRc(device.vesc_status?.rc_latched===true),[device.vesc_status?.rc_latched]);
|
||||
return <SettingsCard title="Проверка вращения" description={`${device.name} · ${device.connection_label??'USB'}. Конфигурации подключённых контроллеров сохраняются автоматически.`}>
|
||||
<p>Для вывешенных колёс без нагрузки. При команде с приёмника проверка прекращается; повторный запуск требует явного возврата управления.</p>
|
||||
<Select label="Проверяемые моторы" value={scope} disabled={busy||blocked} options={[{value:'single',label:`Только ${device.name}`},{value:'profile',label:'Все моторы профиля одновременно',disabled:!groupAvailable}]} onChange={setScope}/>
|
||||
{group&&profile&&<p>{Object.keys(drivePositions(profile.layout)).map(slot=>`${drivePositions(profile.layout)[slot]} · VESC ${profile.bindings[slot]?.uuid.slice(0,6).toUpperCase()??'не назначен'}`).join('; ')}. Общий отсчёт начинается, когда все моторы удерживают скорость. Остановка любого завершает всю проверку. Предел тока применяется к каждому мотору.</p>}
|
||||
<Select label="Направление вращения" value={direction} disabled={busy||blocked} options={[{value:'forward',label:'Прямое'},{value:'reverse',label:'Обратное',disabled:speedLimits?.reverse_supported!==true}]} onChange={value=>{setDirection(value);setClear(false);}}/>
|
||||
<p>Направление относительно настроек VESC. Перед обратным запуском дождитесь полной остановки всех моторов и подтвердите её.</p>
|
||||
<TextField type="number" inputMode="decimal" label="Скорость, ERPM" value={speed} onChange={event=>setSpeed(event.target.value)} disabled={busy||!speedLimits} min={speedLimits?.min_erpm} max={speedLimits?.max_erpm} step="100" aria-invalid={!!speedValue.error} description={speedValue.error??'Электрические обороты в минуту. VESC плавно разгоняет мотор и удерживает заданную скорость.'}/>
|
||||
<TextField type="number" inputMode="decimal" label="Предел тока мотора, А" value={current} onChange={event=>setCurrent(event.target.value)} disabled={busy||!limits} min={limits?.min_current_a} max={limits?.max_current_a} step="0.1" aria-invalid={!!input.currentError} hint={limits?`${limits.min_current_a}–${limits.max_current_a} А`:undefined} description={input.currentError??'Максимальный ток разгона и удержания скорости. Прежние пределы сохраняются перед тестом и восстанавливаются после него.'}/>
|
||||
<TextField type="number" inputMode="decimal" label="Длительность вращения, с" value={duration} onChange={event=>setDuration(event.target.value)} disabled={busy||!limits} min={limits?.min_duration_s} max={limits?.max_duration_s} step="0.1" aria-invalid={!!input.durationError} hint={limits?`${limits.min_duration_s}–${limits.max_duration_s} с`:undefined} description={input.durationError??'Отсчёт начинается после разгона и стабилизации скорости. Подготовка, разгон и остановки не входят в это время.'}/>
|
||||
<p>Время считается по скорости и тахометру VESC. Если мотор не разгонится за 15 секунд, потеряет скорость или сработает ограничение, результат покажет фактическое время и причину остановки. Показания контроллера нужно сопоставить с видимым вращением.</p>
|
||||
{limits?.stall_timeout_s&&<p>При токе выше {limits.stall_current_a} А и отсутствии подтверждённого движения в течение {limits.stall_timeout_s} секунд проверка остановится.</p>}
|
||||
<p>Все приводы должны быть вывешены, вращение свободно. Подтверждение требуется перед каждым запуском.</p>
|
||||
<Checker checked={clear} onChange={setClear} disabled={busy} label="Все моторы остановлены, наблюдаю"/>
|
||||
{blockedReason&&<p role="status">{blockedReason}</p>}
|
||||
<div className="sensor-actions">
|
||||
{rc?<Button disabled={!available||!clear||busy||!valid} loading={busy} onClick={()=>void execute(true)}>Вернуть управление после нейтрали</Button>:
|
||||
<Button disabled={!available||!clear||busy||!valid} loading={busy} onClick={()=>void execute()}>Проверить вращение</Button>}
|
||||
<Button disabled={!enabled||!device.online||blocked} loading={stopping} onClick={()=>void stop()}>Остановить проверку</Button>
|
||||
</div>
|
||||
{result&&<p role="status">{result}</p>}
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {Sensor} from '../../../../packages/sensor-ui/src/contracts';
|
||||
|
||||
export interface VescIdentity {uuid:string;hardware:string;version:string;test_firmware:number|null;hardware_type:number|null}
|
||||
export interface VescTelemetry {observed_at:string;values:Record<string,number|boolean>}
|
||||
export interface DriveProfile {layout:null|'1x1'|'2x2';revision:number;bindings:Record<string,{device_id:string;uuid:string}>}
|
||||
export function drivePositions(layout:DriveProfile['layout']):Record<string,string> {
|
||||
return layout==='2x2'
|
||||
? {'left.1':'Левый передний','left.2':'Левый задний','right.1':'Правый передний','right.2':'Правый задний'}
|
||||
: {'left.1':'Левый','right.1':'Правый'};
|
||||
}
|
||||
export interface VescStatus {
|
||||
board_settings_supported?:boolean;
|
||||
group_test_supported?:boolean;
|
||||
link_check_supported?:boolean;
|
||||
foc_calibration?:{min_power_loss_w:number;max_power_loss_w:number;interruptible:boolean};
|
||||
speed_limits?:{min_erpm:number;max_erpm:number;duration_basis:string;reverse_supported?:boolean;standstill_confirmation_required?:boolean};
|
||||
hall_measurement?:{current_a:number;interruptible:boolean;standstill_confirmation_required?:boolean};
|
||||
drive_profile?:DriveProfile;
|
||||
test_limits?:{min_current_a:number;max_current_a:number;min_duration_s:number;max_duration_s:number;current_ramp_a_per_s?:number;continuous_current?:boolean;max_erpm?:number;max_duty?:number;stall_current_a?:number;stall_timeout_s?:number};
|
||||
identity:VescIdentity|null; readable:boolean; message:string|null; telemetry:VescTelemetry|null;
|
||||
backup:{observed_at:string;operation_id:string;configs:Record<string,{bytes:number;sha256:string}>}|null;
|
||||
}
|
||||
|
||||
export function driveTestIds(profile:DriveProfile|undefined,selected:string):string[] {
|
||||
if(!profile?.layout)return [];
|
||||
const slots=Object.keys(drivePositions(profile.layout));
|
||||
if(Object.keys(profile.bindings).length!==slots.length||slots.some(slot=>!profile.bindings[slot]))return [];
|
||||
const ids=slots.map(slot=>profile.bindings[slot].device_id);
|
||||
return new Set(ids).size===ids.length&&ids.includes(selected)?ids:[];
|
||||
}
|
||||
|
||||
export function speedInput(speed:string,limits:VescStatus['speed_limits']) {
|
||||
const erpm=Number(speed);
|
||||
const error=!limits?'Для удержания скорости требуется обновление профиля VESC на борту.':
|
||||
speed.trim()===''||!Number.isFinite(erpm)?'Введите скорость.':
|
||||
erpm<limits.min_erpm||erpm>limits.max_erpm?`Скорость должна быть от ${limits.min_erpm} до ${limits.max_erpm} ERPM.`:null;
|
||||
return {erpm,error};
|
||||
}
|
||||
|
||||
export function rotationResult(value:{outcome?:string;rotation_s?:number;release_confirmed?:boolean;limits_restored?:boolean}) {
|
||||
const time=(value.rotation_s??0).toLocaleString('ru-RU',{maximumFractionDigits:1});
|
||||
const outcome=value.outcome==='duration'?'Заданное время вращения набрано.':value.outcome==='stopped'?'Проверка остановлена.':String(value.outcome);
|
||||
return `${outcome} Вращение на заданной скорости по данным VESC: ${time} с. ${value.release_confirmed?'Снятие тока подтверждено.':'Снятие тока не подтверждено.'}${value.limits_restored?' Исходные токовые пределы восстановлены.':''}`;
|
||||
}
|
||||
export function vescStatus(device:Sensor):VescStatus {
|
||||
return {identity:null,readable:false,message:null,telemetry:null,backup:null,...device.vesc_status} as VescStatus;
|
||||
}
|
||||
export function vescLabel(device:Sensor,fresh:boolean):{label:string;tone:'neutral'|'success'|'warning'|'danger'} {
|
||||
const status=vescStatus(device);
|
||||
if(!fresh||!device.online)return {label:'Нет связи',tone:'neutral'};
|
||||
if(!device.prepared)return {label:'Требуется подготовка',tone:'neutral'};
|
||||
if(!status.identity)return {label:'Не определён',tone:'warning'};
|
||||
if(!status.readable)return {label:'Прошивка не поддерживается',tone:'warning'};
|
||||
return {label:'Готов к чтению',tone:'success'};
|
||||
}
|
||||
|
||||
export function testInput(current:string,duration:string,limits:VescStatus['test_limits']) {
|
||||
const currentA=Number(current),durationS=Number(duration);
|
||||
const number=(value:number)=>value.toLocaleString('ru-RU');
|
||||
const currentError=!limits?null:current.trim()===''||!Number.isFinite(currentA)
|
||||
? 'Введите ток мотора.'
|
||||
: currentA<limits.min_current_a||currentA>limits.max_current_a
|
||||
? `Ток должен быть от ${number(limits.min_current_a)} до ${number(limits.max_current_a)} А.`:null;
|
||||
const durationError=!limits?null:duration.trim()===''||!Number.isFinite(durationS)
|
||||
? 'Введите длительность проверки.'
|
||||
: durationS<limits.min_duration_s||durationS>limits.max_duration_s
|
||||
? `Длительность должна быть от ${number(limits.min_duration_s)} до ${number(limits.max_duration_s)} с.`:null;
|
||||
return {currentA,durationS,currentError,durationError,valid:!!limits&&!currentError&&!durationError};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type {SensorUiContribution} from '../../../../packages/sensor-ui/src/extensions';
|
||||
import {VescDetail} from './VescDetail';
|
||||
import {VescBoardSettings} from './VescBoardSettings';
|
||||
import {vescLabel} from './model';
|
||||
|
||||
export const vescSensorUi:SensorUiContribution={
|
||||
kind:'vesc.controller',Detail:VescDetail,BoardSettings:VescBoardSettings,icon:'activity',retainOffline:true,
|
||||
supportsPreparation:true,supportsRenaming:true,detailLabel:'Настройка VESC',status:vescLabel,
|
||||
};
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,97 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#pragma once
|
||||
#include <QApplication>
|
||||
#include <QCryptographicHash>
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QRegularExpression>
|
||||
#include <QXmlStreamWriter>
|
||||
#include <stdexcept>
|
||||
#include "vescinterface.h"
|
||||
#include "utility.h"
|
||||
|
||||
static QString sha256(const QByteArray &data) {
|
||||
return QCryptographicHash::hash(data, QCryptographicHash::Sha256).toHex();
|
||||
}
|
||||
|
||||
static void require(bool condition, const char *message) {
|
||||
if (!condition) throw std::runtime_error(message);
|
||||
}
|
||||
|
||||
static QJsonObject exportConfig(ConfigParams *config, const QByteArray &packet,
|
||||
const QString &xmlName) {
|
||||
VByteArray roundTrip;
|
||||
config->serialize(roundTrip);
|
||||
require(roundTrip == packet.mid(1), "Native configuration round-trip differs from archive");
|
||||
QJsonArray parameters;
|
||||
for (const auto &name : config->getParamOrder()) {
|
||||
const auto p = config->getParamCopy(name);
|
||||
QJsonObject item{{"name", name}, {"title", p.longName},
|
||||
{"description", p.description}, {"suffix", p.suffix},
|
||||
{"transmittable", p.transmittable}, {"editor_scale", p.editorScale}};
|
||||
switch (p.type) {
|
||||
case CFG_T_DOUBLE:
|
||||
item.insert("type", "number"); item.insert("value", p.valDouble);
|
||||
item.insert("min", p.minDouble); item.insert("max", p.maxDouble);
|
||||
item.insert("step", p.stepDouble); item.insert("decimals", p.editorDecimalsDouble);
|
||||
break;
|
||||
case CFG_T_INT: case CFG_T_BITFIELD:
|
||||
item.insert("type", p.type == CFG_T_INT ? "integer" : "bitfield");
|
||||
item.insert("value", p.valInt); item.insert("min", p.minInt);
|
||||
item.insert("max", p.maxInt); item.insert("step", p.stepInt);
|
||||
break;
|
||||
case CFG_T_ENUM:
|
||||
item.insert("type", "enum"); item.insert("value", p.valInt);
|
||||
item.insert("options", QJsonArray::fromStringList(p.enumNames)); break;
|
||||
case CFG_T_BOOL:
|
||||
item.insert("type", "boolean"); item.insert("value", bool(p.valInt)); break;
|
||||
case CFG_T_QSTRING:
|
||||
item.insert("type", "string"); item.insert("value", p.valString);
|
||||
item.insert("max_length", p.maxLen); break;
|
||||
default: item.insert("type", "undefined");
|
||||
}
|
||||
parameters.append(item);
|
||||
}
|
||||
QJsonArray groups;
|
||||
for (const auto &group : config->getParamGroups()) {
|
||||
QJsonArray subgroups;
|
||||
for (const auto &subgroup : config->getParamSubgroups(group)) {
|
||||
subgroups.append(QJsonObject{{"name", subgroup}, {"parameters",
|
||||
QJsonArray::fromStringList(config->getParamsFromSubgroup(group, subgroup))}});
|
||||
}
|
||||
groups.append(QJsonObject{{"name", group}, {"subgroups", subgroups}});
|
||||
}
|
||||
QString xml;
|
||||
QXmlStreamWriter writer(&xml);
|
||||
writer.setAutoFormatting(true);
|
||||
config->getXML(writer, xmlName);
|
||||
// Validate Tool's own XML load as well as its binary codec. No output file,
|
||||
// motor configuration write, custom schema, or custom parameter parser.
|
||||
ConfigParams loaded;
|
||||
loaded = *config;
|
||||
QXmlStreamReader reader(xml);
|
||||
require(loaded.setXML(reader, xmlName), "Native XML round-trip failed");
|
||||
VByteArray xmlRoundTrip; loaded.serialize(xmlRoundTrip);
|
||||
const auto differences = config->checkDifference(&loaded);
|
||||
require(differences.isEmpty(), "Native XML differs beyond upstream comparison tolerance");
|
||||
return QJsonObject{{"parameters", parameters}, {"groups", groups}, {"xml", xml},
|
||||
{"packet_sha256", sha256(packet)}, {"round_trip_exact", true},
|
||||
{"xml_round_trip_exact", xmlRoundTrip == roundTrip},
|
||||
{"xml_equivalent_by_upstream_comparison", true},
|
||||
{"signature", double(config->getSignature())}};
|
||||
}
|
||||
|
||||
static QByteArray archivePacket(const QJsonObject &archive, const QString &key, int command) {
|
||||
auto entry = archive.value("configs").toObject().value(key).toObject();
|
||||
auto encoded = entry.value("payload").toString().toLatin1();
|
||||
auto packet = QByteArray::fromBase64(encoded, QByteArray::AbortOnBase64DecodingErrors);
|
||||
require(entry.value("encoding") == "base64" && !packet.isEmpty() && packet.size() <= 16384,
|
||||
"Invalid archived configuration encoding");
|
||||
require(packet.toBase64() == encoded && packet.size() == entry.value("bytes").toInt(),
|
||||
"Invalid archived configuration length");
|
||||
require(quint8(packet.at(0)) == command && sha256(packet) == entry.value("sha256").toString(),
|
||||
"Archived configuration hash or command mismatch");
|
||||
return packet;
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Private per-device JSON process adapter. Every wire operation is upstream.
|
||||
#include "config_export.h"
|
||||
#include <QEventLoop>
|
||||
#include <QElapsedTimer>
|
||||
#include <QSocketNotifier>
|
||||
#include <QSerialPort>
|
||||
#include <QTimer>
|
||||
#include <QFileInfo>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
#include <fcntl.h>
|
||||
#include <sys/file.h>
|
||||
#include <sys/ioctl.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/sysmacros.h>
|
||||
#include <termios.h>
|
||||
#include <unistd.h>
|
||||
|
||||
class ExchangeFailure : public std::runtime_error {
|
||||
public:
|
||||
QJsonObject diagnostics;
|
||||
explicit ExchangeFailure(const QJsonObject &value)
|
||||
: std::runtime_error("Native query timed out or disconnected"), diagnostics(value) {}
|
||||
};
|
||||
|
||||
class Engine {
|
||||
public:
|
||||
VescInterface vesc;
|
||||
Packet *packet;
|
||||
FW_RX_PARAMS identity;
|
||||
bool procedureRunning = false;
|
||||
bool procedureUncertain = false;
|
||||
QJsonObject procedure;
|
||||
bool queryRunning = false;
|
||||
bool allowHardware = false;
|
||||
QByteArray lastMotor, lastApplication, lastHall;
|
||||
|
||||
Engine() {
|
||||
require(Utility::configLoadLatest(&vesc), "Upstream resources missing");
|
||||
packet = vesc.findChild<Packet *>();
|
||||
require(packet, "Upstream packet transport missing");
|
||||
QObject::connect(vesc.commands(), &Commands::fwVersionReceived,
|
||||
[&](FW_RX_PARAMS value) { identity = value; });
|
||||
QObject::connect(packet, &Packet::packetReceived, [&](QByteArray &raw) {
|
||||
if (!raw.isEmpty() && quint8(raw[0]) == COMM_GET_MCCONF) lastMotor = raw;
|
||||
if (!raw.isEmpty() && quint8(raw[0]) == COMM_GET_APPCONF) lastApplication = raw;
|
||||
if (!raw.isEmpty() && quint8(raw[0]) == COMM_DETECT_HALL_FOC) lastHall = raw;
|
||||
});
|
||||
}
|
||||
|
||||
void open(const QString &port) {
|
||||
require(QRegularExpression("^/dev/ttyACM[0-9]+$").match(port).hasMatch(), "Unsupported USB path");
|
||||
struct stat st;
|
||||
require(lstat(port.toLocal8Bit(), &st) == 0 && S_ISCHR(st.st_mode) && major(st.st_rdev) == 166,
|
||||
"Not a CDC ACM device");
|
||||
require(vesc.connectSerial(port, 115200), "Native serial connection failed");
|
||||
auto serial = vesc.findChild<QSerialPort *>();
|
||||
require(serial && serial->isOpen() && "/dev/" + serial->portName() == port,
|
||||
"Native serial path mismatch");
|
||||
require(flock(serial->handle(), LOCK_EX | LOCK_NB) == 0 && ioctl(serial->handle(), TIOCEXCL) == 0,
|
||||
"Serial port is already owned");
|
||||
allowHardware = true;
|
||||
query(COMM_FW_VERSION, 3000);
|
||||
require(identity.major == 5 && identity.minor == 2 && identity.hwType == HW_TYPE_VESC
|
||||
&& identity.isTestFw == 0 && identity.customConfigNum == 0,
|
||||
"Native hardware acceptance currently admits stable FW 5.02 only");
|
||||
}
|
||||
|
||||
QByteArray exchange(int command, int timeoutMs, const std::function<void()> &send) {
|
||||
require(allowHardware && vesc.isPortConnected(), "Device disconnected");
|
||||
require(!queryRunning, "A native query is already pending");
|
||||
queryRunning = true;
|
||||
struct PendingReset { bool &pending; ~PendingReset() { pending = false; } } reset{queryRunning};
|
||||
QEventLoop loop;
|
||||
QTimer timeout; timeout.setSingleShot(true);
|
||||
QObject observer;
|
||||
QElapsedTimer elapsed; elapsed.start();
|
||||
QJsonArray events;
|
||||
bool emitted = false;
|
||||
int packetsSent = 0, packetsReceived = 0;
|
||||
qint64 bytesWritten = 0;
|
||||
auto serial = vesc.findChild<QSerialPort *>();
|
||||
auto record = [&](const QString &kind, int code, qint64 bytes) {
|
||||
if (events.size() == 24) events.removeFirst();
|
||||
events.append(QJsonObject{{"event", kind}, {"command", code},
|
||||
{"bytes", double(bytes)}, {"at_ms", elapsed.nsecsElapsed() / 1e6}});
|
||||
};
|
||||
QObject::connect(vesc.commands(), &Commands::dataToSend, &observer, [&](QByteArray &raw) {
|
||||
const int code = raw.isEmpty() ? -1 : quint8(raw[0]);
|
||||
if (code == command) emitted = true;
|
||||
record("command_emitted", code, raw.size());
|
||||
});
|
||||
QObject::connect(packet, &Packet::dataToSend, &observer, [&](QByteArray &raw) {
|
||||
++packetsSent; record("packet_sent", -1, raw.size());
|
||||
});
|
||||
if (serial) {
|
||||
QObject::connect(serial, &QSerialPort::bytesWritten, &observer, [&](qint64 bytes) {
|
||||
bytesWritten += bytes; record("serial_written", -1, bytes);
|
||||
});
|
||||
QObject::connect(serial, &QSerialPort::errorOccurred, &observer, [&](QSerialPort::SerialPortError error) {
|
||||
if (error != QSerialPort::NoError) record("serial_error", int(error), 0);
|
||||
});
|
||||
}
|
||||
QByteArray answer;
|
||||
QObject::connect(packet, &Packet::packetReceived, &observer, [&](QByteArray &raw) {
|
||||
++packetsReceived;
|
||||
record("packet_received", raw.isEmpty() ? -1 : quint8(raw[0]), raw.size());
|
||||
if (!raw.isEmpty() && quint8(raw[0]) == command) { answer = raw; loop.quit(); }
|
||||
});
|
||||
QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
|
||||
timeout.start(timeoutMs);
|
||||
send();
|
||||
if (answer.isEmpty()) loop.exec();
|
||||
if (answer.isEmpty() || !vesc.isPortConnected()) {
|
||||
throw ExchangeFailure({{"requested_command", command}, {"timeout_ms", timeoutMs},
|
||||
{"elapsed_ms", elapsed.nsecsElapsed() / 1e6}, {"request_emitted", emitted},
|
||||
{"packets_sent", packetsSent}, {"packets_received", packetsReceived},
|
||||
{"serial_bytes_written", double(bytesWritten)}, {"port_connected", vesc.isPortConnected()},
|
||||
{"serial_open", serial && serial->isOpen()}, {"serial_error", serial ? int(serial->error()) : -1},
|
||||
{"serial_bytes_pending", serial ? double(serial->bytesToWrite()) : -1}, {"events", events}});
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
QByteArray query(int code, int timeoutMs, bool internal = false) {
|
||||
auto cmd = vesc.commands();
|
||||
require(internal || !procedureRunning || code == COMM_GET_VALUES || code == COMM_GET_DECODED_PPM,
|
||||
"Configuration reads are unavailable during native measurement");
|
||||
std::function<void()> send;
|
||||
switch (code) {
|
||||
case COMM_FW_VERSION: send = [=] { cmd->getFwVersion(); }; break;
|
||||
case COMM_GET_VALUES: send = [=] { cmd->getValues(); }; break;
|
||||
case COMM_GET_MCCONF: send = [=] { cmd->getMcconf(); }; break;
|
||||
case COMM_GET_APPCONF: send = [=] { cmd->getAppConf(); }; break;
|
||||
case COMM_GET_DECODED_PPM: send = [=] { cmd->getDecodedPpm(); }; break;
|
||||
case COMM_PING_CAN: send = [=] { cmd->pingCan(); }; break;
|
||||
default: throw std::runtime_error("Native read command is not admitted");
|
||||
}
|
||||
auto raw = exchange(code, timeoutMs, send);
|
||||
if (code == COMM_GET_MCCONF || code == COMM_GET_APPCONF) {
|
||||
VByteArray serialized;
|
||||
auto config = code == COMM_GET_MCCONF ? vesc.mcConfig() : vesc.appConfig();
|
||||
config->serialize(serialized);
|
||||
require(serialized == raw.mid(1), "Native configuration decode is not byte-exact");
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
QByteArray configurationPacket(ConfigParams *config, int code) {
|
||||
VByteArray raw; raw.vbAppendInt8(code); config->serialize(raw); return raw;
|
||||
}
|
||||
|
||||
void calibrate(double loss) {
|
||||
ConfigParams beforeMotor, beforeApp;
|
||||
beforeMotor = *vesc.mcConfig(); beforeApp = *vesc.appConfig();
|
||||
bool received = false, validated = false;
|
||||
int code = -1000;
|
||||
QString report, error;
|
||||
QJsonArray changed;
|
||||
auto connection = QObject::connect(vesc.commands(), &Commands::detectAllFocReceived,
|
||||
[&](int result) { received = true; code = result; });
|
||||
try {
|
||||
// The actual upstream wizard motor procedure, including its FW 5.02
|
||||
// power-loss correction. Do not infer a battery profile from Ah/voltage.
|
||||
report = Utility::detectAllFoc(&vesc, false, loss,
|
||||
beforeMotor.getParamDouble("l_in_current_min"), beforeMotor.getParamDouble("l_in_current_max"),
|
||||
beforeMotor.getParamDouble("foc_openloop_rpm"), beforeMotor.getParamDouble("foc_sl_erpm"));
|
||||
require(received, "Native calibration completion was not received");
|
||||
query(COMM_GET_MCCONF, 3000, true);
|
||||
query(COMM_GET_APPCONF, 3000, true);
|
||||
auto mc = vesc.mcConfig(); auto app = vesc.appConfig();
|
||||
const QStringList admitted = {"l_current_max", "l_current_min", "motor_type", "foc_motor_r",
|
||||
"foc_motor_l", "foc_motor_flux_linkage", "foc_current_kp", "foc_current_ki", "foc_observer_gain",
|
||||
"foc_sensor_mode", "m_sensor_port_mode", "foc_encoder_offset", "foc_encoder_ratio", "foc_encoder_inverted",
|
||||
"foc_hall_table__0", "foc_hall_table__1", "foc_hall_table__2", "foc_hall_table__3",
|
||||
"foc_hall_table__4", "foc_hall_table__5", "foc_hall_table__6", "foc_hall_table__7"};
|
||||
for (const auto &key : beforeMotor.checkDifference(mc)) {
|
||||
require(admitted.contains(key), "Calibration changed a protected motor parameter");
|
||||
changed.append(key);
|
||||
}
|
||||
for (const auto &key : beforeApp.checkDifference(app))
|
||||
require(key == "send_can_status", "Calibration changed a protected receiver parameter");
|
||||
// Native FW 5.02 enables CAN status as a side effect. Restore the
|
||||
// original application exactly; retain existing PPM and CAN identity.
|
||||
if (!beforeApp.checkDifference(app).isEmpty()) {
|
||||
*app = beforeApp;
|
||||
const auto expected = configurationPacket(app, COMM_GET_APPCONF);
|
||||
require(exchange(COMM_SET_APPCONF, 3000, [&] { vesc.commands()->setAppConf(); }).size() == 1,
|
||||
"Application write ACK invalid");
|
||||
require(query(COMM_GET_APPCONF, 3000, true) == expected, "Application restore not byte-exact");
|
||||
}
|
||||
if (code < 0) {
|
||||
// A completed failed detection can leave partial RAM changes.
|
||||
// Only the known calibration fields passed the guard above.
|
||||
*mc = beforeMotor;
|
||||
} else {
|
||||
for (const auto &key : {"foc_motor_r", "foc_motor_l", "foc_motor_flux_linkage"})
|
||||
require(std::isfinite(mc->getParamDouble(key)) && mc->getParamDouble(key) > 0,
|
||||
"Invalid detected motor parameter");
|
||||
require(mc->getParamDouble("l_current_max") > 0 && mc->getParamDouble("l_current_min") < 0,
|
||||
"Invalid detected current limits");
|
||||
// Calibration must not silently increase the owner's existing
|
||||
// current limits. The separate spin test applies its own 30 A cap.
|
||||
mc->updateParamDouble("l_current_max", qMin(mc->getParamDouble("l_current_max"), beforeMotor.getParamDouble("l_current_max")));
|
||||
mc->updateParamDouble("l_current_min", qMax(mc->getParamDouble("l_current_min"), beforeMotor.getParamDouble("l_current_min")));
|
||||
}
|
||||
const auto expected = configurationPacket(mc, COMM_GET_MCCONF);
|
||||
if (expected != lastMotor) {
|
||||
require(exchange(COMM_SET_MCCONF, 3000, [&] { vesc.commands()->setMcconf(false); }).size() == 1,
|
||||
"Motor write ACK invalid");
|
||||
require(query(COMM_GET_MCCONF, 3000, true) == expected, "Motor write not byte-exact");
|
||||
}
|
||||
validated = true;
|
||||
} catch (const std::exception &e) { error = e.what(); }
|
||||
QObject::disconnect(connection);
|
||||
QJsonObject parameters;
|
||||
for (const auto &key : {"l_current_max", "l_current_min", "foc_motor_r", "foc_motor_l", "foc_motor_flux_linkage"})
|
||||
parameters.insert(key, vesc.mcConfig()->getParamDouble(key));
|
||||
procedure = {{"kind", "foc"}, {"completed", received}, {"success", received && code >= 0 && validated},
|
||||
{"validated", validated}, {"code", code}, {"report", report}, {"error", error},
|
||||
{"sensor_mode", vesc.mcConfig()->getParamEnum("foc_sensor_mode")}, {"parameters", parameters},
|
||||
{"changed", changed}, {"upstream", "Utility::detectAllFoc"}};
|
||||
procedureUncertain = !validated;
|
||||
procedureRunning = false;
|
||||
}
|
||||
|
||||
double number(const QJsonObject &request, const QString &name, double min, double max) {
|
||||
auto v = request.value(name);
|
||||
require(v.isDouble() && std::isfinite(v.toDouble()) && v.toDouble() >= min && v.toDouble() <= max,
|
||||
"Numeric argument is outside operation bounds");
|
||||
return v.toDouble();
|
||||
}
|
||||
|
||||
void flush() {
|
||||
auto serial = vesc.findChild<QSerialPort *>();
|
||||
require(serial && serial->isOpen(), "Serial transport closed");
|
||||
serial->flush();
|
||||
if (serial->bytesToWrite() > 0) require(serial->waitForBytesWritten(40), "Serial write not confirmed");
|
||||
require(serial->bytesToWrite() == 0, "Serial write is incomplete");
|
||||
}
|
||||
|
||||
QJsonObject dispatch(const QJsonObject &request) {
|
||||
const auto method = request.value("method").toString();
|
||||
if (method == "engine") return {{"version", "7.00"}, {"commit", "01d5f10901116c311e3fb84d5a1541f663d3ce20"},
|
||||
{"connected", vesc.isPortConnected()}, {"hardware_enabled", allowHardware},
|
||||
{"legacy_power_loss_correction", vesc.commands()->getMaxPowerLossBug()}};
|
||||
require(allowHardware && vesc.isPortConnected(), "Native device is not connected");
|
||||
if (method == "query") return {{"payload", QString::fromLatin1(query(
|
||||
int(number(request, "command", 0, 255)), int(number(request, "timeout_ms", 20, 8000))).toBase64())}};
|
||||
if (method == "procedure_result") return {{"running", procedureRunning}, {"uncertain", procedureUncertain}, {"result", procedure}};
|
||||
if (method == "lease") { vesc.commands()->disableAppOutput(250, false); flush(); return {}; }
|
||||
if (method == "release") { vesc.commands()->setCurrent(0); flush(); return {}; }
|
||||
require(!procedureRunning && !procedureUncertain, "Native procedure owns this controller");
|
||||
if (method == "current") {
|
||||
auto current = number(request, "current_a", 0, 30);
|
||||
require(current <= vesc.mcConfig()->getParamDouble("l_current_max"), "Configured current limit exceeded");
|
||||
vesc.commands()->setCurrent(current); flush(); return {};
|
||||
}
|
||||
if (method == "rpm") { vesc.commands()->setRpm(int(number(request, "erpm", 0, 3000))); flush(); return {}; }
|
||||
if (method == "limits") {
|
||||
auto p = request.value("parameters").toObject();
|
||||
MCCONF_TEMP conf;
|
||||
conf.current_min_scale = number(p, "l_current_min_scale", 0, 1);
|
||||
conf.current_max_scale = number(p, "l_current_max_scale", 0, 1);
|
||||
// Restore/application may only change current scales. All other
|
||||
// values must equal the last native read, including battery limits.
|
||||
auto mc = vesc.mcConfig();
|
||||
for (const auto &key : {"l_min_erpm", "l_max_erpm", "l_min_duty", "l_max_duty", "l_watt_min", "l_watt_max", "l_in_current_min", "l_in_current_max"})
|
||||
require(p.value(key).isDouble() && p.value(key).toDouble() == mc->getParamDouble(key), "Only volatile current scales may change");
|
||||
conf.erpm_or_speed_min = mc->getParamDouble("l_min_erpm");
|
||||
conf.erpm_or_speed_max = mc->getParamDouble("l_max_erpm");
|
||||
conf.duty_min = mc->getParamDouble("l_min_duty"); conf.duty_max = mc->getParamDouble("l_max_duty");
|
||||
conf.watt_min = mc->getParamDouble("l_watt_min"); conf.watt_max = mc->getParamDouble("l_watt_max");
|
||||
auto ack = exchange(COMM_SET_MCCONF_TEMP, 2000, [&] {
|
||||
vesc.commands()->setMcconfTemp(conf, false, false, false, false, true);
|
||||
});
|
||||
require(ack.size() == 1, "Invalid native limits ACK"); return {};
|
||||
}
|
||||
if (method == "configuration") {
|
||||
auto motor = query(COMM_GET_MCCONF, 2000);
|
||||
auto application = query(COMM_GET_APPCONF, 2000);
|
||||
return {{"motor", exportConfig(vesc.mcConfig(), motor, "MCConfiguration")},
|
||||
{"application", exportConfig(vesc.appConfig(), application, "APPConfiguration")}};
|
||||
}
|
||||
if (method == "foc_start") {
|
||||
const auto loss = number(request, "max_power_loss_w", 10, 150);
|
||||
require(!lastMotor.isEmpty() && !lastApplication.isEmpty(), "Read configurations before calibration");
|
||||
for (const auto &key : {"l_in_current_min", "l_in_current_max", "foc_openloop_rpm", "foc_sl_erpm"})
|
||||
require(std::isfinite(vesc.mcConfig()->getParamDouble(key)) && std::abs(vesc.mcConfig()->getParamDouble(key)) > 0.001,
|
||||
"Zero-valued detection inputs require an explicit equipment profile");
|
||||
procedureRunning = true; procedure = {{"kind", "foc"}};
|
||||
QTimer::singleShot(0, [this, loss] { calibrate(loss); });
|
||||
return {{"started", true}, {"interruptible", false}};
|
||||
}
|
||||
if (method == "hall_start") {
|
||||
require(request.value("current_a") == 5, "This Hall profile uses 5 A");
|
||||
procedureRunning = true; procedure = {}; lastHall.clear();
|
||||
QTimer::singleShot(0, [&] {
|
||||
auto measured = Utility::measureHallFocBlocking(&vesc, 5.0);
|
||||
QJsonArray table;
|
||||
for (int i = 1; i < measured.size(); ++i) table.append(measured[i]);
|
||||
const bool completed = measured.size() == 9 && measured.first() != -10;
|
||||
procedure = {{"kind", "hall"}, {"completed", completed},
|
||||
{"status", measured.isEmpty() ? -10 : measured.first()}, {"table", table},
|
||||
{"payload", QString::fromLatin1(lastHall.toBase64())},
|
||||
{"upstream", "Utility::measureHallFocBlocking"}};
|
||||
procedureUncertain = !completed;
|
||||
procedureRunning = false;
|
||||
});
|
||||
return {{"started", true}, {"interruptible", false}};
|
||||
}
|
||||
throw std::runtime_error("Native operation is not admitted");
|
||||
}
|
||||
};
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
qputenv("QT_QPA_PLATFORM", "offscreen");
|
||||
QApplication application(argc, argv);
|
||||
QCoreApplication::setOrganizationName("MissionCore");
|
||||
QCoreApplication::setApplicationName("VescToolEngine");
|
||||
try {
|
||||
require(argc == 2, "One exact serial port or --offline argument is required");
|
||||
Engine engine;
|
||||
const QString port = QString::fromLocal8Bit(argv[1]);
|
||||
if (port != "--offline") engine.open(port);
|
||||
QFile output; output.open(stdout, QIODevice::WriteOnly);
|
||||
auto write = [&](const QJsonObject &value) {
|
||||
output.write(QJsonDocument(value).toJson(QJsonDocument::Compact) + '\n'); output.flush();
|
||||
};
|
||||
write({{"ready", true}, {"engine", engine.dispatch({{"method", "engine"}})}});
|
||||
QByteArray buffer;
|
||||
fcntl(STDIN_FILENO, F_SETFL, fcntl(STDIN_FILENO, F_GETFL) | O_NONBLOCK);
|
||||
QSocketNotifier input(STDIN_FILENO, QSocketNotifier::Read);
|
||||
QObject::connect(&input, &QSocketNotifier::activated, [&] {
|
||||
char chunk[4096]; const auto size = ::read(STDIN_FILENO, chunk, sizeof(chunk));
|
||||
if (size == 0) { application.quit(); return; }
|
||||
if (size < 0) return;
|
||||
buffer.append(chunk, int(size));
|
||||
if (buffer.size() > 65536) { application.exit(2); return; }
|
||||
int end;
|
||||
while ((end = buffer.indexOf('\n')) >= 0) {
|
||||
auto raw = buffer.left(end); buffer.remove(0, end + 1);
|
||||
QJsonParseError error;
|
||||
auto document = QJsonDocument::fromJson(raw, &error);
|
||||
auto request = document.object();
|
||||
QJsonObject response{{"id", request.value("id")}};
|
||||
try {
|
||||
require(error.error == QJsonParseError::NoError && document.isObject(), "Invalid JSON request");
|
||||
response.insert("result", engine.dispatch(request)); response.insert("ok", true);
|
||||
} catch (const ExchangeFailure &e) {
|
||||
response.insert("ok", false); response.insert("error", e.what());
|
||||
response.insert("diagnostics", e.diagnostics);
|
||||
} catch (const std::exception &e) { response.insert("ok", false); response.insert("error", e.what()); }
|
||||
write(response);
|
||||
}
|
||||
});
|
||||
return application.exec();
|
||||
} catch (const std::exception &error) {
|
||||
fprintf(stderr, "Native engine startup failed: %s\n", error.what());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Mission Core adapter to the unmodified VESC Tool engine. No hardware transport
|
||||
// is admitted by this executable. Input is one archived snapshot on stdin.
|
||||
#include "config_export.h"
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
// This application never calls connectSerial/connectTcp/connectBle or starts
|
||||
// a Qt event loop. Upstream timers therefore cannot reconnect or poll.
|
||||
qputenv("QT_QPA_PLATFORM", "offscreen");
|
||||
QApplication app(argc, argv);
|
||||
QCoreApplication::setOrganizationName("MissionCore");
|
||||
QCoreApplication::setApplicationName("VescToolOfflineAdapter");
|
||||
QJsonObject result{{"schema", "missioncore.vesc.native-offline/v1"},
|
||||
{"upstream_commit", "01d5f10901116c311e3fb84d5a1541f663d3ce20"},
|
||||
{"upstream_version", QString::number(VT_VERSION, 'f', 2)},
|
||||
{"hardware_access", false}};
|
||||
try {
|
||||
require(argc == 1, "No command-line transport or operation arguments are accepted");
|
||||
QFile input; require(input.open(stdin, QIODevice::ReadOnly), "Cannot read input");
|
||||
const auto raw = input.read(1024 * 1024 + 1);
|
||||
require(!raw.isEmpty() && raw.size() <= 1024 * 1024, "Archive input is empty or too large");
|
||||
QJsonParseError parse;
|
||||
auto document = QJsonDocument::fromJson(raw, &parse);
|
||||
require(parse.error == QJsonParseError::NoError && document.isObject(), "Invalid archive JSON");
|
||||
const auto archive = document.object();
|
||||
const auto identity = archive.value("identity").toObject();
|
||||
FW_RX_PARAMS fw;
|
||||
fw.major = identity.value("major").toInt(-1);
|
||||
fw.minor = identity.value("minor").toInt(-1);
|
||||
fw.hw = identity.value("hardware").toString();
|
||||
fw.isTestFw = identity.value("test_firmware").toInt(-1);
|
||||
fw.customConfigNum = identity.value("custom_configs").toInt(-1);
|
||||
const auto uuid = identity.value("uuid").toString();
|
||||
require(QRegularExpression("^[0-9a-fA-F]{24}$").match(uuid).hasMatch(), "Invalid archived UUID");
|
||||
fw.uuid = QByteArray::fromHex(uuid.toLatin1());
|
||||
require(fw.major == 5 && fw.minor == 2 && fw.isTestFw == 0 && fw.customConfigNum == 0
|
||||
&& identity.value("hardware_type").toInt(-1) == 0,
|
||||
"Offline acceptance currently admits stable firmware 5.02 only");
|
||||
VescInterface vesc;
|
||||
require(Utility::configLoadLatest(&vesc), "Bundled upstream configuration resources missing");
|
||||
auto *commands = vesc.commands();
|
||||
// Replay the archived identity through the real firmware negotiation
|
||||
// signal. The real engine selects bundled schemas and compatibility.
|
||||
commands->fwVersionReceived(fw);
|
||||
require(!vesc.isPortConnected(), "Offline adapter unexpectedly connected");
|
||||
require(vesc.mcConfig()->getSerializeOrder().size() > 0, "Native schema not selected");
|
||||
bool failed = false;
|
||||
QObject::connect(commands, &Commands::deserializeConfigFailed,
|
||||
[&](bool, bool) { failed = true; });
|
||||
auto motor = archivePacket(archive, "motor", COMM_GET_MCCONF);
|
||||
auto application = archivePacket(archive, "application", COMM_GET_APPCONF);
|
||||
// FW 5.02 has fixed-size configurations. Obtain their size through the
|
||||
// public native serializer; do not reproduce the private schema walker.
|
||||
VByteArray motorShape, applicationShape;
|
||||
vesc.mcConfig()->serialize(motorShape);
|
||||
vesc.appConfig()->serialize(applicationShape);
|
||||
require(motor.size() == motorShape.size() + 1 && application.size() == applicationShape.size() + 1,
|
||||
"Archive length differs from the native firmware schema");
|
||||
commands->processPacket(motor);
|
||||
commands->processPacket(application);
|
||||
require(!failed, "Upstream rejected archived configuration");
|
||||
result.insert("motor", exportConfig(vesc.mcConfig(), motor, "MCConfiguration"));
|
||||
result.insert("application", exportConfig(vesc.appConfig(), application, "APPConfiguration"));
|
||||
// A disconnected serializer-only probe demonstrates that FW-specific
|
||||
// detect corrections execute upstream. This byte array goes nowhere.
|
||||
QByteArray encodedDetect;
|
||||
QObject::connect(commands, &Commands::dataToSend, [&](QByteArray &data) {
|
||||
if (!data.isEmpty() && quint8(data.at(0)) == COMM_DETECT_APPLY_ALL_FOC) encodedDetect = data;
|
||||
});
|
||||
commands->detectAllFoc(false, 100.0, 0.0, 0.0, 0.0, 0.0);
|
||||
require(!encodedDetect.isEmpty() && !vesc.isPortConnected(), "Offline serialization failed");
|
||||
result.insert("compatibility", QJsonObject{
|
||||
{"legacy_power_loss_correction", commands->getMaxPowerLossBug()},
|
||||
{"offline_detect_example_base64", QString::fromLatin1(encodedDetect.toBase64())},
|
||||
{"example_requested_power_loss_w", 100.0}, {"transmitted_to_hardware", false}});
|
||||
result.insert("archive_sha256", sha256(raw));
|
||||
result.insert("ok", true);
|
||||
} catch (const std::exception &error) {
|
||||
result = QJsonObject{{"schema", "missioncore.vesc.native-offline/v1"},
|
||||
{"ok", false}, {"hardware_access", false}, {"error", error.what()}};
|
||||
}
|
||||
QFile output; output.open(stdout, QIODevice::WriteOnly);
|
||||
output.write(QJsonDocument(result).toJson(QJsonDocument::Compact) + '\n');
|
||||
return result.value("ok").toBool() ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Exact observed candidate descriptor. Firmware identity is verified by the reader.
|
||||
SUBSYSTEM=="tty", ATTRS{idVendor}=="0483", ATTRS{idProduct}=="5740", ATTRS{product}=="ChibiOS/RT Virtual COM Port", GROUP="mission-core-vesc", MODE="0660", ENV{ID_MM_DEVICE_IGNORE}="1", ENV{ID_MM_PORT_IGNORE}="1"
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Create the deterministic, self-contained native-engine qualification job."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
|
||||
def build(output):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
files = {"__main__.py": (root / "packaging/native_probe.py").read_bytes(),
|
||||
"offline_main.cpp": (root / "native/offline_main.cpp").read_bytes(),
|
||||
"config_export.h": (root / "native/config_export.h").read_bytes(),
|
||||
"engine_main.cpp": (root / "native/engine_main.cpp").read_bytes(),
|
||||
"native_bundle.py": (root / "packaging/native_bundle.py").read_bytes()}
|
||||
identity = hashlib.sha256(b"".join(k.encode() + v for k, v in sorted(files.items()))).hexdigest()[:24]
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
path = output / ("mission-core-vesc-native-probe-" + identity + ".pyz")
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as bundle:
|
||||
for name, data in files.items():
|
||||
entry = zipfile.ZipInfo(name, (2026, 9, 23, 0, 0, 0))
|
||||
entry.external_attr = 0o600 << 16
|
||||
bundle.writestr(entry, data)
|
||||
result = {"id": identity, "artifact": str(path.resolve()),
|
||||
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "bytes": path.stat().st_size}
|
||||
(output / "current-artifact.json").write_text(json.dumps(result, indent=2) + "\n")
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
print(json.dumps(build(parser.parse_args().output)))
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Discard only generated VESC bytecode before starting an upgraded profile.
|
||||
|
||||
Deterministic packages reuse file mtimes; a same-size Python source update
|
||||
can otherwise validate an old timestamp-based pyc, even with python -B.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
|
||||
def clear(root):
|
||||
for cache in root.rglob("__pycache__"):
|
||||
if cache.is_symlink():
|
||||
cache.unlink()
|
||||
elif cache.is_dir():
|
||||
shutil.rmtree(cache)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.argv[1:]:
|
||||
raise ValueError("This installer step accepts no paths or arguments")
|
||||
clear(Path("/usr/lib/mission-core-vesc"))
|
||||
@@ -0,0 +1,8 @@
|
||||
[Unit]
|
||||
Description=Prepare the versioned VESC read profile
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 -I -B /usr/lib/mission-core-vesc/prepare.py
|
||||
TimeoutStartSec=90
|
||||
UMask=0022
|
||||
@@ -0,0 +1,37 @@
|
||||
[Unit]
|
||||
Description=Mission Core VESC Tool service
|
||||
Wants=modprobe@cdc_acm.service
|
||||
After=systemd-udev-settle.service modprobe@cdc_acm.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=mission-core-vesc
|
||||
Group=mission-core-node
|
||||
SupplementaryGroups=mission-core-vesc
|
||||
WorkingDirectory=/usr/lib/mission-core-vesc
|
||||
ExecStart=/usr/bin/python3 -B -m runtime.server
|
||||
RuntimeDirectory=mission-core-vesc
|
||||
RuntimeDirectoryMode=0750
|
||||
StateDirectory=mission-core-vesc
|
||||
StateDirectoryMode=0700
|
||||
UMask=0007
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
NoNewPrivileges=yes
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
PrivateNetwork=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictAddressFamilies=AF_UNIX
|
||||
DevicePolicy=closed
|
||||
DeviceAllow=char-ttyACM rw
|
||||
MemoryMax=512M
|
||||
TasksMax=64
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,676 @@
|
||||
{
|
||||
"schema": "missioncore.vesc.native-runtime/v1",
|
||||
"upstream_version": "7.00",
|
||||
"upstream_commit": "01d5f10901116c311e3fb84d5a1541f663d3ce20",
|
||||
"os": "ubuntu-24.04-amd64",
|
||||
"file": "mission-core-vesc-native-runtime.tar.gz",
|
||||
"bytes": 50352726,
|
||||
"sha256": "cc78c273e025f0738ed79f94d4bc6a70e504f2e7204b1ef694119e6a230b913a",
|
||||
"engine_sha256": "9d2d6a87032e3c60f2666cadd5d5d1520edac05087e0dcb7a5ac3875f2ace325",
|
||||
"files": {
|
||||
"bin/mission-core-vesc-engine": {
|
||||
"sha256": "9d2d6a87032e3c60f2666cadd5d5d1520edac05087e0dcb7a5ac3875f2ace325",
|
||||
"bytes": 24801544
|
||||
},
|
||||
"lib/libGL.so.1": {
|
||||
"sha256": "67f471213576d225d38347a0b6d2a08a231980685301ff6461bd74d3994e5027",
|
||||
"bytes": 547136
|
||||
},
|
||||
"lib/libGLX.so.0": {
|
||||
"sha256": "16fc8a37eea9210dc83c57eeff5aedc10ab4c6673f2f97e8bb6ee103df657b40",
|
||||
"bytes": 137792
|
||||
},
|
||||
"lib/libGLdispatch.so.0": {
|
||||
"sha256": "ca01a91104c8887b3d8e59499b58cbb8f604cc285666b50d9ec888eb0c915182",
|
||||
"bytes": 719304
|
||||
},
|
||||
"lib/libQt5Bluetooth.so.5": {
|
||||
"sha256": "7e409c8ba1153801671343a454a3aee4943504f2d7a9d1077c67ff46dc7a9831",
|
||||
"bytes": 915320
|
||||
},
|
||||
"lib/libQt5Core.so.5": {
|
||||
"sha256": "f3a46b3517fdcd82d70b9a2c01ba707499860e2eab328bb98445d8afce59ca37",
|
||||
"bytes": 5699976
|
||||
},
|
||||
"lib/libQt5DBus.so.5": {
|
||||
"sha256": "1122d2c92c03fca880e2e8e7de4eaf15fc577f75012362e8f3a76a38a3ce07ab",
|
||||
"bytes": 595528
|
||||
},
|
||||
"lib/libQt5Gamepad.so.5": {
|
||||
"sha256": "364e27bb1a7dd64ff974ab6442a844beb729006da53912581674c478ac5dc4d7",
|
||||
"bytes": 130144
|
||||
},
|
||||
"lib/libQt5Gui.so.5": {
|
||||
"sha256": "75b66a3edcbc7f3013b4180b76919b49c22ce652372eba45ead8aac9121625e0",
|
||||
"bytes": 7256408
|
||||
},
|
||||
"lib/libQt5Network.so.5": {
|
||||
"sha256": "8b3062321c31ddb2c0d96f2c7a7761e086fc4ef2d0d52dbf1119a03d0079ec64",
|
||||
"bytes": 1749216
|
||||
},
|
||||
"lib/libQt5Positioning.so.5": {
|
||||
"sha256": "267929ff49f2ecfbfed7951f3a2cd50985dc2d96f1d8f5ca689615bb6ee5f2b6",
|
||||
"bytes": 590448
|
||||
},
|
||||
"lib/libQt5PrintSupport.so.5": {
|
||||
"sha256": "818b07d6b46ede7e935f97c523231c97b79b44004723b8c1a17e40739337767d",
|
||||
"bytes": 486696
|
||||
},
|
||||
"lib/libQt5Qml.so.5": {
|
||||
"sha256": "e57364d9d0d366824064539a49f4cb3d8fdd284a15db9e3d164ca1ac9a1f64f3",
|
||||
"bytes": 4729856
|
||||
},
|
||||
"lib/libQt5QmlModels.so.5": {
|
||||
"sha256": "68bebbb6db95909aa2262aa2fa6cfd23547565faaa5bb72c49cfe027ac9caf0b",
|
||||
"bytes": 567600
|
||||
},
|
||||
"lib/libQt5Quick.so.5": {
|
||||
"sha256": "a2d2e45f415baa2c9a251844dd45f67d1d10cf6f15c6270dd49c0d348a431260",
|
||||
"bytes": 5737928
|
||||
},
|
||||
"lib/libQt5QuickWidgets.so.5": {
|
||||
"sha256": "8e3947729ac17448ed59a0a0a74960d9f09244a2d084c976b1c4af342f87d82e",
|
||||
"bytes": 92384
|
||||
},
|
||||
"lib/libQt5SerialPort.so.5": {
|
||||
"sha256": "67305251921c0a1d737b90d72b365cc937ca665c6ee218575e2555e85c63dcdb",
|
||||
"bytes": 97680
|
||||
},
|
||||
"lib/libQt5Widgets.so.5": {
|
||||
"sha256": "8e03815f581781fc8b0ef931335493f8f2fcb48ee855f0be2258bbfbd06e62ee",
|
||||
"bytes": 7057536
|
||||
},
|
||||
"lib/libX11.so.6": {
|
||||
"sha256": "c5b5d782bd9cab3420a62df88f5c991507edf3331a89f98464ddbc538c37b879",
|
||||
"bytes": 1298088
|
||||
},
|
||||
"lib/libXau.so.6": {
|
||||
"sha256": "8040da3f8516c1acfe39f04ff022480cdb273705ba5e7a4ca70bbdb1527cd67a",
|
||||
"bytes": 18696
|
||||
},
|
||||
"lib/libXdmcp.so.6": {
|
||||
"sha256": "667d97d6da16016400ab10de9f83ef4ab209ceb6adf68ff9650225ec13e723b0",
|
||||
"bytes": 26776
|
||||
},
|
||||
"lib/libbrotlicommon.so.1": {
|
||||
"sha256": "a91ead095d2c80520c55a89057bbe10b031a075340442e63f44b310f93883a1b",
|
||||
"bytes": 141640
|
||||
},
|
||||
"lib/libbrotlidec.so.1": {
|
||||
"sha256": "64d8a5019d4c294b89fde1193343ea324bbd8603652554e5545f0a01595fa2c5",
|
||||
"bytes": 51512
|
||||
},
|
||||
"lib/libbsd.so.0": {
|
||||
"sha256": "e86cd4f0019f42c2ba5e60602e0edc8694d2948377d98c3654d0f6bddb5254bb",
|
||||
"bytes": 80888
|
||||
},
|
||||
"lib/libbz2.so.1.0": {
|
||||
"sha256": "cc08c9f50a8009ffd6391e0116a100369b11ca9238fa52392c019e6645b122a1",
|
||||
"bytes": 78944
|
||||
},
|
||||
"lib/libcap.so.2": {
|
||||
"sha256": "6ac6abc86ac891c6e13486470e26f1d939f47fda9e6b5d5508a7f5ec881adc84",
|
||||
"bytes": 51536
|
||||
},
|
||||
"lib/libcom_err.so.2": {
|
||||
"sha256": "022943b3b11c860b049bce41342f1c2594941b7b401d95dfdf235521099fee08",
|
||||
"bytes": 18504
|
||||
},
|
||||
"lib/libdbus-1.so.3": {
|
||||
"sha256": "a6ae7b4ef48562b40d7b9ba8efd2e49f6528b7cc6364bea382dcee4b473b5413",
|
||||
"bytes": 317752
|
||||
},
|
||||
"lib/libdouble-conversion.so.3": {
|
||||
"sha256": "d1c9583dc7c1fce6f0a0701dd4356448425e45afe15c0946c23edeaf93d9397c",
|
||||
"bytes": 79952
|
||||
},
|
||||
"lib/libexpat.so.1": {
|
||||
"sha256": "ec6c12d33bb8f9d0e90804121adf19930f36b1b2a4aeb6e1a454b89c7a50c801",
|
||||
"bytes": 186624
|
||||
},
|
||||
"lib/libfontconfig.so.1": {
|
||||
"sha256": "a94b4059b27766f563894c8f7e61762b6f6b2e25c59ef36b730164d4b75c6c98",
|
||||
"bytes": 325712
|
||||
},
|
||||
"lib/libfreetype.so.6": {
|
||||
"sha256": "c14c53c5baff12afafb610c6312fb879e9bb77e80dd42e27504d52d6d8bcd059",
|
||||
"bytes": 833608
|
||||
},
|
||||
"lib/libgcc_s.so.1": {
|
||||
"sha256": "d93224d2b0dab4247598be683adca02f5cf00586f99c187579cd7e92058fb7cb",
|
||||
"bytes": 183024
|
||||
},
|
||||
"lib/libgcrypt.so.20": {
|
||||
"sha256": "6ad6d7007ee1ad8319eb18ba9a512cf09dda49396a303774c68b47477020bad4",
|
||||
"bytes": 1345072
|
||||
},
|
||||
"lib/libglib-2.0.so.0": {
|
||||
"sha256": "96ef9163aee942bdc09e6f4a1acd2fd6b178c03af824c569741440d63ac9f4f4",
|
||||
"bytes": 1343056
|
||||
},
|
||||
"lib/libgpg-error.so.0": {
|
||||
"sha256": "6cb18a007bfcb623029f4528a36b46578fea7dc34c2b505b8e6e0d99e6348cd1",
|
||||
"bytes": 149760
|
||||
},
|
||||
"lib/libgraphite2.so.3": {
|
||||
"sha256": "fcfaf843b25b58b88319ced52f826fc8213a661f3693c916f88641eddffe05b8",
|
||||
"bytes": 149776
|
||||
},
|
||||
"lib/libgssapi_krb5.so.2": {
|
||||
"sha256": "6c1b81696044d79a47d6f0f494ee60aaf641c525c46a11da0c6e4a041a782d3c",
|
||||
"bytes": 338696
|
||||
},
|
||||
"lib/libharfbuzz.so.0": {
|
||||
"sha256": "4562cfcfd18935324ba3ac74a944898b867ab87a56e925ab3e351ba773f1513e",
|
||||
"bytes": 1101752
|
||||
},
|
||||
"lib/libicudata.so.74": {
|
||||
"sha256": "ddbb3718b8bd9cbd780e5ab08b4503c30a6c4fa0706ebe5d074ed6b596c1714e",
|
||||
"bytes": 30795392
|
||||
},
|
||||
"lib/libicui18n.so.74": {
|
||||
"sha256": "3550b194eb2cf2e6f798f033eb9ca279d498c21296b4a18790ce158d2023e47b",
|
||||
"bytes": 3455304
|
||||
},
|
||||
"lib/libicuuc.so.74": {
|
||||
"sha256": "7560aadde38e5f4237a47a1ddd5891f9b36768a77a60faae30beee003ac01901",
|
||||
"bytes": 2140336
|
||||
},
|
||||
"lib/libk5crypto.so.3": {
|
||||
"sha256": "73bc9d72c0c684d6149a3c38f96aab891d178375415c05954fdda587685936f5",
|
||||
"bytes": 178648
|
||||
},
|
||||
"lib/libkeyutils.so.1": {
|
||||
"sha256": "f48214417757f18793ed6e180cc14ee1d6f04252a518fc7270e8ca1d0b4260fe",
|
||||
"bytes": 22600
|
||||
},
|
||||
"lib/libkrb5.so.3": {
|
||||
"sha256": "9615a2841f0783c410eec7fae005a951282551cfedd377737ad8431c8c8cde64",
|
||||
"bytes": 823488
|
||||
},
|
||||
"lib/libkrb5support.so.0": {
|
||||
"sha256": "0aa43578471faecbd642ed2ee6ab92b6682a2b676644193e956fc59d18ca24bb",
|
||||
"bytes": 47904
|
||||
},
|
||||
"lib/liblz4.so.1": {
|
||||
"sha256": "40bffd0a098387368b16b992abd5f7cf43c0fa2f05cabe5a6d483719554adfda",
|
||||
"bytes": 137440
|
||||
},
|
||||
"lib/liblzma.so.5": {
|
||||
"sha256": "696e868dd0700a19a6d65fc01608ec2d70d3cb91f65710e89180cd2e688f30cb",
|
||||
"bytes": 202904
|
||||
},
|
||||
"lib/libmd.so.0": {
|
||||
"sha256": "423e18586b6ea740f4465afd64f7a9a4cb7264ed979c8e7256f9085af5345fef",
|
||||
"bytes": 55536
|
||||
},
|
||||
"lib/libmd4c.so.0": {
|
||||
"sha256": "d5f418d0ea9aec6b41efbb924580a1fb61e9113bcae36d5ad3f322269822d1b3",
|
||||
"bytes": 67656
|
||||
},
|
||||
"lib/libpcre2-16.so.0": {
|
||||
"sha256": "4dfa8a4023270763b8ca1654dc4f59f835393b1ae78085a19369228f8109a671",
|
||||
"bytes": 572064
|
||||
},
|
||||
"lib/libpcre2-8.so.0": {
|
||||
"sha256": "e00576d71d81d3ba0cfa4903c835a44a8723aac96f72f79ff75200b4cff9071b",
|
||||
"bytes": 625344
|
||||
},
|
||||
"lib/libpng16.so.16": {
|
||||
"sha256": "eac265b3506df0d9110dd9143e1d0503e2daabafe1d98b23fac8ca17b71d1f6b",
|
||||
"bytes": 223304
|
||||
},
|
||||
"lib/libstdc++.so.6": {
|
||||
"sha256": "1fd75fe70354a416d75aef22bcae68c47bd25d20e2d0568c30b1a9838cf62f11",
|
||||
"bytes": 2592224
|
||||
},
|
||||
"lib/libsystemd.so.0": {
|
||||
"sha256": "bdf59c828b547bcdbe7b3576c0810d9d9d2e982d8b61b365812542da6a4c4a99",
|
||||
"bytes": 910592
|
||||
},
|
||||
"lib/libudev.so.1": {
|
||||
"sha256": "4298228175fa62a36af88b1afd4406cdd8b1bf166621f957d0dbbde45a698fe5",
|
||||
"bytes": 207288
|
||||
},
|
||||
"lib/libxcb.so.1": {
|
||||
"sha256": "7958a0136b121bdc4c708968569ad152a9ed208ab026e2537b1005dde64ca440",
|
||||
"bytes": 162392
|
||||
},
|
||||
"lib/libz.so.1": {
|
||||
"sha256": "86200da370f20476a2507e9097a789b5ef97269b4ca8d5e164ad82dab9d99892",
|
||||
"bytes": 113000
|
||||
},
|
||||
"lib/libzstd.so.1": {
|
||||
"sha256": "0a2128bc10841fb29e76d08d945864dfb0b6a66da5df6df5d8299197439e54bb",
|
||||
"bytes": 755864
|
||||
},
|
||||
"licenses/UPSTREAM-SOURCE.txt": {
|
||||
"sha256": "ff2fbc975e47bffd1e3c488e14f1fbe417c83db82fb5ab41e16a9c1da96edb2a",
|
||||
"bytes": 293
|
||||
},
|
||||
"licenses/VESC-Tool-LICENSE": {
|
||||
"sha256": "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986",
|
||||
"bytes": 35149
|
||||
},
|
||||
"licenses/config_export.h": {
|
||||
"sha256": "f0c483aa27b9910bab1bff4f7baf66fe4077461e65a1d3e11169c4a722212756",
|
||||
"bytes": 4724
|
||||
},
|
||||
"licenses/engine_main.cpp": {
|
||||
"sha256": "e789004d14185140bda21c10a9d7d8f0b48174a9b5053ec1d70755842b1888de",
|
||||
"bytes": 20913
|
||||
},
|
||||
"licenses/gir1.2-glib-2.0.copyright": {
|
||||
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||
"bytes": 54957
|
||||
},
|
||||
"licenses/gir1.2-gudev-1.0.copyright": {
|
||||
"sha256": "e8208610819fd05fca6ea4776156858bb934e29c408ae9bdac4a8266a53178e3",
|
||||
"bytes": 1056
|
||||
},
|
||||
"licenses/libblkid-dev.copyright": {
|
||||
"sha256": "3492fb92cb56cf517d9a899828b4f989038421a38736e49e5525ffbd33196eab",
|
||||
"bytes": 23160
|
||||
},
|
||||
"licenses/libbrotli-dev.copyright": {
|
||||
"sha256": "24a64e5bb83d0960d1835696a1e23c0896ad6055b0ca47c66ab0eb9a766324b1",
|
||||
"bytes": 1354
|
||||
},
|
||||
"licenses/libdouble-conversion3.copyright": {
|
||||
"sha256": "1cc0b36cdfe5a674e11cb9907a88291c7602d3805bddd334cc07d57231a0cd00",
|
||||
"bytes": 1999
|
||||
},
|
||||
"licenses/libegl-dev.copyright": {
|
||||
"sha256": "37920f219f60efa2fde5d003c51be0608b2159cc74c5eb6d70d630907db2d9a3",
|
||||
"bytes": 4422
|
||||
},
|
||||
"licenses/libevdev-dev.copyright": {
|
||||
"sha256": "01d0da4919f92dd06e7ec73fee6cf5dccac3c6e475917b9dc57cb22800b732de",
|
||||
"bytes": 5460
|
||||
},
|
||||
"licenses/libexpat1-dev.copyright": {
|
||||
"sha256": "60919fe1a156395ff14511bb6ff79756c50ade2fff59ac60c9bae1d8a7fe6292",
|
||||
"bytes": 1756
|
||||
},
|
||||
"licenses/libexpat1.copyright": {
|
||||
"sha256": "60919fe1a156395ff14511bb6ff79756c50ade2fff59ac60c9bae1d8a7fe6292",
|
||||
"bytes": 1756
|
||||
},
|
||||
"licenses/libfontconfig-dev.copyright": {
|
||||
"sha256": "b215a61cdd3e62b5b17cc28b1852c78acb3dd38be0fb30706f7efc050dba91db",
|
||||
"bytes": 1301
|
||||
},
|
||||
"licenses/libfreetype-dev.copyright": {
|
||||
"sha256": "ce6d766883ea111e7f47acc09e9d49be8827daa6a03f2c2707243a425d41f0e9",
|
||||
"bytes": 31209
|
||||
},
|
||||
"licenses/libgirepository-2.0-0.copyright": {
|
||||
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||
"bytes": 54957
|
||||
},
|
||||
"licenses/libgl-dev.copyright": {
|
||||
"sha256": "37920f219f60efa2fde5d003c51be0608b2159cc74c5eb6d70d630907db2d9a3",
|
||||
"bytes": 4422
|
||||
},
|
||||
"licenses/libglib2.0-0t64.copyright": {
|
||||
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||
"bytes": 54957
|
||||
},
|
||||
"licenses/libglib2.0-bin.copyright": {
|
||||
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||
"bytes": 54957
|
||||
},
|
||||
"licenses/libglib2.0-data.copyright": {
|
||||
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||
"bytes": 54957
|
||||
},
|
||||
"licenses/libglib2.0-dev-bin.copyright": {
|
||||
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||
"bytes": 54957
|
||||
},
|
||||
"licenses/libglib2.0-dev.copyright": {
|
||||
"sha256": "b52574b109fb876dfa71dea7a98b8aee1bb583f6aa90c327d1e4dc0a1d59f211",
|
||||
"bytes": 54957
|
||||
},
|
||||
"licenses/libglu1-mesa-dev.copyright": {
|
||||
"sha256": "7802232600641c113e2948fbc2feae6de45f26af59a43c28836e0ef846f8dd94",
|
||||
"bytes": 4055
|
||||
},
|
||||
"licenses/libglx-dev.copyright": {
|
||||
"sha256": "37920f219f60efa2fde5d003c51be0608b2159cc74c5eb6d70d630907db2d9a3",
|
||||
"bytes": 4422
|
||||
},
|
||||
"licenses/libgudev-1.0-dev.copyright": {
|
||||
"sha256": "e8208610819fd05fca6ea4776156858bb934e29c408ae9bdac4a8266a53178e3",
|
||||
"bytes": 1056
|
||||
},
|
||||
"licenses/libinput-bin.copyright": {
|
||||
"sha256": "f075af6e7319a471289bc1908aae04e44ba954ea53b11fcf42ee8d5422f40363",
|
||||
"bytes": 2292
|
||||
},
|
||||
"licenses/libinput-dev.copyright": {
|
||||
"sha256": "f075af6e7319a471289bc1908aae04e44ba954ea53b11fcf42ee8d5422f40363",
|
||||
"bytes": 2292
|
||||
},
|
||||
"licenses/libinput10.copyright": {
|
||||
"sha256": "f075af6e7319a471289bc1908aae04e44ba954ea53b11fcf42ee8d5422f40363",
|
||||
"bytes": 2292
|
||||
},
|
||||
"licenses/libmd4c0.copyright": {
|
||||
"sha256": "68e5ce452a6fc2bee44279ca61a7064950d146481e325eeaece6c9bea095fd2f",
|
||||
"bytes": 23273
|
||||
},
|
||||
"licenses/libmount-dev.copyright": {
|
||||
"sha256": "3492fb92cb56cf517d9a899828b4f989038421a38736e49e5525ffbd33196eab",
|
||||
"bytes": 23160
|
||||
},
|
||||
"licenses/libmtdev-dev.copyright": {
|
||||
"sha256": "7ca89f7e6e0ab15b9941aa80def7c50b22a7bd6356dd1eacf28ca4382f72a6de",
|
||||
"bytes": 1628
|
||||
},
|
||||
"licenses/libopengl-dev.copyright": {
|
||||
"sha256": "37920f219f60efa2fde5d003c51be0608b2159cc74c5eb6d70d630907db2d9a3",
|
||||
"bytes": 4422
|
||||
},
|
||||
"licenses/libpcre2-16-0.copyright": {
|
||||
"sha256": "030511beb4d9d620ad09914c369c36ec0528dcf301d1923cc643c948ee7c6a38",
|
||||
"bytes": 6626
|
||||
},
|
||||
"licenses/libpcre2-dev.copyright": {
|
||||
"sha256": "030511beb4d9d620ad09914c369c36ec0528dcf301d1923cc643c948ee7c6a38",
|
||||
"bytes": 6626
|
||||
},
|
||||
"licenses/libpcre2-posix3.copyright": {
|
||||
"sha256": "030511beb4d9d620ad09914c369c36ec0528dcf301d1923cc643c948ee7c6a38",
|
||||
"bytes": 6626
|
||||
},
|
||||
"licenses/libpkgconf3.copyright": {
|
||||
"sha256": "676c75e54ff7e3b9892940fbf1b31763cb6f2a6527a5607a3640ac59a2441fba",
|
||||
"bytes": 7501
|
||||
},
|
||||
"licenses/libpng-dev.copyright": {
|
||||
"sha256": "4620d402b97601a910946acccbbe2e15bdffac11615bf9046520990d3b00f2b9",
|
||||
"bytes": 13051
|
||||
},
|
||||
"licenses/libpthread-stubs0-dev.copyright": {
|
||||
"sha256": "e45b85577d0f6883300ccfb004ab79e1a4f2cf3777b64eb989525115af400b5a",
|
||||
"bytes": 1849
|
||||
},
|
||||
"licenses/libqt5bluetooth5-bin.copyright": {
|
||||
"sha256": "937c1479a92f32fbd29957b9339b35e9edb7c85be3be6543c8c7495ea799933e",
|
||||
"bytes": 7982
|
||||
},
|
||||
"licenses/libqt5bluetooth5.copyright": {
|
||||
"sha256": "937c1479a92f32fbd29957b9339b35e9edb7c85be3be6543c8c7495ea799933e",
|
||||
"bytes": 7982
|
||||
},
|
||||
"licenses/libqt5concurrent5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5core5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5dbus5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5gamepad5-dev.copyright": {
|
||||
"sha256": "eb1b57f89c6b2c7e25472e1e59c796ed6bc7ff5c5eb08845a95fe9653f781c5b",
|
||||
"bytes": 3446
|
||||
},
|
||||
"licenses/libqt5gamepad5.copyright": {
|
||||
"sha256": "eb1b57f89c6b2c7e25472e1e59c796ed6bc7ff5c5eb08845a95fe9653f781c5b",
|
||||
"bytes": 3446
|
||||
},
|
||||
"licenses/libqt5gui5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5network5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5nfc5.copyright": {
|
||||
"sha256": "937c1479a92f32fbd29957b9339b35e9edb7c85be3be6543c8c7495ea799933e",
|
||||
"bytes": 7982
|
||||
},
|
||||
"licenses/libqt5positioning5-plugins.copyright": {
|
||||
"sha256": "2d54a4f412d1a2c1f5138b06a94a6cdd445e267227fb0526fc376ab2e7d9b63f",
|
||||
"bytes": 18782
|
||||
},
|
||||
"licenses/libqt5positioning5.copyright": {
|
||||
"sha256": "2d54a4f412d1a2c1f5138b06a94a6cdd445e267227fb0526fc376ab2e7d9b63f",
|
||||
"bytes": 18782
|
||||
},
|
||||
"licenses/libqt5positioningquick5.copyright": {
|
||||
"sha256": "2d54a4f412d1a2c1f5138b06a94a6cdd445e267227fb0526fc376ab2e7d9b63f",
|
||||
"bytes": 18782
|
||||
},
|
||||
"licenses/libqt5printsupport5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5qml5.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/libqt5qmlmodels5.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/libqt5qmlworkerscript5.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/libqt5quick5.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/libqt5quickcontrols2-5.copyright": {
|
||||
"sha256": "334854f9488a33d1211ded54478a35071369a4addaf86d2581820025b244ae03",
|
||||
"bytes": 10979
|
||||
},
|
||||
"licenses/libqt5quickparticles5.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/libqt5quickshapes5.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/libqt5quicktemplates2-5.copyright": {
|
||||
"sha256": "334854f9488a33d1211ded54478a35071369a4addaf86d2581820025b244ae03",
|
||||
"bytes": 10979
|
||||
},
|
||||
"licenses/libqt5quicktest5.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/libqt5quickwidgets5.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/libqt5serialport5-dev.copyright": {
|
||||
"sha256": "679e7d434baaf5b3b80e3f7764d6e57a7a971f11693f8d0420f1de5dc7696a6b",
|
||||
"bytes": 6220
|
||||
},
|
||||
"licenses/libqt5serialport5.copyright": {
|
||||
"sha256": "679e7d434baaf5b3b80e3f7764d6e57a7a971f11693f8d0420f1de5dc7696a6b",
|
||||
"bytes": 6220
|
||||
},
|
||||
"licenses/libqt5sql5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5svg5-dev.copyright": {
|
||||
"sha256": "71bc825205f218db925d601e2c4cee8f1cdd36ae07ece4e63736bfb0afe0453f",
|
||||
"bytes": 8476
|
||||
},
|
||||
"licenses/libqt5svg5.copyright": {
|
||||
"sha256": "71bc825205f218db925d601e2c4cee8f1cdd36ae07ece4e63736bfb0afe0453f",
|
||||
"bytes": 8476
|
||||
},
|
||||
"licenses/libqt5test5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5widgets5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libqt5xml5t64.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/libselinux1-dev.copyright": {
|
||||
"sha256": "864f1bb189f609075d580b8c4aada16d85a44546884f1687989e1cacc8c56751",
|
||||
"bytes": 1957
|
||||
},
|
||||
"licenses/libsepol-dev.copyright": {
|
||||
"sha256": "78d2a34606a0302057ec499a3b3c07bcbc43ca334064ce4c80891a0f69c8f6c6",
|
||||
"bytes": 3750
|
||||
},
|
||||
"licenses/libudev-dev.copyright": {
|
||||
"sha256": "a7d06854714a1ca99f6dbd1a1641dde5bcf28635be149f6554449618f8f427f3",
|
||||
"bytes": 12776
|
||||
},
|
||||
"licenses/libvulkan-dev.copyright": {
|
||||
"sha256": "c579213e28f67944a7e407816b8a8e1d2d2406b3d820e420aa923807c450dc07",
|
||||
"bytes": 1964
|
||||
},
|
||||
"licenses/libwacom-dev.copyright": {
|
||||
"sha256": "5026eb61394922e821cfea069fe9740141b1d6abd3ea900e79655ad45ea1cb8a",
|
||||
"bytes": 1624
|
||||
},
|
||||
"licenses/libx11-dev.copyright": {
|
||||
"sha256": "0b380a7fd5b2228f26e9585e56f14812efd3350f3df307507d2bc055dfd8de3e",
|
||||
"bytes": 47102
|
||||
},
|
||||
"licenses/libxau-dev.copyright": {
|
||||
"sha256": "118dd263a7b91c8f21c489f949bf13281dff9e766deea92b829dac4dce66601a",
|
||||
"bytes": 1224
|
||||
},
|
||||
"licenses/libxcb-xinerama0.copyright": {
|
||||
"sha256": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80",
|
||||
"bytes": 1781
|
||||
},
|
||||
"licenses/libxcb-xinput0.copyright": {
|
||||
"sha256": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80",
|
||||
"bytes": 1781
|
||||
},
|
||||
"licenses/libxcb1-dev.copyright": {
|
||||
"sha256": "4f7cb9db6bf6542f5417e3d674c780d3a5fd12291a54d63054fb576ee0cfae80",
|
||||
"bytes": 1781
|
||||
},
|
||||
"licenses/libxdmcp-dev.copyright": {
|
||||
"sha256": "1bcbb50f8603fe8b86d330bfa460b772ea04fbd3c70e4499f0768b5340b9fd6e",
|
||||
"bytes": 1265
|
||||
},
|
||||
"licenses/libxext-dev.copyright": {
|
||||
"sha256": "bc57e445ca1d9fe082c8d54189dd411ff26caa8552c9c63d44ea06a982f32124",
|
||||
"bytes": 10421
|
||||
},
|
||||
"licenses/libxkbcommon-dev.copyright": {
|
||||
"sha256": "5eeaeb1b6e029a0274e1573765bb0bae2926ef96a3679203faa4fd00fdaeaa88",
|
||||
"bytes": 3566
|
||||
},
|
||||
"licenses/pkgconf-bin.copyright": {
|
||||
"sha256": "676c75e54ff7e3b9892940fbf1b31763cb6f2a6527a5607a3640ac59a2441fba",
|
||||
"bytes": 7501
|
||||
},
|
||||
"licenses/pkgconf.copyright": {
|
||||
"sha256": "676c75e54ff7e3b9892940fbf1b31763cb6f2a6527a5607a3640ac59a2441fba",
|
||||
"bytes": 7501
|
||||
},
|
||||
"licenses/python3-packaging.copyright": {
|
||||
"sha256": "51fe4bbadf841c4e4d02ad97ba375bcde0ae11a51da62ae16fdcbe723d3cdad2",
|
||||
"bytes": 2444
|
||||
},
|
||||
"licenses/qt5-qmake-bin.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/qt5-qmake.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/qt5-qmltooling-plugins.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/qtbase5-dev-tools.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/qtbase5-dev.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/qtbase5-private-dev.copyright": {
|
||||
"sha256": "4839c591405c069c6daad29f578f0e9d09580d15571b4c1e6bb7bbc0902b8c08",
|
||||
"bytes": 131975
|
||||
},
|
||||
"licenses/qtchooser.copyright": {
|
||||
"sha256": "0b3fa692b33acfbb5b9539335c66938c957350b2a24d2fec35de89e09198738e",
|
||||
"bytes": 5193
|
||||
},
|
||||
"licenses/qtconnectivity5-dev.copyright": {
|
||||
"sha256": "937c1479a92f32fbd29957b9339b35e9edb7c85be3be6543c8c7495ea799933e",
|
||||
"bytes": 7982
|
||||
},
|
||||
"licenses/qtdeclarative5-dev-tools.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/qtdeclarative5-dev.copyright": {
|
||||
"sha256": "5efc270a798372057f7ee88c14cf4a7cb41289607f2a15a5c5aa7d26809eddd0",
|
||||
"bytes": 44601
|
||||
},
|
||||
"licenses/qtpositioning5-dev.copyright": {
|
||||
"sha256": "2d54a4f412d1a2c1f5138b06a94a6cdd445e267227fb0526fc376ab2e7d9b63f",
|
||||
"bytes": 18782
|
||||
},
|
||||
"licenses/qtquickcontrols2-5-dev.copyright": {
|
||||
"sha256": "334854f9488a33d1211ded54478a35071369a4addaf86d2581820025b244ae03",
|
||||
"bytes": 10979
|
||||
},
|
||||
"licenses/uuid-dev.copyright": {
|
||||
"sha256": "3492fb92cb56cf517d9a899828b4f989038421a38736e49e5525ffbd33196eab",
|
||||
"bytes": 23160
|
||||
},
|
||||
"licenses/x11proto-dev.copyright": {
|
||||
"sha256": "7b40446cf2035abc6836c7a7f411ec79153dbf4530bce209e26aa2fd7c4dd55a",
|
||||
"bytes": 3963
|
||||
},
|
||||
"licenses/xorg-sgml-doctools.copyright": {
|
||||
"sha256": "f8f02d5cfd7d4ed0eb6c46deacb3a64c1fa5bc60e06db10ecbf202e6fe1d5a89",
|
||||
"bytes": 2271
|
||||
},
|
||||
"licenses/xtrans-dev.copyright": {
|
||||
"sha256": "29e6f06b1dcd85f1bc4b3e9374b92967cb2a274abde00169784f1dd1c7c95431",
|
||||
"bytes": 6364
|
||||
},
|
||||
"licenses/zlib1g-dev.copyright": {
|
||||
"sha256": "9e5b96d63773a5d177ba264254390f792be07e41748ebd94730981c6cac31cc6",
|
||||
"bytes": 2927
|
||||
},
|
||||
"plugins/platforms/libqoffscreen.so": {
|
||||
"sha256": "f2f19a29e816c7e5c60fd52b9d3c1a214634c38e06cd2ffdf9fc88e9f0c49ffd",
|
||||
"bytes": 193880
|
||||
}
|
||||
},
|
||||
"host_libraries": [
|
||||
"ld-linux-x86-64.so.2",
|
||||
"libc.so.6",
|
||||
"libdl.so.2",
|
||||
"libm.so.6",
|
||||
"libpthread.so.0",
|
||||
"libresolv.so.2",
|
||||
"librt.so.1"
|
||||
],
|
||||
"offline_verified": true,
|
||||
"hardware_qualified": false,
|
||||
"clean_os_qualified": false
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Bundle an admitted native engine and the actual ELF dependency closure.
|
||||
|
||||
Private, installer-owned Qt runtime; never installs packages on the build host.
|
||||
The target is Ubuntu 24.04 amd64. Only its glibc family stays a host prerequisite.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
|
||||
SYSTEM = {"libc.so.6", "libm.so.6", "libpthread.so.0", "libdl.so.2", "librt.so.1", "libresolv.so.2", "ld-linux-x86-64.so.2"}
|
||||
|
||||
|
||||
def build(engine, sysroot, source, output):
|
||||
output.mkdir(mode=0o700, exist_ok=False)
|
||||
staging = output / "payload"
|
||||
(staging / "bin").mkdir(parents=True)
|
||||
(staging / "lib").mkdir()
|
||||
(staging / "plugins/platforms").mkdir(parents=True)
|
||||
binary = staging / "bin/mission-core-vesc-engine"
|
||||
shutil.copyfile(engine, binary); binary.chmod(0o755)
|
||||
qtlib = sysroot / "usr/lib/x86_64-linux-gnu"
|
||||
plugin = qtlib / "qt5/plugins/platforms/libqoffscreen.so"
|
||||
shutil.copyfile(plugin, staging / "plugins/platforms/libqoffscreen.so")
|
||||
env = dict(os.environ, LD_LIBRARY_PATH=str(qtlib), LC_ALL="C")
|
||||
sources = {}
|
||||
for executable in (engine, plugin):
|
||||
result = subprocess.run(["/usr/bin/ldd", str(executable)], env=env, capture_output=True, text=True, check=True).stdout
|
||||
if "not found" in result: raise RuntimeError("Native runtime dependency missing")
|
||||
for name, path in re.findall(r"^\s*(\S+) => (/[\S]+) \(", result, re.MULTILINE):
|
||||
if name in SYSTEM: continue
|
||||
library = Path(path)
|
||||
if name in sources and sources[name] != library: raise RuntimeError("Conflicting dependency")
|
||||
sources[name] = library
|
||||
for name, library in sources.items(): shutil.copyfile(library, staging / "lib" / name)
|
||||
env.update(LD_LIBRARY_PATH=str(staging / "lib"), QT_PLUGIN_PATH=str(staging / "plugins"),
|
||||
QT_QPA_PLATFORM="offscreen", XDG_CONFIG_HOME=str(output / "config"), XDG_CACHE_HOME=str(output / "cache"))
|
||||
# All non-glibc ELF dependencies must now resolve inside the shipped payload.
|
||||
for executable in (binary, staging / "plugins/platforms/libqoffscreen.so"):
|
||||
result = subprocess.run(["/usr/bin/ldd", str(executable)], env=env, capture_output=True, text=True, check=True).stdout
|
||||
if "not found" in result: raise RuntimeError("Bundled native closure incomplete")
|
||||
for name, path in re.findall(r"^\s*(\S+) => (/[\S]+) \(", result, re.MULTILINE):
|
||||
if name not in SYSTEM and not Path(path).resolve().is_relative_to(staging):
|
||||
raise RuntimeError("Undeclared host dependency: " + name)
|
||||
proc = subprocess.run([str(binary), "--offline"], env=env, input=b'{"id":1,"method":"engine"}\n',
|
||||
capture_output=True, timeout=15, check=True)
|
||||
responses = [json.loads(line) for line in proc.stdout.splitlines()]
|
||||
if len(responses) != 2 or not responses[0]["ready"] or responses[1]["result"]["connected"]:
|
||||
raise RuntimeError("Bundled engine acceptance failed")
|
||||
(output / "offline.stdout").write_bytes(proc.stdout)
|
||||
(output / "offline.stderr").write_bytes(proc.stderr)
|
||||
(staging / "licenses").mkdir()
|
||||
shutil.copyfile(source / "LICENSE", staging / "licenses/VESC-Tool-LICENSE")
|
||||
(staging / "licenses/UPSTREAM-SOURCE.txt").write_text(
|
||||
"VESC Tool 7.00, unmodified upstream sources and resources:\n"
|
||||
"https://github.com/vedderb/vesc_tool/tree/01d5f10901116c311e3fb84d5a1541f663d3ce20\n"
|
||||
"Source archive SHA-256: 4565ecec4e41e094bcc43127a51970e810aa9080a9d7c025af64632bfd4c0189\n"
|
||||
"The process adapter source is included alongside this notice.\n")
|
||||
for name in ("engine_main.cpp", "config_export.h"):
|
||||
shutil.copyfile(engine.parent / name, staging / "licenses" / name)
|
||||
# Preserve dependency notices available in the private signed-package sysroot.
|
||||
for index, path in enumerate(sorted((sysroot / "usr/share/doc").glob("*/copyright"))):
|
||||
shutil.copyfile(path, staging / "licenses" / (path.parent.name + ".copyright"))
|
||||
# Host libraries copied into the closure retain their distribution notices.
|
||||
for library in sources.values():
|
||||
if library.resolve().is_relative_to(sysroot): continue
|
||||
owner = subprocess.run(["dpkg-query", "-S", str(library)], capture_output=True, text=True)
|
||||
if owner.returncode: continue
|
||||
package = owner.stdout.split(": ", 1)[0].split(":", 1)[0]
|
||||
notice = Path("/usr/share/doc") / package / "copyright"
|
||||
if notice.is_file(): shutil.copyfile(notice, staging / "licenses" / (package + ".copyright"))
|
||||
metadata = {str(p.relative_to(staging)): {"sha256": hashlib.sha256(p.read_bytes()).hexdigest(), "bytes":p.stat().st_size}
|
||||
for p in sorted(staging.rglob("*")) if p.is_file()}
|
||||
archive = output / "mission-core-vesc-native-runtime.tar.gz"
|
||||
with tarfile.open(archive, "w:gz") as stream:
|
||||
for path in sorted(staging.rglob("*")):
|
||||
if path.is_file(): stream.add(path, arcname=str(path.relative_to(staging)))
|
||||
report = {"schema":"missioncore.vesc.native-runtime/v1", "upstream_version":"7.00",
|
||||
"upstream_commit":"01d5f10901116c311e3fb84d5a1541f663d3ce20", "os":"ubuntu-24.04-amd64",
|
||||
"file":archive.name, "bytes":archive.stat().st_size, "sha256":hashlib.sha256(archive.read_bytes()).hexdigest(),
|
||||
"engine_sha256":hashlib.sha256(binary.read_bytes()).hexdigest(), "files":metadata,
|
||||
"host_libraries":sorted(SYSTEM), "offline_verified":True, "hardware_qualified":False,
|
||||
"clean_os_qualified":False}
|
||||
(output / "bundle.json").write_text(json.dumps(report,indent=2)+"\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser=argparse.ArgumentParser()
|
||||
for name in ("engine","sysroot","source","output"):parser.add_argument("--"+name,type=Path,required=True)
|
||||
args=parser.parse_args();build(args.engine,args.sysroot,args.source,args.output)
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Installer acceptance under the service account; never opens a USB device."""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
def check(root):
|
||||
manifest = json.loads((root / "manifest.json").read_text())
|
||||
for name, expected in manifest["files"].items():
|
||||
path = root / name
|
||||
if path.is_symlink() or not path.resolve().is_relative_to(root.resolve()):
|
||||
raise RuntimeError("Untrusted native runtime path")
|
||||
if path.stat().st_size != expected["bytes"] or hashlib.sha256(path.read_bytes()).hexdigest() != expected["sha256"]:
|
||||
raise RuntimeError("Native runtime integrity check failed")
|
||||
with tempfile.TemporaryDirectory(prefix="mission-core-vesc-check-") as temporary:
|
||||
env = {"PATH":"/usr/bin:/bin", "LANG":"C.UTF-8", "QT_QPA_PLATFORM":"offscreen",
|
||||
"LD_LIBRARY_PATH":str(root / "lib"), "QT_PLUGIN_PATH":str(root / "plugins"),
|
||||
"XDG_CONFIG_HOME":temporary, "XDG_CACHE_HOME":temporary}
|
||||
result = subprocess.run([str(root / "bin/mission-core-vesc-engine"), "--offline"],
|
||||
env=env, input=b'{"id":1,"method":"engine"}\n', capture_output=True, timeout=15, check=True)
|
||||
replies = [json.loads(line) for line in result.stdout.splitlines()]
|
||||
if (len(replies) != 2 or not replies[0]["ready"] or not replies[1]["ok"]
|
||||
or replies[1]["result"]["hardware_enabled"] or replies[1]["result"]["connected"]
|
||||
or replies[1]["result"]["commit"] != manifest["upstream_commit"]):
|
||||
raise RuntimeError("Native engine offline check failed")
|
||||
print(json.dumps({"ok":True, "upstream_commit":manifest["upstream_commit"], "hardware_access":False}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if os.geteuid() == 0: raise RuntimeError("Run as the VESC service account")
|
||||
check(Path(__file__).resolve().parent / "native")
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Versioned offline qualification artifact, not a runtime installer.
|
||||
|
||||
Reuse an attested upstream Tool build's object files unchanged. Only the adapter
|
||||
entry point is compiled. The previous staging, packages and services are untouched.
|
||||
All inputs, link objects, outputs and checks are hashed in the private report.
|
||||
"""
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
import zipfile
|
||||
|
||||
COMMIT = "01d5f10901116c311e3fb84d5a1541f663d3ce20"
|
||||
UPSTREAM_SHA256 = "4565ecec4e41e094bcc43127a51970e810aa9080a9d7c025af64632bfd4c0189"
|
||||
|
||||
|
||||
def digest(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def run(args):
|
||||
release = platform.freedesktop_os_release()
|
||||
if os.geteuid() == 0 or (release.get("ID"), release.get("VERSION_ID"), platform.machine()) != ("ubuntu", "24.04", "x86_64"):
|
||||
raise RuntimeError("Unprivileged Ubuntu 24.04 amd64 required")
|
||||
group = Path("/sys/fs/cgroup") / Path("/proc/self/cgroup").read_text().strip().split("::", 1)[1].lstrip("/")
|
||||
limit = (group / "memory.max").read_text().strip()
|
||||
if limit == "max" or int(limit) > 3 * 1024**3:
|
||||
raise RuntimeError("A bounded user scope with MemoryMax <= 3G is required")
|
||||
previous = json.loads(args.upstream_report.read_text())
|
||||
if previous.get("state") != "complete" or previous.get("source_sha256") != UPSTREAM_SHA256:
|
||||
raise RuntimeError("Unqualified upstream build")
|
||||
upstream = Path(previous["binary"])
|
||||
if digest(upstream) != previous["binary_sha256"]:
|
||||
raise RuntimeError("Upstream binary changed")
|
||||
source = upstream.parents[2]
|
||||
if source.name != "vesc_tool-" + COMMIT:
|
||||
raise RuntimeError("Upstream source path mismatch")
|
||||
os.umask(0o077)
|
||||
root = args.output.resolve()
|
||||
root.mkdir(parents=True, mode=0o700, exist_ok=False)
|
||||
# Paths enter a generated makefile, never a shell command assembled from JSON.
|
||||
if any(not re.fullmatch(r"[A-Za-z0-9_./-]+", str(p)) for p in (root, source)):
|
||||
raise RuntimeError("Build paths must be make-safe")
|
||||
with zipfile.ZipFile(args.artifact) as bundle:
|
||||
for name in ("offline_main.cpp", "config_export.h", "engine_main.cpp", "native_bundle.py"):
|
||||
(root / name).write_bytes(bundle.read(name))
|
||||
report = {"schema": "missioncore.vesc.native-probe/v1", "state": "running",
|
||||
"started_at": datetime.now(timezone.utc).isoformat(), "monotonic_started": time.monotonic(),
|
||||
"source_commit": COMMIT, "artifact_sha256": digest(args.artifact),
|
||||
"upstream_report_sha256": digest(args.upstream_report),
|
||||
"adapter_sha256": digest(root / "offline_main.cpp"),
|
||||
"hardware_access": False, "runtime_installed": False, "system_packages_installed": False,
|
||||
"jobs": [], "checks": []}
|
||||
|
||||
def publish():
|
||||
(root / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
|
||||
staging = source.parents[1]
|
||||
qtbase = staging / "sysroot/usr"
|
||||
env = dict(os.environ, LC_ALL="C", QT_QPA_PLATFORM="offscreen",
|
||||
LD_LIBRARY_PATH=str(qtbase / "lib/x86_64-linux-gnu"),
|
||||
QT_PLUGIN_PATH=str(qtbase / "lib/x86_64-linux-gnu/qt5/plugins"),
|
||||
XDG_CONFIG_HOME=str(root / "config"), XDG_CACHE_HOME=str(root / "cache"))
|
||||
|
||||
def execute(name, command, data=None, expected=0, timeout=60):
|
||||
start = time.monotonic()
|
||||
proc = subprocess.run(command, cwd=source, env=env, input=data,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
|
||||
(root / (name + ".stdout")).write_bytes(proc.stdout)
|
||||
(root / (name + ".stderr")).write_bytes(proc.stderr)
|
||||
report["jobs"].append({"id": name, "exit_code": proc.returncode,
|
||||
"duration_seconds": time.monotonic() - start,
|
||||
"stdout_sha256": hashlib.sha256(proc.stdout).hexdigest(),
|
||||
"stderr_sha256": hashlib.sha256(proc.stderr).hexdigest()})
|
||||
publish()
|
||||
if proc.returncode != expected:
|
||||
raise RuntimeError("Native probe step failed: " + name)
|
||||
return proc.stdout
|
||||
|
||||
try:
|
||||
makefile = (source / "Makefile").read_text().replace("\\\n", " ")
|
||||
match = re.search(r"^OBJECTS\s*=\s*(.+)$", makefile, re.MULTILINE)
|
||||
if not match:
|
||||
raise RuntimeError("Upstream link objects missing")
|
||||
objects = [source / name for name in match.group(1).split() if name != "build/lin/obj/main.o"]
|
||||
if not 100 < len(objects) < 1000 or any(not p.is_file() for p in objects):
|
||||
raise RuntimeError("Upstream object inventory incomplete")
|
||||
report["link_objects"] = [{"file": str(p.relative_to(source)), "sha256": digest(p)} for p in objects]
|
||||
report["upstream_makefile_sha256"] = digest(source / "Makefile")
|
||||
target = root / "mission-core-vesc-offline"
|
||||
wrapper = root / "Makefile.native"
|
||||
wrapper.write_text(
|
||||
"include " + str(source / "Makefile") + "\n"
|
||||
".PHONY: mission-core-native-probe\n"
|
||||
"mission-core-native-probe:\n"
|
||||
"\t$(CXX) -c $(CXXFLAGS) $(INCPATH) -o " + str(root / "offline_main.o") + " " + str(root / "offline_main.cpp") + "\n"
|
||||
"\t$(LINK) $(LFLAGS) -o " + str(target) + " " + str(root / "offline_main.o") +
|
||||
" $(filter-out build/lin/obj/main.o,$(OBJECTS)) $(OBJCOMP) $(LIBS)\n")
|
||||
execute("compile-link", ["/usr/bin/make", "-f", str(wrapper), "mission-core-native-probe"], timeout=180)
|
||||
report["binary_sha256"] = digest(target)
|
||||
for index, archive in enumerate(args.archives):
|
||||
raw = archive.read_bytes()
|
||||
native = json.loads(execute("archive-%d" % index, [str(target)], raw))
|
||||
if not native["ok"] or not native["compatibility"]["legacy_power_loss_correction"]:
|
||||
raise RuntimeError("Upstream compatibility check failed")
|
||||
import base64
|
||||
packet = base64.b64decode(native["compatibility"]["offline_detect_example_base64"])
|
||||
if packet[:2] != bytes([58, 0]) or int.from_bytes(packet[2:6], "big", signed=True) != 50000:
|
||||
raise RuntimeError("Expected native 5.02 detect correction was not applied")
|
||||
report["checks"].append({"id": "native-archive-%d" % index, "ok": True,
|
||||
"archive_sha256": hashlib.sha256(raw).hexdigest(),
|
||||
"motor_parameter_count": len(native["motor"]["parameters"]),
|
||||
"application_parameter_count": len(native["application"]["parameters"]),
|
||||
"binary_round_trip_exact": True,
|
||||
"xml_equivalent_by_upstream_comparison": True,
|
||||
"xml_binary_exact": all(native[k]["xml_round_trip_exact"] for k in ("motor", "application")),
|
||||
"legacy_power_loss_correction": True})
|
||||
# Fail closed on archive corruption and unsupported firmware instead
|
||||
# of silently presenting the bundled defaults as actual settings.
|
||||
original = json.loads(raw)
|
||||
damaged = json.loads(raw); damaged["configs"]["motor"]["sha256"] = "0" * 64
|
||||
unknown = json.loads(raw); unknown["identity"]["major"] = 99
|
||||
wrong_signature = json.loads(raw)
|
||||
payload = bytearray(base64.b64decode(original["configs"]["motor"]["payload"])); payload[1] ^= 1
|
||||
wrong_signature["configs"]["motor"]["payload"] = base64.b64encode(payload).decode()
|
||||
wrong_signature["configs"]["motor"]["sha256"] = hashlib.sha256(payload).hexdigest()
|
||||
truncated = json.loads(raw)
|
||||
payload = base64.b64decode(original["configs"]["motor"]["payload"])[:-1]
|
||||
truncated["configs"]["motor"].update(payload=base64.b64encode(payload).decode(),
|
||||
sha256=hashlib.sha256(payload).hexdigest(), bytes=len(payload))
|
||||
for name, value in (("corrupt", damaged), ("unsupported", unknown), ("signature", wrong_signature), ("truncated", truncated)):
|
||||
rejected = json.loads(execute("%s-%d" % (name, index), [str(target)], json.dumps(value).encode(), expected=1))
|
||||
if rejected["ok"] or rejected["hardware_access"]:
|
||||
raise RuntimeError("Invalid archive was not rejected")
|
||||
report["checks"].append({"id": "%s-%d" % (name, index), "ok": True})
|
||||
for item in report["link_objects"]:
|
||||
if digest(source / item["file"]) != item["sha256"]:
|
||||
raise RuntimeError("Upstream objects were modified")
|
||||
engine = root / "mission-core-vesc-engine"
|
||||
wrapper.write_text(wrapper.read_text() +
|
||||
"\n.PHONY: mission-core-native-engine\nmission-core-native-engine:\n"
|
||||
"\t$(CXX) -c $(CXXFLAGS) $(INCPATH) -o " + str(root / "engine_main.o") + " " + str(root / "engine_main.cpp") + "\n"
|
||||
"\t$(LINK) $(LFLAGS) -o " + str(engine) + " " + str(root / "engine_main.o") +
|
||||
" $(filter-out build/lin/obj/main.o,$(OBJECTS)) $(OBJCOMP) $(LIBS)\n")
|
||||
execute("engine-compile", ["/usr/bin/make", "-f", str(wrapper), "mission-core-native-engine"], timeout=180)
|
||||
requests = [{"id": 1, "method": "engine"}, {"id": 2, "method": "current", "current_a": 30},
|
||||
{"id": 3, "method": "hall_start", "current_a": 5}, {"id": 4, "method": "arbitrary_packet"}]
|
||||
responses = [json.loads(line) for line in execute("engine-offline", [str(engine), "--offline"],
|
||||
b"".join(json.dumps(r).encode()+b"\n" for r in requests)).splitlines()]
|
||||
if (len(responses) != 5 or not responses[0]["ready"] or not responses[1]["ok"]
|
||||
or any(r["ok"] for r in responses[2:]) or responses[1]["result"]["hardware_enabled"]):
|
||||
raise RuntimeError("Native engine offline boundary failed")
|
||||
report["checks"].append({"id": "engine-offline-denies-hardware", "ok": True})
|
||||
execute("runtime-bundle", ["/usr/bin/python3", str(root / "native_bundle.py"),
|
||||
"--engine", str(engine), "--sysroot", str(staging / "sysroot"), "--source", str(source), "--output", str(root / "runtime")], timeout=180)
|
||||
report["native_runtime"] = json.loads((root / "runtime/bundle.json").read_text())
|
||||
report.update(state="complete", binary=str(target), upstream_objects_unchanged=True)
|
||||
except Exception as error:
|
||||
report.update(state="error", error=str(error))
|
||||
raise
|
||||
finally:
|
||||
report["finished_at"] = datetime.now(timezone.utc).isoformat()
|
||||
report["duration_seconds"] = time.monotonic() - report["monotonic_started"]
|
||||
publish()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--artifact", type=Path, required=True)
|
||||
parser.add_argument("--upstream-report", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--archives", type=Path, nargs="+", required=True)
|
||||
run(parser.parse_args())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""The Node release carries this model profile from the first hardware use."""
|
||||
from pathlib import Path, PurePosixPath
|
||||
import hashlib
|
||||
import json
|
||||
import tarfile
|
||||
|
||||
|
||||
def payload():
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
files = [("usr/lib/mission-core-vesc/runtime/" + p.name, p.read_bytes(), 0o644)
|
||||
for p in sorted((root / "runtime").glob("*.py"))]
|
||||
files.extend(("usr/lib/mission-core-vesc/runtime/" + str(p.relative_to(root / "runtime")), p.read_bytes(), 0o644)
|
||||
for p in sorted((root / "runtime/schemas").rglob("*")) if p.is_file())
|
||||
files.append(("usr/lib/mission-core-vesc/runtime/archive.py",
|
||||
(root.parents[1] / "src/k1link/device_plugins/vesc/archive.py").read_bytes(), 0o644))
|
||||
for name in ("mission-core-vesc.service", "mission-core-node-vesc-prepare.service"):
|
||||
files.append(("usr/lib/systemd/system/" + name, (root / "packaging" / name).read_bytes(), 0o644))
|
||||
files.append(("usr/lib/mission-core-vesc/prepare.py", (root / "packaging/prepare.py").read_bytes(), 0o644))
|
||||
files.append(("usr/lib/mission-core-vesc/clear_runtime_cache.py", (root / "packaging/clear_runtime_cache.py").read_bytes(), 0o644))
|
||||
files.append(("usr/share/mission-core-node/profiles/vesc/70-mission-core-vesc.rules",
|
||||
(root / "packaging/70-mission-core-vesc.rules").read_bytes(), 0o644))
|
||||
manifest = json.loads((root / "packaging/native-runtime.json").read_text())
|
||||
bundle = root / "build/native-runtime" / manifest["file"]
|
||||
data = bundle.read_bytes()
|
||||
if len(data) != manifest["bytes"] or hashlib.sha256(data).hexdigest() != manifest["sha256"]:
|
||||
raise ValueError("Native VESC Tool bundle changed")
|
||||
seen = set()
|
||||
with tarfile.open(bundle, "r:gz") as archive:
|
||||
for member in archive.getmembers():
|
||||
path = PurePosixPath(member.name)
|
||||
if (not member.isfile() or path.is_absolute() or ".." in path.parts
|
||||
or path.as_posix() != member.name or member.name in seen
|
||||
or member.name not in manifest["files"]):
|
||||
raise ValueError("Unexpected native payload member")
|
||||
seen.add(member.name)
|
||||
expected = manifest["files"][member.name]
|
||||
if member.size != expected["bytes"] or member.size > 128 * 1024**2:
|
||||
raise ValueError("Native member size mismatch")
|
||||
content = archive.extractfile(member).read()
|
||||
if hashlib.sha256(content).hexdigest() != expected["sha256"]:
|
||||
raise ValueError("Native member hash mismatch")
|
||||
mode = 0o755 if member.name.startswith("bin/") else 0o644
|
||||
files.append(("usr/lib/mission-core-vesc/native/" + member.name, content, mode))
|
||||
if seen != set(manifest["files"]): raise ValueError("Incomplete native payload")
|
||||
files.append(("usr/lib/mission-core-vesc/native/manifest.json",
|
||||
(root / "packaging/native-runtime.json").read_bytes(), 0o644))
|
||||
files.append(("usr/lib/mission-core-vesc/native_check.py",
|
||||
(root / "packaging/native_check.py").read_bytes(), 0o644))
|
||||
return files
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Versioned, idempotent VESC profile; no packages downloaded and no motor I/O."""
|
||||
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from runtime.serial import discover
|
||||
from runtime.service import atomic
|
||||
|
||||
STATE = Path("/var/lib/mission-core-node-profiles/vesc")
|
||||
|
||||
|
||||
def prepare():
|
||||
if os.geteuid() != 0 or sys.argv[1:]:
|
||||
raise RuntimeError("Fixed system profile only")
|
||||
release = platform.freedesktop_os_release()
|
||||
if (release.get("ID"), release.get("VERSION_ID"), platform.machine()) != ("ubuntu", "24.04", "x86_64"):
|
||||
raise RuntimeError("Ubuntu 24.04 amd64 required")
|
||||
STATE.mkdir(mode=0o755, parents=True, exist_ok=True)
|
||||
for directory in (STATE.parent, STATE):
|
||||
info = directory.lstat()
|
||||
if directory.is_symlink() or info.st_uid != 0 or info.st_mode & 0o022:
|
||||
raise RuntimeError("Untrusted profile state")
|
||||
lock = os.open(STATE / "prepare.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
report = {"schema": "missioncore.node.device-preparation/v1", "model_id": "vesc.controller",
|
||||
"version": "0.6.3", "run_id": uuid.uuid4().hex, "started_at": time.time(),
|
||||
"monotonic_started": time.monotonic(), "state": "running", "steps": []}
|
||||
|
||||
def publish():
|
||||
atomic(STATE / "preparation.json", report)
|
||||
os.chmod(STATE / "preparation.json", 0o644)
|
||||
|
||||
def run(name, label, args):
|
||||
step = {"id": name, "label": label, "state": "running"}
|
||||
report["steps"].append(step)
|
||||
publish()
|
||||
result = subprocess.run(args, capture_output=True, timeout=45,
|
||||
env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8"})
|
||||
step["state"] = "complete" if result.returncode == 0 else "error"
|
||||
publish()
|
||||
if result.returncode:
|
||||
raise RuntimeError("Не завершён этап: " + label)
|
||||
|
||||
try:
|
||||
import pwd
|
||||
try:
|
||||
pwd.getpwnam("mission-core-vesc")
|
||||
except KeyError:
|
||||
run("account", "Подготовка доступа", ["/usr/sbin/adduser", "--system", "--group", "--home",
|
||||
"/var/lib/mission-core-vesc", "--no-create-home", "--disabled-login", "mission-core-vesc"])
|
||||
run("native", "Проверка VESC Tool", ["/usr/sbin/runuser", "-u", "mission-core-vesc", "--",
|
||||
"/usr/bin/python3", "-I", "/usr/lib/mission-core-vesc/native_check.py"])
|
||||
source = Path("/usr/share/mission-core-node/profiles/vesc/70-mission-core-vesc.rules")
|
||||
rules = source.read_bytes()
|
||||
report["udev_sha256"] = hashlib.sha256(rules).hexdigest()
|
||||
target = Path("/etc/udev/rules.d/70-mission-core-vesc.rules")
|
||||
if target.is_symlink():
|
||||
raise RuntimeError("Untrusted udev destination")
|
||||
target.write_bytes(rules)
|
||||
target.chmod(0o644)
|
||||
run("rules", "Настройка USB-доступа", ["/usr/bin/udevadm", "control", "--reload-rules"])
|
||||
for i, device in enumerate(discover()):
|
||||
run("usb" + str(i), "Применение USB-доступа", ["/usr/bin/udevadm", "trigger", "--action=change",
|
||||
"/sys/class/tty/" + device.tty])
|
||||
run("settle", "Проверка USB-доступа", ["/usr/bin/udevadm", "settle", "--timeout=10"])
|
||||
run("enable", "Подготовка службы", ["/usr/bin/systemctl", "enable", "mission-core-vesc.service"])
|
||||
run("runtime", "Запуск чтения контроллеров", ["/usr/bin/systemctl", "restart", "mission-core-vesc.service"])
|
||||
report["state"] = "complete"
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
|
||||
report.update(state="error", message=str(error)[:300])
|
||||
finally:
|
||||
report["duration_seconds"] = time.monotonic() - report["monotonic_started"]
|
||||
publish()
|
||||
os.close(lock)
|
||||
return report["state"] == "complete"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(0 if prepare() else 1)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Unprivileged VESC Tool build spike; never installs packages or opens a device.
|
||||
|
||||
Run in a bounded user systemd scope on Ubuntu 24.04 amd64. APT resolves and
|
||||
downloads signed Ubuntu packages into this job; dpkg-deb only extracts them.
|
||||
The output is an engineering build, not an installed or qualified runtime.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
COMMIT = "01d5f10901116c311e3fb84d5a1541f663d3ce20"
|
||||
ARCHIVE_SHA256 = "4565ecec4e41e094bcc43127a51970e810aa9080a9d7c025af64632bfd4c0189"
|
||||
DEPS = (
|
||||
"qtbase5-dev", "qtbase5-private-dev", "qtdeclarative5-dev",
|
||||
"qtquickcontrols2-5-dev", "libqt5serialport5-dev", "qtconnectivity5-dev",
|
||||
"qtpositioning5-dev", "libqt5gamepad5-dev", "libqt5svg5-dev",
|
||||
)
|
||||
|
||||
|
||||
def build(archive, root, dependency_cache=None, resume=None):
|
||||
if os.geteuid() == 0:
|
||||
raise RuntimeError("This build must not run as root")
|
||||
release = platform.freedesktop_os_release()
|
||||
if (release.get("ID"), release.get("VERSION_ID"), platform.machine()) != ("ubuntu", "24.04", "x86_64"):
|
||||
raise RuntimeError("Ubuntu 24.04 amd64 required")
|
||||
assert hashlib.sha256(archive.read_bytes()).hexdigest() == ARCHIVE_SHA256
|
||||
group = Path("/sys/fs/cgroup") / Path("/proc/self/cgroup").read_text().strip().split("::", 1)[1].lstrip("/")
|
||||
limit = (group / "memory.max").read_text().strip()
|
||||
if limit == "max" or int(limit) > 3 * 1024**3:
|
||||
raise RuntimeError("Run in a user scope with MemoryMax=3G")
|
||||
os.umask(0o077)
|
||||
root.mkdir(mode=0o700, parents=True, exist_ok=False)
|
||||
root = root.resolve()
|
||||
report = {"schema": "missioncore.vesc.tool-build/v1", "source_commit": COMMIT,
|
||||
"source_sha256": ARCHIVE_SHA256, "state": "running", "jobs": [],
|
||||
"started_at": datetime.now(timezone.utc).isoformat(), "monotonic_started": time.monotonic(),
|
||||
"hardware_access": False, "system_packages_installed": False, "packages": []}
|
||||
env = dict(os.environ, LC_ALL="C", DEBIAN_FRONTEND="noninteractive", QT_QPA_PLATFORM="offscreen")
|
||||
|
||||
def publish():
|
||||
(root / "report.json").write_text(json.dumps(report, indent=2) + "\n")
|
||||
|
||||
def run(name, args, cwd=None, timeout=600):
|
||||
job = {"id": name, "state": "running"}; report["jobs"].append(job); publish()
|
||||
started = time.monotonic()
|
||||
with (root / (name + ".stdout")).open("wb") as out, (root / (name + ".stderr")).open("wb") as err:
|
||||
result = subprocess.run(args, cwd=cwd or root, env=env, stdout=out, stderr=err, timeout=timeout)
|
||||
job.update(state="complete" if result.returncode == 0 else "error", exit_code=result.returncode,
|
||||
duration_seconds=time.monotonic() - started)
|
||||
publish()
|
||||
if result.returncode:
|
||||
raise RuntimeError("Build step failed: " + name)
|
||||
return (root / (name + ".stdout")).read_text()
|
||||
|
||||
try:
|
||||
staging = root
|
||||
if resume is not None:
|
||||
previous_raw = (resume / "report.json").read_bytes()
|
||||
previous = json.loads(previous_raw)
|
||||
if previous.get("source_sha256") != ARCHIVE_SHA256 or previous.get("error") != "Build step failed: compile":
|
||||
raise RuntimeError("Only this source's failed compile may resume")
|
||||
staging = resume.resolve()
|
||||
report["resume_report_sha256"] = hashlib.sha256(previous_raw).hexdigest()
|
||||
report["resumed_staging"] = str(staging)
|
||||
downloads = staging / "packages"
|
||||
if resume is None: downloads.mkdir()
|
||||
sysroot = staging / "sysroot"
|
||||
if resume is None: sysroot.mkdir()
|
||||
if resume is not None:
|
||||
report["packages"] = previous["packages"]
|
||||
for item in report["packages"]:
|
||||
if hashlib.sha256((downloads / item["file"]).read_bytes()).hexdigest() != item["sha256"]:
|
||||
raise RuntimeError("Resumed dependency changed")
|
||||
elif dependency_cache is not None:
|
||||
previous = json.loads((dependency_cache / "report.json").read_text())
|
||||
if previous.get("source_sha256") != ARCHIVE_SHA256 or not previous.get("packages"):
|
||||
raise RuntimeError("Unqualified dependency cache")
|
||||
for item in previous["packages"]:
|
||||
name = item["file"]
|
||||
if Path(name).name != name or not name.endswith(".deb"):
|
||||
raise RuntimeError("Invalid cached package name")
|
||||
package = dependency_cache / "packages" / name
|
||||
if hashlib.sha256(package.read_bytes()).hexdigest() != item["sha256"]:
|
||||
raise RuntimeError("Cached dependency changed")
|
||||
shutil.copyfile(package, downloads / name)
|
||||
report["dependency_cache_report_sha256"] = hashlib.sha256((dependency_cache / "report.json").read_bytes()).hexdigest()
|
||||
else:
|
||||
# Host lists may refer to superseded security packages. Refresh only
|
||||
# this job's signed Ubuntu indexes; never update /var/lib/apt or invoke
|
||||
# the host's update hooks (Timescale and other sources are irrelevant).
|
||||
aptdir = root / "apt"; aptdir.mkdir()
|
||||
for name in ("lists", "lists/partial", "archives", "archives/partial"):
|
||||
(aptdir / name).mkdir(exist_ok=True)
|
||||
sources = aptdir / "sources.list"
|
||||
sources.write_text("".join(
|
||||
"deb [signed-by=/usr/share/keyrings/ubuntu-archive-keyring.gpg] " + url + " " + suite + " main universe\n"
|
||||
for url, suite in (("https://archive.ubuntu.com/ubuntu", "noble"),
|
||||
("https://archive.ubuntu.com/ubuntu", "noble-updates"),
|
||||
("https://security.ubuntu.com/ubuntu", "noble-security"))))
|
||||
config = aptdir / "apt.conf"
|
||||
config.write_text(
|
||||
'Dir::Etc::Parts "-";\nDir::Etc::main "-";\n'
|
||||
'Dir::Etc::sourceparts "-";\nDir::Etc::sourcelist "' + str(sources) + '";\n'
|
||||
'Dir::State::lists "' + str(aptdir / "lists") + '";\n'
|
||||
'Dir::Cache::archives "' + str(aptdir / "archives") + '";\n'
|
||||
'Dir::Cache::pkgcache "";\nDir::Cache::srcpkgcache "";\n'
|
||||
'Acquire::Languages "none";\nDebug::NoLocking "true";\n'
|
||||
'#clear APT::Update::Post-Invoke;\n#clear APT::Update::Post-Invoke-Success;\n')
|
||||
env["APT_CONFIG"] = str(config)
|
||||
apt = ["/usr/bin/apt-get"]
|
||||
run("private-indexes", [*apt, "update"])
|
||||
plan = run("dependencies-plan", [*apt, "--simulate", "--no-install-recommends", "--no-remove", "install", *DEPS])
|
||||
packages = re.findall(r"^Inst (\S+)(?: \[[^\]]+\])? \((\S+)", plan, re.MULTILINE)
|
||||
if not packages or len(packages) > 150:
|
||||
raise RuntimeError("Unexpected dependency plan; inspect before changing profile")
|
||||
for index, (name, version) in enumerate(packages):
|
||||
# apt-get download verifies the archive against the host's trusted
|
||||
# repository metadata. No maintainer script or package install runs.
|
||||
run("download-%03d" % index, [*apt, "download", name + "=" + version], downloads)
|
||||
for index, package in enumerate(sorted(downloads.glob("*.deb")) if resume is None else []):
|
||||
report["packages"].append({"file": package.name, "sha256": hashlib.sha256(package.read_bytes()).hexdigest()})
|
||||
run("extract-%03d" % index, ["/usr/bin/dpkg-deb", "--extract", str(package), str(sysroot)])
|
||||
source = staging / "source"
|
||||
if resume is None:
|
||||
source.mkdir()
|
||||
with tarfile.open(archive) as stream:
|
||||
stream.extractall(source, filter="data")
|
||||
source = source / ("vesc_tool-" + COMMIT)
|
||||
qtbase = sysroot / "usr"
|
||||
# APT omits already-installed runtime packages. Complete the private
|
||||
# development symlinks from declared host libraries, recording provenance.
|
||||
report["host_libraries"] = []
|
||||
for name in ("libGL.so.1", "libGLX.so.0", "libGLU.so.1"):
|
||||
target = qtbase / "lib/x86_64-linux-gnu" / name
|
||||
host = Path("/usr/lib/x86_64-linux-gnu") / name
|
||||
if not target.exists() and host.exists():
|
||||
shutil.copyfile(host, target)
|
||||
report["host_libraries"].append({"source": str(host.resolve()), "sha256": hashlib.sha256(host.read_bytes()).hexdigest()})
|
||||
qtarch = qtbase / "lib/x86_64-linux-gnu/qt5"
|
||||
qtbin = qtbase / "lib/qt5/bin"
|
||||
qtconfig = "[Paths]\nPrefix=" + str(qtbase) + "\n" + "\n".join(
|
||||
name + "=" + str(path) for name, path in {
|
||||
"Headers": qtbase / "include/x86_64-linux-gnu/qt5",
|
||||
"Libraries": qtbase / "lib/x86_64-linux-gnu", "ArchData": qtarch,
|
||||
"HostData": qtarch, "Binaries": qtbin, "HostBinaries": qtbin,
|
||||
"Plugins": qtarch / "plugins", "Qml2Imports": qtarch / "qml",
|
||||
"Data": qtbase / "share/qt5",
|
||||
}.items()) + "\n"
|
||||
(qtbin / "qt.conf").write_text(qtconfig)
|
||||
env["LD_LIBRARY_PATH"] = str(qtbase / "lib/x86_64-linux-gnu")
|
||||
env["QT_PLUGIN_PATH"] = str(qtarch / "plugins")
|
||||
env["PKG_CONFIG_LIBDIR"] = str(qtbase / "lib/x86_64-linux-gnu/pkgconfig")
|
||||
env["PKG_CONFIG_SYSROOT_DIR"] = str(sysroot)
|
||||
run("qmake", [str(qtbin / "qmake"), "-config", "release", "CONFIG += release_lin build_original exclude_fw",
|
||||
"VT_GIT_COMMIT=" + COMMIT[:8], "INCLUDEPATH += " + str(qtbase / "include") + " " + str(qtbase / "include/x86_64-linux-gnu"),
|
||||
"QMAKE_LIBDIR += " + str(qtbase / "lib/x86_64-linux-gnu")], source)
|
||||
run("compile", ["/usr/bin/make", "-j2"], source, timeout=1800)
|
||||
binary = source / "build/lin/vesc_tool_7.00"
|
||||
result = run("version", [str(binary), "--version"], source, timeout=30)
|
||||
report.update(state="complete", binary=str(binary), binary_sha256=hashlib.sha256(binary.read_bytes()).hexdigest(),
|
||||
version_output=result, runtime_installed=False, hardware_qualified=False)
|
||||
except Exception as error:
|
||||
report.update(state="error", error=str(error))
|
||||
raise
|
||||
finally:
|
||||
report["duration_seconds"] = time.monotonic() - report["monotonic_started"]
|
||||
report["finished_at"] = datetime.now(timezone.utc).isoformat()
|
||||
publish()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--source", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--dependencies", type=Path)
|
||||
parser.add_argument("--resume", type=Path)
|
||||
args = parser.parse_args()
|
||||
build(args.source.resolve(), args.output, args.dependencies, args.resume)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Onboard VESC discovery, archive and bounded motor identification service."""
|
||||
|
||||
VERSION = "0.6.3"
|
||||
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,47 @@
|
||||
"""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.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", "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.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,153 @@
|
||||
"""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
|
||||
|
||||
|
||||
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")
|
||||
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):
|
||||
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(abs(level) > .02 for level, _ in observed.values()):
|
||||
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",
|
||||
"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,98 @@
|
||||
"""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):
|
||||
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 abs(incoming["level"]) > 0.02:
|
||||
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",
|
||||
"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,79 @@
|
||||
"""Bounded idle transport measurement through the installed native owners.
|
||||
|
||||
Only 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
|
||||
|
||||
|
||||
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}
|
||||
failure = None
|
||||
stop_reason = "complete"
|
||||
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 = abs(reading["level"]) <= .02
|
||||
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 {"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", [])}
|
||||
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")
|
||||
# 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):
|
||||
cycle = monotonic()
|
||||
cancelled = service.motor.cancelled_at
|
||||
if cancelled is not None and cancelled >= datetime.fromisoformat(command["requested_at"].replace("Z", "+00:00")):
|
||||
stop_reason = "stopped"; break
|
||||
if monotonic()-started >= 25 or (deadline-datetime.now(timezone.utc)).total_seconds() < 2:
|
||||
stop_reason = "deadline"; 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(),
|
||||
"summary": summary(samples[d.id]), "samples": samples[d.id]} for d in devices}}
|
||||
@@ -0,0 +1,343 @@
|
||||
"""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
|
||||
|
||||
|
||||
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):
|
||||
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"])
|
||||
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]
|
||||
if not math.isfinite(actual) or not low <= actual <= high:
|
||||
raise LimitExceeded(field, actual, low, high, label, unit, scale)
|
||||
|
||||
|
||||
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.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 abs(value["level"]) > 0.02:
|
||||
self.state("rc")
|
||||
raise Rejected("Приёмник передаёт команду. Управление удерживается за пультом.")
|
||||
|
||||
def run(self, command, devices, target, release=False):
|
||||
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:
|
||||
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 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:
|
||||
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 = {}, {}, [], {}
|
||||
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")
|
||||
check_configuration(identity, motor, 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 command["parameters"]["erpm"] > min(SPEED_LIMITS["max_erpm"], motor["l_max_erpm"] * 0.8):
|
||||
raise Rejected("Заданная скорость превышает настроенный диапазон контроллера.")
|
||||
if speed_mode and 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)))
|
||||
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)
|
||||
self.sleep(0.1)
|
||||
if release:
|
||||
self.state("ready")
|
||||
return {"authority": "ready", "observed_at": self.utc(), "backups": backups, "calibration_recovered": recovered}
|
||||
if hall_mode:
|
||||
from .hall_detection import measure
|
||||
return measure(self, devices, target, target_raw, backups, unchanged)
|
||||
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:
|
||||
from .group_test import run
|
||||
return run(self, command, devices, moving, originals, backups, unchanged)
|
||||
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,
|
||||
"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,169 @@
|
||||
"""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)}
|
||||
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,153 @@
|
||||
"""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"}
|
||||
|
||||
|
||||
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 0 <= 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
|
||||
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,130 @@
|
||||
"""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
|
||||
|
||||
|
||||
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 == "/operation":
|
||||
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))
|
||||
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")
|
||||
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)
|
||||
|
||||
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()
|
||||
try:
|
||||
server.serve_forever()
|
||||
finally:
|
||||
stop.set()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,347 @@
|
||||
"""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.config.backup", "vesc.input.read", "vesc.can.read", "vesc.drive.assign", "vesc.drive.unassign", "vesc.motor.pulse", "vesc.motor.run", "vesc.drive.run", "vesc.hall.measure", "vesc.foc.calibrate", "vesc.motor.stop", "vesc.control.release"})
|
||||
|
||||
|
||||
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"], "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}, "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"} | ({"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 in ("vesc.motor.run", "vesc.drive.run") and (type(params["erpm"]) not in (int, float) or not SPEED_LIMITS["min_erpm"] <= 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 set()
|
||||
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 extra 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"):
|
||||
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"):
|
||||
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.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.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 "Операция не выполнена. Проверьте связь и совместимость контроллера."}
|
||||
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) <= 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 = min(self.erpm, max(1, (now - self.started) * SPEED_LIMITS["ramp_erpm_per_s"]))
|
||||
return setpoint, done, error
|
||||
@@ -0,0 +1,86 @@
|
||||
"""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))
|
||||
if abs(state["motor_current_a"]) > 1 or abs(ppm(device.link.query(31))["level"]) > 0.02:
|
||||
raise ValueError("Wait for zero current and neutral before restoring limits")
|
||||
device.link.set_temporary_limits(old)
|
||||
if device.link.query(14) != raw: raise ValueError("Original configuration readback failed")
|
||||
path.unlink()
|
||||
# atomic() fsyncs the directory on writes; persist removal as well.
|
||||
import os
|
||||
fd = os.open(path.parent, os.O_RDONLY)
|
||||
try: os.fsync(fd)
|
||||
finally: os.close(fd)
|
||||
return True
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Targeted attachment checks preserve generation and topology rejection."""
|
||||
from pathlib import Path
|
||||
import sys,tempfile,unittest
|
||||
from unittest.mock import patch
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
|
||||
from runtime.serial import Attachment, attachment_at, check_attachment, discover
|
||||
|
||||
class AttachmentTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp=tempfile.TemporaryDirectory();self.addCleanup(self.temp.cleanup)
|
||||
self.root=Path(self.temp.name);self.device=self.root/'1-2.3';self.device.mkdir()
|
||||
for name,value in {'idVendor':'0483','idProduct':'5740','product':'ChibiOS/RT Virtual COM Port','devnum':'17','speed':'12'}.items():
|
||||
(self.device/name).write_text(value)
|
||||
(self.device/'1-2.3:1.0/tty/ttyACM2').mkdir(parents=True)
|
||||
self.expected=Attachment('1-2.3','17','ttyACM2','12 Мбит/с')
|
||||
|
||||
def test_target_check_never_enumerates_siblings(self):
|
||||
original=Path.iterdir
|
||||
def entries(path):
|
||||
if path==self.root:raise AssertionError('full bus scan in per-request check')
|
||||
return original(path)
|
||||
with patch.object(Path,'iterdir',entries):check_attachment(self.expected,self.root)
|
||||
self.assertEqual(discover(self.root),[self.expected])
|
||||
|
||||
def test_generation_change_or_removed_port_invalidates_old_owner(self):
|
||||
(self.device/'devnum').write_text('18')
|
||||
with self.assertRaises(OSError):check_attachment(self.expected,self.root)
|
||||
(self.device/'devnum').write_text('17')
|
||||
(self.device/'1-2.3:1.0/tty/ttyACM2').rmdir()
|
||||
with self.assertRaises(OSError):check_attachment(self.expected,self.root)
|
||||
|
||||
def test_ambiguous_tty_or_other_vendor_rejected(self):
|
||||
extra=self.device/'1-2.3:1.1/tty/ttyACM3';extra.mkdir(parents=True)
|
||||
self.assertIsNone(attachment_at(self.device))
|
||||
extra.rmdir();(self.device/'idVendor').write_text('1234')
|
||||
self.assertIsNone(attachment_at(self.device))
|
||||
|
||||
def test_driver_sibling_tty_cannot_be_mistaken_for_device_interface(self):
|
||||
(self.device/'driver/tty/ttyACM9').mkdir(parents=True)
|
||||
self.assertEqual(attachment_at(self.device),self.expected)
|
||||
with self.assertRaises(OSError):check_attachment(Attachment('../1-2.3','17','ttyACM2','12 Мбит/с'),self.root)
|
||||
|
||||
def test_generation_change_during_read_is_rejected(self):
|
||||
original=Path.read_text;reads=0
|
||||
def read(path,*args,**kwargs):
|
||||
nonlocal reads
|
||||
if path==self.device/'devnum':
|
||||
reads+=1;return '17' if reads==1 else '18'
|
||||
return original(path,*args,**kwargs)
|
||||
with patch.object(Path,'read_text',read):self.assertIsNone(attachment_at(self.device))
|
||||
@@ -0,0 +1,61 @@
|
||||
"""User-assigned drive layout, persistence and conflict tests; no motor I/O."""
|
||||
from pathlib import Path
|
||||
import sys
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parents[1]))
|
||||
import test_reader
|
||||
|
||||
|
||||
class DriveTests(test_reader.ServiceTests):
|
||||
def assign(self, device, slot, layout='1x1', revision=None):
|
||||
item=next(i for i in self.service.inventory('node_synthetic')['items'] if i['id']==device.id)
|
||||
command=self.command(item,action='vesc.drive.assign')
|
||||
command['parameters']={'layout':layout,'slot':slot,'revision':self.service.drive.value['revision'] if revision is None else revision}
|
||||
return self.service.execute(command)
|
||||
|
||||
def test_assign_is_user_metadata_without_serial_commands_and_survives_restart(self):
|
||||
left,right=list(self.service.devices.values())
|
||||
self.assertEqual(self.assign(left,'left.1')['state'],'complete')
|
||||
self.assertEqual(self.assign(right,'right.1')['state'],'complete')
|
||||
self.assertTrue(all(link.commands==[0] for link in test_reader.FakeLink.instances))
|
||||
from runtime.drive_profile import DriveProfile
|
||||
restored=DriveProfile(self.service.root,self.service.drive.atomic)
|
||||
self.assertEqual(restored.value,self.service.drive.value)
|
||||
self.assertEqual(restored.value['layout'],'1x1')
|
||||
self.assertEqual(restored.value['bindings']['left.1']['device_id'],left.id)
|
||||
self.assertEqual(self.service.drive.path.stat().st_mode & 0o777,0o600)
|
||||
|
||||
def test_duplicate_slot_and_stale_revision_never_replace_an_assignment(self):
|
||||
left,right=list(self.service.devices.values())
|
||||
self.assign(left,'left.1')
|
||||
self.assertEqual(self.assign(right,'left.1')['state'],'error')
|
||||
self.assertEqual(self.assign(right,'right.1',revision=0)['state'],'error')
|
||||
self.assertEqual(list(self.service.drive.value['bindings']),['left.1'])
|
||||
|
||||
def test_layout_shrink_requires_explicit_unassign_of_removed_slots(self):
|
||||
left,right=list(self.service.devices.values())
|
||||
self.assign(left,'left.2','2x2')
|
||||
self.assign(right,'right.1','2x2')
|
||||
self.assertEqual(self.assign(right,'right.1','1x1')['state'],'error')
|
||||
command=self.command(action='vesc.drive.unassign')
|
||||
command['parameters']={'slot':'left.2','revision':self.service.drive.value['revision']}
|
||||
self.assertEqual(self.service.execute(command)['state'],'complete')
|
||||
self.assertEqual(self.assign(right,'right.1','1x1')['state'],'complete')
|
||||
self.assertEqual(list(self.service.drive.value['bindings']),['right.1'])
|
||||
|
||||
def test_reassign_moves_one_controller_and_replay_does_not_repeat(self):
|
||||
device=next(iter(self.service.devices.values()))
|
||||
self.assign(device,'left.1')
|
||||
command=self.command(action='vesc.drive.assign')
|
||||
command['parameters']={'layout':'2x2','slot':'right.2','revision':1}
|
||||
result=self.service.execute(command)
|
||||
self.assertEqual(result['state'],'complete')
|
||||
self.assertEqual(list(result['result']['bindings']),['right.2'])
|
||||
self.assertEqual(self.service.execute(command),result)
|
||||
self.assertEqual(self.service.drive.value['revision'],2)
|
||||
|
||||
test_two_attachments_promote_independently = None
|
||||
test_uuid_collision_never_aliases_a_controller = None
|
||||
test_reconnect_invalidates_old_session_but_retains_uuid = None
|
||||
test_receipt_replay_never_repeats_serial_query = None
|
||||
test_backup_is_raw_hashed_and_bound_to_identity = None
|
||||
test_arbitrary_writes_parameters_and_expired_requests_rejected = None
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Fault injection at the native calibration transaction boundary; no hardware."""
|
||||
from datetime import datetime, timedelta
|
||||
import unittest
|
||||
import struct
|
||||
|
||||
import test_motor_test
|
||||
from test_motor_test import configuration
|
||||
from runtime.configuration import decode
|
||||
|
||||
|
||||
class CalibrationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.case = test_motor_test.PulseTests('test_fixed_wire_commands_no_broadcast_and_no_parameters')
|
||||
self.case.setUp()
|
||||
self.addCleanup(self.case.tearDown)
|
||||
c = self.case
|
||||
self.original = configuration('motor', {**decode(c.motor, 'motor'),
|
||||
'l_current_min': -60, 'l_in_current_min': -55, 'foc_openloop_rpm': 600, 'foc_sl_erpm': 2000})
|
||||
self.configs = {d.id: self.original for d in c.devices}
|
||||
self.starts = []
|
||||
self.finished = False
|
||||
for device in c.devices:
|
||||
previous = device.link.query
|
||||
def query(code, timeout=2, previous=previous, device=device):
|
||||
if code == 14: return self.configs[device.id]
|
||||
if code == 62: return bytes([62])
|
||||
return previous(code, timeout)
|
||||
device.link.query = query
|
||||
def start(loss, device=device):
|
||||
self.starts.append((device.id, loss))
|
||||
self.configs[device.id] = configuration('motor', {**decode(self.original, 'motor'),
|
||||
'foc_motor_r': 0.08, 'foc_motor_l': 0.00003, 'foc_motor_flux_linkage': 0.009, 'foc_sensor_mode': 0})
|
||||
device.link.calibrate_foc = start
|
||||
def result():
|
||||
if not self.finished:
|
||||
self.finished = True
|
||||
return {'running': True}
|
||||
return {'running': False, 'uncertain': False, 'result': {
|
||||
'kind': 'foc', 'completed': True, 'success': True, 'validated': True, 'code': 0, 'sensor_mode': 0}}
|
||||
device.link.procedure_result = result
|
||||
|
||||
def command(self):
|
||||
c = self.case.command(action='vesc.foc.calibrate')
|
||||
c['deadline_at'] = (datetime.fromisoformat(c['requested_at']) + timedelta(seconds=300)).isoformat()
|
||||
c['parameters'] = {'rig_clear': True, 'native_cycle_confirmed': True, 'max_power_loss_w': 50,
|
||||
'sessions': {d.id: d.session for d in self.case.devices}}
|
||||
return c
|
||||
|
||||
def test_native_success_archives_both_versions_and_never_sends_host_torque(self):
|
||||
command = self.command()
|
||||
receipt = self.case.service.execute(command)
|
||||
result = receipt['result']
|
||||
self.assertTrue(result['success'], receipt)
|
||||
self.assertEqual(result['native']['sensor_mode'], 0)
|
||||
self.assertEqual(self.starts, [(self.case.devices[0].id, 50)])
|
||||
self.assertEqual(len(result['backups']), 2)
|
||||
self.assertEqual(len(result['after_backups']), 2)
|
||||
self.assertEqual(self.case.currents, [])
|
||||
self.assertTrue(all(action == 'release' for _, action in self.case.sent))
|
||||
self.assertFalse((self.case.service.root/'calibration-pending.json').exists())
|
||||
self.assertEqual(self.case.service.execute(command), receipt)
|
||||
self.assertEqual(len(self.starts), 1)
|
||||
|
||||
def test_unknown_completion_persists_across_service_restart_and_blocks_new_motion(self):
|
||||
target = self.case.devices[0]
|
||||
target.link.procedure_result = lambda: {'running': False, 'uncertain': True, 'result': {}}
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertFalse(result['completed'])
|
||||
self.assertFalse(result['success'])
|
||||
self.assertTrue((self.case.service.root/'calibration-pending.json').exists())
|
||||
self.assertTrue(self.case.service.motor.latched)
|
||||
from runtime.service import Service
|
||||
restarted = Service(self.case.service.root, discover_fn=self.case.service.discover_fn)
|
||||
self.assertTrue(restarted.motor.latched)
|
||||
self.assertEqual(self.case.service.execute(self.case.command_for_pulse())['state'], 'error')
|
||||
self.assertEqual(len(self.starts), 1)
|
||||
|
||||
def test_peer_configuration_change_is_archived_and_blocks_further_movement(self):
|
||||
start = self.case.devices[0].link.calibrate_foc
|
||||
def mutate(loss):
|
||||
start(loss)
|
||||
self.configs[self.case.devices[1].id] = configuration('motor', {**decode(self.original, 'motor'), 'foc_motor_r': 1})
|
||||
self.case.devices[0].link.calibrate_foc = mutate
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertFalse(result['success'])
|
||||
self.assertFalse(result['configuration_verified'])
|
||||
self.assertEqual(len(result['after_backups']), 2)
|
||||
self.assertTrue(self.case.service.motor.latched)
|
||||
|
||||
def test_known_can_peers_still_block_upstream_broadcast_side_effect(self):
|
||||
target = self.case.devices[0]
|
||||
query = target.link.query
|
||||
target.link.query = lambda code, timeout=2: bytes([62, 11]) if code == 62 else query(code, timeout)
|
||||
result = self.case.service.execute(self.command())
|
||||
self.assertEqual(result['state'], 'error')
|
||||
self.assertEqual(self.starts, [])
|
||||
|
||||
def test_confirmed_failed_cycle_restores_and_can_be_retried_explicitly(self):
|
||||
target = self.case.devices[0]
|
||||
target.link.calibrate_foc = lambda loss: self.starts.append((target.id, loss))
|
||||
target.link.procedure_result = lambda: {'running': False, 'uncertain': False, 'result': {
|
||||
'completed': True, 'success': False, 'validated': True, 'code': -10}}
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertTrue(result['completed'])
|
||||
self.assertFalse(result['success'])
|
||||
self.assertTrue(result['configuration_verified'])
|
||||
self.assertFalse((self.case.service.root/'calibration-pending.json').exists())
|
||||
|
||||
def test_missing_native_write_ack_never_accepts_calibration(self):
|
||||
target = self.case.devices[0]
|
||||
target.link.procedure_result = lambda: {'running': False, 'uncertain': True, 'result': {
|
||||
'completed': True, 'success': False, 'validated': False, 'code': 0, 'error': 'ACK missing'}}
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertFalse(result['success'])
|
||||
self.assertTrue((self.case.service.root/'calibration-pending.json').exists())
|
||||
|
||||
def test_calibration_heating_budget_is_finite_and_explicit(self):
|
||||
for loss in (True, float('nan'), float('inf'), 0, 151):
|
||||
command = self.command(); command['parameters']['max_power_loss_w'] = loss
|
||||
with self.assertRaises(ValueError): self.case.service.execute(command)
|
||||
self.assertEqual(self.starts, [])
|
||||
|
||||
def averaged_current(self, transient=True):
|
||||
target = self.case.devices[0]
|
||||
query = target.link.query
|
||||
reads = []
|
||||
def read(code, timeout=2):
|
||||
raw = query(code, timeout)
|
||||
if code == 4 and self.starts:
|
||||
reads.append(1)
|
||||
if not transient or len(reads) == 1:
|
||||
return raw[:5] + struct.pack('>i', 367) + raw[9:]
|
||||
return raw
|
||||
target.link.query = read
|
||||
return query, reads
|
||||
|
||||
def test_cycle_average_is_drained_before_verifying_released_current(self):
|
||||
_, reads = self.averaged_current()
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertTrue(result['success'], result)
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
self.assertGreaterEqual(len(reads), 2)
|
||||
self.assertEqual(result['after'][self.case.devices[0].id]['motor_current_a'], 0)
|
||||
|
||||
def test_explicit_neutral_recovery_after_verified_cycle_never_recalibrates(self):
|
||||
query, _ = self.averaged_current(transient=False)
|
||||
command = self.command()
|
||||
receipt = self.case.service.execute(command)
|
||||
self.assertFalse(receipt['result']['release_confirmed'])
|
||||
self.assertTrue(receipt['result']['configuration_verified'])
|
||||
self.case.devices[0].link.query = query
|
||||
release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'
|
||||
result = self.case.service.execute(release)['result']
|
||||
self.assertEqual(result['authority'], 'ready')
|
||||
self.assertFalse(result['calibration_recovered']['calibration_replayed'])
|
||||
self.assertEqual(len(self.starts), 1)
|
||||
self.assertEqual(self.case.service.execute(command), receipt)
|
||||
self.assertFalse((self.case.service.root/'calibration-pending.json').exists())
|
||||
self.assertEqual(self.case.currents, [])
|
||||
|
||||
def test_recovery_rejects_new_configuration_and_does_not_overwrite_it(self):
|
||||
query, _ = self.averaged_current(transient=False)
|
||||
self.case.service.execute(self.command())
|
||||
self.case.devices[0].link.query = query
|
||||
key = self.case.devices[0].id
|
||||
self.configs[key] = configuration('motor', {**decode(self.configs[key], 'motor'), 'foc_motor_r': .5})
|
||||
before = self.configs[key]
|
||||
release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'
|
||||
receipt = self.case.service.execute(release)
|
||||
self.assertEqual(receipt['state'], 'error')
|
||||
self.assertEqual(self.configs[key], before)
|
||||
self.assertTrue((self.case.service.root/'calibration-pending.json').exists())
|
||||
self.assertEqual(len(self.starts), 1)
|
||||
|
||||
def test_unknown_completion_cannot_be_cleared_by_neutral_return(self):
|
||||
self.case.devices[0].link.procedure_result = lambda: {'running': False, 'uncertain': True, 'result': {}}
|
||||
self.case.service.execute(self.command())
|
||||
release = self.case.command_for_pulse(); release['action_id'] = 'vesc.control.release'
|
||||
self.assertEqual(self.case.service.execute(release)['state'], 'error')
|
||||
self.assertTrue((self.case.service.root/'calibration-pending.json').exists())
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Synthetic complete-profile motion, shared timing and all-peer failure tests."""
|
||||
import struct
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import test_motor_test
|
||||
import test_reader
|
||||
from test_motor_test import configuration
|
||||
from runtime.configuration import decode
|
||||
from runtime.drive_profile import LAYOUTS
|
||||
from runtime.temporary_limits import FIELDS
|
||||
|
||||
|
||||
class GroupTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.case = test_motor_test.PulseTests('test_fixed_wire_commands_no_broadcast_and_no_parameters')
|
||||
self.case.setUp()
|
||||
self.addCleanup(self.case.tearDown)
|
||||
self.configure(2)
|
||||
|
||||
def configure(self, count):
|
||||
c = self.case
|
||||
if len(c.devices) != count:
|
||||
c.attachments = [test_reader.Attachment(f'2-{i+1}', str(i+1), f'ttyACM{i}', '12') for i in range(count)]
|
||||
c.service.scan(); c.configure_controllers()
|
||||
self.configs, self.speeds, self.commands, self.tacho, self.sets = {}, {}, [], {}, []
|
||||
self.motion = lambda device: True
|
||||
for device in c.devices:
|
||||
self.configs[device.id] = configuration('motor', {**decode(c.motor, 'motor'),
|
||||
'l_current_min': -60, 'l_current_min_scale': 1, 'l_current_max_scale': 1})
|
||||
original = device.link.query
|
||||
def query(code, timeout=2, original=original, device=device):
|
||||
if code == 14: return self.configs[device.id]
|
||||
raw = original(code, timeout)
|
||||
if code == 4 and device.id in self.speeds and self.motion(device):
|
||||
self.tacho[device.id] = self.tacho.get(device.id, 0)+6
|
||||
raw = raw[:23]+struct.pack('>i', round(self.speeds[device.id]))+raw[27:]
|
||||
raw = raw[:45]+struct.pack('>ii', self.tacho[device.id], self.tacho[device.id])+raw[53:]
|
||||
return raw
|
||||
device.link.query = query
|
||||
def limits(config, device=device):
|
||||
self.sets.append(device.id)
|
||||
self.configs[device.id] = configuration('motor', {**decode(self.configs[device.id], 'motor'),
|
||||
**{k: config[k] for k in FIELDS}})
|
||||
device.link.set_temporary_limits = limits
|
||||
def speed(value, device=device):
|
||||
self.speeds[device.id] = value
|
||||
self.commands.append((device.id, value))
|
||||
device.link.test_speed = speed
|
||||
self.originals = dict(self.configs)
|
||||
layout = '1x1' if count == 2 else '2x2'
|
||||
c.service.drive.value = {'layout': layout, 'revision': 4,
|
||||
'bindings': {slot: {'device_id': d.id, 'uuid': d.identity['uuid']}
|
||||
for slot, d in zip(LAYOUTS[layout], c.devices)}}
|
||||
|
||||
def command(self):
|
||||
command = self.case.command_for_pulse()
|
||||
command['action_id'] = 'vesc.drive.run'
|
||||
command['parameters'].update(erpm=2000, current_a=30, duration_s=3, profile_revision=4,
|
||||
device_ids=[d.id for d in self.case.devices])
|
||||
command['deadline_at'] = (datetime.fromisoformat(command['requested_at'])+timedelta(seconds=120)).isoformat()
|
||||
return command
|
||||
|
||||
def test_pair_and_four_motors_run_one_common_interval_and_restore(self):
|
||||
for count in (2, 4):
|
||||
with self.subTest(count=count):
|
||||
self.configure(count)
|
||||
command = self.command()
|
||||
receipt = self.case.service.execute(command)
|
||||
result = receipt['result']
|
||||
self.assertEqual(result['outcome'], 'duration', result)
|
||||
self.assertGreaterEqual(result['rotation_s'], 3)
|
||||
self.assertLess(result['rotation_s'], 3.2)
|
||||
self.assertEqual(set(self.speeds), {d.id for d in self.case.devices})
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
self.assertTrue(result['limits_restored'])
|
||||
self.assertEqual(self.configs, self.originals)
|
||||
before = list(self.commands)
|
||||
self.assertEqual(self.case.service.execute(command), receipt)
|
||||
self.assertEqual(before, self.commands)
|
||||
|
||||
def test_count_starts_only_after_slower_motor_reaches_speed(self):
|
||||
self.motion = lambda d: d is self.case.devices[0] or self.case.clock.now >= 8
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertEqual(result['outcome'], 'duration')
|
||||
first = next(s['at'] for s in result['samples'] if s['rotation_s'] > 0)
|
||||
self.assertGreater(first, 7.5)
|
||||
self.assertGreaterEqual(result['rotation_s'], 3)
|
||||
|
||||
def test_stationary_peer_never_counts_single_motor_as_joint_rotation(self):
|
||||
self.motion = lambda d: d is self.case.devices[0]
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertEqual(result['rotation_s'], 0)
|
||||
self.assertIn('не вышел', result['outcome'])
|
||||
self.assertEqual(set(result['release']), {d.id for d in self.case.devices})
|
||||
self.assertEqual(self.configs, self.originals)
|
||||
|
||||
def test_rc_preemption_releases_every_motor_and_latches(self):
|
||||
original = self.case.devices[0].link.test_speed
|
||||
def speed(value):
|
||||
original(value); self.case.changed = True
|
||||
self.case.devices[0].link.test_speed = speed
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertEqual(result['rotation_s'], 0)
|
||||
self.assertTrue(self.case.service.motor.latched)
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
self.assertFalse(result['limits_restored'])
|
||||
self.assertTrue(list(self.case.service.root.glob('limits_*.json')))
|
||||
self.case.changed = False
|
||||
self.case.service.scan()
|
||||
self.assertEqual(self.configs, self.originals)
|
||||
|
||||
def test_losing_one_motor_stops_joint_timer_and_releases_pair(self):
|
||||
self.motion = lambda d: d is self.case.devices[0] or self.case.clock.now < 7
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertLess(result['rotation_s'], 3)
|
||||
self.assertIn('перестал удерживать', result['outcome'])
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
self.assertEqual(self.configs, self.originals)
|
||||
|
||||
def test_failed_peer_lease_never_sends_speed_and_keeps_cause(self):
|
||||
original = self.case.devices[1].link.test_command
|
||||
def send(action):
|
||||
if action == 'claim': raise TimeoutError('synthetic lease timeout')
|
||||
original(action)
|
||||
self.case.devices[1].link.test_command = send
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertEqual(self.commands, [])
|
||||
self.assertEqual(result['failure']['message'], 'synthetic lease timeout')
|
||||
self.assertEqual(set(result['release']), {d.id for d in self.case.devices})
|
||||
self.assertEqual(self.configs, self.originals)
|
||||
|
||||
def test_second_limit_ack_failure_restores_both_without_motion(self):
|
||||
original = self.case.devices[1].link.set_temporary_limits
|
||||
def apply(config):
|
||||
original(config)
|
||||
if len(self.sets) == 2: raise TimeoutError('lost second ACK')
|
||||
self.case.devices[1].link.set_temporary_limits = apply
|
||||
result = self.case.service.execute(self.command())['result']
|
||||
self.assertEqual(self.commands, [])
|
||||
self.assertTrue(result['limits_restored'])
|
||||
self.assertEqual(self.configs, self.originals)
|
||||
|
||||
def test_stale_incomplete_duplicate_or_changed_profile_never_runs(self):
|
||||
command = self.command(); command['parameters']['profile_revision'] -= 1
|
||||
self.assertEqual(self.case.service.execute(command)['state'], 'error')
|
||||
command = self.command(); command['parameters']['device_ids'] *= 2
|
||||
with self.assertRaises(ValueError): self.case.service.execute(command)
|
||||
self.case.service.drive.value['bindings'].pop('right.1')
|
||||
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
|
||||
self.assertEqual(self.commands, [])
|
||||
self.assertEqual(self.sets, [])
|
||||
|
||||
def test_current_bound_is_checked_for_every_motor(self):
|
||||
device = self.case.devices[1]
|
||||
self.configs[device.id] = configuration('motor', {**decode(self.configs[device.id], 'motor'), 'l_current_max': 10})
|
||||
self.assertEqual(self.case.service.execute(self.command())['state'], 'error')
|
||||
self.assertEqual(self.commands, [])
|
||||
self.assertEqual(self.sets, [])
|
||||
@@ -0,0 +1,87 @@
|
||||
"""No-power read measurements and failure attribution; no USB hardware."""
|
||||
from unittest.mock import patch
|
||||
import unittest
|
||||
import test_motor_test
|
||||
from runtime.link_check import measure, summary
|
||||
|
||||
|
||||
class LinkCheckTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.case=test_motor_test.PulseTests('test_fixed_wire_commands_no_broadcast_and_no_parameters')
|
||||
self.case.setUp(); self.addCleanup(self.case.tearDown)
|
||||
self.reads=[]
|
||||
for device in self.case.devices:
|
||||
original=device.link.query
|
||||
def query(code,timeout=2,original=original,device=device):
|
||||
self.reads.append((device.id,code,timeout))
|
||||
return original(code,timeout)
|
||||
device.link.query=query
|
||||
|
||||
def command(self):
|
||||
c=self.case.command_for_pulse();c['action_id']='vesc.link.check'
|
||||
c['parameters']={'sessions':c['parameters']['sessions']}
|
||||
return c
|
||||
|
||||
def run_check(self):
|
||||
return measure(self.case.service,self.command(),self.case.devices,
|
||||
sleep=self.case.clock.sleep,monotonic=lambda:self.case.clock.now)
|
||||
|
||||
def test_reads_pair_at_group_cadence_without_any_motor_commands(self):
|
||||
result=self.run_check()
|
||||
self.assertEqual(result['outcome'],'complete')
|
||||
self.assertFalse(result['motor_commands_sent'])
|
||||
self.assertEqual(len(self.reads),400)
|
||||
self.assertEqual({r[1] for r in self.reads},{31,4})
|
||||
self.assertTrue(all(r[2]==.5 for r in self.reads))
|
||||
self.assertEqual(self.case.sent,[])
|
||||
self.assertTrue(all(v['summary']['replies']==200 for v in result['devices'].values()))
|
||||
self.assertGreaterEqual(result['duration_s'],9.9)
|
||||
|
||||
def test_late_replies_are_measured_without_relaxing_motor_budget(self):
|
||||
result=summary([{'elapsed_ms':x} for x in [20,30,55,80,120]])
|
||||
self.assertEqual(result['over_60_ms'],2)
|
||||
self.assertEqual(result['p50_ms'],55)
|
||||
self.assertEqual(result['max_ms'],120)
|
||||
|
||||
def test_no_response_keeps_peer_command_and_native_cause(self):
|
||||
device=self.case.devices[1]
|
||||
def query(code,timeout=2):
|
||||
error=OSError('native read timeout');error.native_rpc={'command':code,'process_alive':True,'attachment_present':True}
|
||||
raise error
|
||||
device.link.query=query
|
||||
result=self.run_check()
|
||||
self.assertEqual(result['outcome'],'read_failed')
|
||||
self.assertEqual(result['failure'][0]['device_id'],device.id)
|
||||
self.assertEqual(result['failure'][0]['command'],31)
|
||||
self.assertTrue(result['failure'][0]['native_rpc']['attachment_present'])
|
||||
self.assertEqual(self.case.sent,[])
|
||||
|
||||
def test_active_receiver_stops_measurement_without_claiming_control(self):
|
||||
self.case.changed=True
|
||||
result=self.run_check()
|
||||
self.assertEqual(result['outcome'],'not_idle')
|
||||
self.assertEqual(self.case.sent,[])
|
||||
self.assertEqual({r[1] for r in self.reads},{31})
|
||||
|
||||
def test_service_keeps_idempotent_receipt_and_excludes_other_operations(self):
|
||||
command=self.command()
|
||||
original=measure
|
||||
def fast(*args):return original(*args,sleep=self.case.clock.sleep,monotonic=lambda:self.case.clock.now)
|
||||
with patch('runtime.link_check.measure',side_effect=fast):
|
||||
result=self.case.service.execute(command)
|
||||
self.assertEqual(result['state'],'complete')
|
||||
self.assertEqual(self.case.service.execute(command),result)
|
||||
self.assertEqual(len(self.reads),400)
|
||||
self.case.service.operation_lock.acquire()
|
||||
try:
|
||||
with self.assertRaises(ValueError):self.case.service.execute(self.command())
|
||||
finally:self.case.service.operation_lock.release()
|
||||
|
||||
def test_changed_sessions_rejected_before_reads_and_stop_is_respected(self):
|
||||
command=self.command();command['parameters']['sessions']={}
|
||||
with self.assertRaises(ValueError): measure(self.case.service,command,self.case.devices)
|
||||
self.assertEqual(self.reads,[])
|
||||
command=self.command();self.case.service.motor.cancel()
|
||||
result=measure(self.case.service,command,self.case.devices)
|
||||
self.assertEqual(result['outcome'],'stopped')
|
||||
self.assertEqual(self.reads,[])
|
||||
@@ -0,0 +1,352 @@
|
||||
"""Synthetic fault-injection tests. No real ports, private config or motors."""
|
||||
import math
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from runtime.configuration import decode, crc32c
|
||||
from runtime.motor_test import Rejected, LimitExceeded, check_configuration, check_values
|
||||
from runtime.protocol import Decoder, test_packet, ppm, current_packet
|
||||
import test_reader
|
||||
from runtime.protocol import firmware
|
||||
from test_reader import telemetry
|
||||
|
||||
|
||||
def configuration(kind, overrides):
|
||||
code, name = {"motor": (14, "mcconf"), "application": (17, "appconf")}[kind]
|
||||
xml = ET.parse(Path(__file__).parents[1] / "runtime/schemas/5.02" / ("parameters_" + name + ".xml")).getroot()
|
||||
params = {p.tag: p for p in xml.find("Params")}; order = [p.text for p in xml.find("SerOrder")]
|
||||
sig = ''.join(n + params[n].findtext('type', '0') + params[n].findtext('vTx', '0') + ''.join(x.text or '' for x in params[n].findall('enumNames')) for n in order)
|
||||
out = bytes([code]) + crc32c(sig.encode()).to_bytes(4, 'big')
|
||||
for n in order:
|
||||
p=params[n];typ=int(p.findtext('type'));tx=int(p.findtext('vTx','0'));v=overrides.get(n,0)
|
||||
if typ in (4,5):fmt='b'
|
||||
elif typ==6:fmt='B'
|
||||
elif typ==2:fmt={1:'B',2:'b',3:'H',4:'h',5:'I',6:'i'}[tx]
|
||||
else:
|
||||
fmt={7:'h',8:'i',9:'I'}[tx]
|
||||
if tx==9:
|
||||
f,e=math.frexp(abs(v));v=0 if v==0 else ((e+126)<<23)|int((f-0.5)*16777216)|(0x80000000 if v<0 else 0)
|
||||
else:v=round(v*float(p.findtext('vTxDoubleScale','1')))
|
||||
out+=struct.pack('>'+fmt,v)
|
||||
return out
|
||||
|
||||
|
||||
class Clock:
|
||||
def __init__(self):self.now=0
|
||||
def monotonic(self):return self.now
|
||||
def sleep(self,value):self.now+=value
|
||||
|
||||
|
||||
class PulseTests(test_reader.ServiceTests):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.configure_controllers()
|
||||
|
||||
def configure_controllers(self):
|
||||
self.clock=Clock();self.service.motor.sleep=self.clock.sleep;self.service.motor.monotonic=self.clock.monotonic
|
||||
self.devices=list(self.service.devices.values());self.changed=False
|
||||
self.sent=[]
|
||||
self.currents=[]
|
||||
self.motor=configuration('motor',{'motor_type':2,'l_current_max':60,'l_in_current_max':55,'l_min_erpm':-60000,'l_max_erpm':60000,'l_max_duty':0.95})
|
||||
for i,device in enumerate(self.devices):
|
||||
raw=b'\0\5\2' + b'75_300_R2\0' + bytes([i+1])*12 + bytes(4)
|
||||
device.identity=firmware(raw)
|
||||
app=configuration('application',{'controller_id':i+10,'app_to_use':1,'timeout_msec':1000,'timeout_brake_current':0,'app_ppm_conf.ctrl_type':4,'app_ppm_conf.hyst':0.15})
|
||||
def query(code,timeout=2,raw=raw,app=app):
|
||||
if code==0:return raw
|
||||
if code==14:return self.motor
|
||||
if code==17:return app
|
||||
if code==4:return telemetry()[:1]+struct.pack('>hhiiiihihiiiiiiB',250,-729,0,0,0,0,0,0,500,0,0,0,0,0,0,0)
|
||||
if code==31:return bytes([31])+struct.pack('>ii',600000 if self.changed else 0,1500000)
|
||||
if code==62:return bytes([62])+bytes(range(10,10+len(self.devices)))
|
||||
raise AssertionError(code)
|
||||
device.link.query=query
|
||||
device.link.test_command=lambda action,i=i:self.sent.append((i,action))
|
||||
def send_current(amps,device=device,i=i):
|
||||
self.currents.append((i,amps))
|
||||
device.link.test_command('current')
|
||||
device.link.test_current=send_current
|
||||
|
||||
def command_for_pulse(self):
|
||||
c=self.command(action='vesc.motor.pulse')
|
||||
c['parameters']={'rig_clear':True,'sessions':{d.id:d.session for d in self.devices},'duration_s':1.5,'current_a':2}
|
||||
return c
|
||||
|
||||
def test_fixed_wire_commands_no_broadcast_and_no_parameters(self):
|
||||
self.assertEqual(Decoder().feed(test_packet('claim')),[bytes([63,0])+struct.pack('>i',250)])
|
||||
self.assertEqual(Decoder().feed(test_packet('current')),[bytes([6])+struct.pack('>i',2000)])
|
||||
self.assertEqual(Decoder().feed(current_packet(5)),[bytes([6])+struct.pack('>i',5000)])
|
||||
with self.assertRaises(KeyError):test_packet('arbitrary')
|
||||
with self.assertRaises(ValueError):ppm(bytes([31])+bytes(7))
|
||||
|
||||
def test_only_selected_motor_receives_current_and_receipt_never_replays(self):
|
||||
c=self.command_for_pulse();r=self.service.execute(c)
|
||||
self.assertEqual(r['state'],'complete',r)
|
||||
self.assertEqual(r['result']['outcome'],'duration')
|
||||
self.assertTrue(r['result']['release_confirmed'])
|
||||
self.assertIn((0,'current'),self.sent);self.assertNotIn((1,'current'),self.sent)
|
||||
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||
self.assertLessEqual(len([x for x in self.sent if x==(0,'current')]),31)
|
||||
before=list(self.sent);self.assertEqual(self.service.execute(c),r);self.assertEqual(self.sent,before)
|
||||
self.assertEqual(len(list(self.service.root.glob('backup_*.json'))),2)
|
||||
|
||||
def test_active_rc_blocks_before_any_command_and_latches(self):
|
||||
self.changed=True;r=self.service.execute(self.command_for_pulse())
|
||||
self.assertEqual(r['state'],'error');self.assertEqual(self.sent,[]);self.assertTrue(self.service.motor.latched)
|
||||
self.changed=False;self.assertEqual(self.service.execute(self.command_for_pulse())['state'],'error')
|
||||
|
||||
def test_rc_preemption_releases_both_and_blocks_next_click(self):
|
||||
def send(action):
|
||||
self.sent.append((0,action))
|
||||
if action=='current':self.changed=True
|
||||
self.devices[0].link.test_command=send
|
||||
r=self.service.execute(self.command_for_pulse())
|
||||
self.assertEqual(r['state'],'complete');self.assertTrue(self.service.motor.latched)
|
||||
self.assertEqual(len([x for x in self.sent if x==(0,'current')]),1)
|
||||
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||
|
||||
def test_missing_second_lease_and_disconnect_release_first(self):
|
||||
def send(action):
|
||||
if action=='claim':raise OSError('Disconnected')
|
||||
self.sent.append((1,action))
|
||||
self.devices[1].link.test_command=send
|
||||
r=self.service.execute(self.command_for_pulse())
|
||||
self.assertNotIn((0,'current'),self.sent);self.assertIn((0,'release'),self.sent)
|
||||
self.assertNotEqual(r['result']['outcome'],'duration')
|
||||
|
||||
def test_bad_schema_stale_peer_and_no_rig_confirmation_never_write(self):
|
||||
self.assertRaises(ValueError,decode,self.motor[:-1],'motor')
|
||||
c=self.command_for_pulse();c['parameters']['sessions'][self.devices[1].id]='stale'
|
||||
self.assertEqual(self.service.execute(c)['state'],'error')
|
||||
c=self.command_for_pulse();c['parameters']['rig_clear']=False
|
||||
with self.assertRaises(ValueError):self.service.execute(c)
|
||||
self.assertEqual(self.sent,[])
|
||||
|
||||
def test_stop_received_before_queued_pulse_cannot_be_cleared_by_start(self):
|
||||
pulse = self.command_for_pulse()
|
||||
result = self.service.execute(self.command(action='vesc.motor.stop'))
|
||||
self.assertTrue(result['result']['stop_requested'])
|
||||
self.assertEqual(self.service.execute(pulse)['state'],'error')
|
||||
self.assertEqual(self.sent,[])
|
||||
self.assertEqual(self.service.execute(self.command_for_pulse())['state'],'complete')
|
||||
|
||||
def test_single_six_and_ten_controllers_target_only_with_durable_backups(self):
|
||||
for count in (1, 6, 10):
|
||||
with self.subTest(count=count):
|
||||
self.attachments = [test_reader.Attachment(f"2-{i+1}", str(i+1), f"ttyACM{i}", "12") for i in range(count)]
|
||||
self.service.scan(); self.configure_controllers()
|
||||
command=self.command_for_pulse()
|
||||
target=self.devices[-1]
|
||||
command['session']={'device_id':target.id,'session_id':target.session}
|
||||
command['parameters']['duration_s']=10
|
||||
result=self.service.execute(command)
|
||||
self.assertEqual(result['state'],'complete',result)
|
||||
self.assertEqual(result['result']['outcome'],'duration')
|
||||
self.assertEqual(result['result']['duration_limit_s'],10)
|
||||
self.assertEqual({i for i,action in self.sent if action=='current'},{count-1})
|
||||
self.assertTrue(result['result']['release_confirmed'])
|
||||
self.assertEqual(len(result['result']['backups']),count)
|
||||
self.assertEqual(len(result['result']['release']),count)
|
||||
self.assertLessEqual(self.clock.now,11.36)
|
||||
|
||||
def test_new_controller_during_preflight_or_pulse_stops_current(self):
|
||||
extra=test_reader.Attachment('3-1','3','ttyACM3','12')
|
||||
self.attachments.append(extra)
|
||||
self.assertEqual(self.service.execute(self.command_for_pulse())['state'],'error')
|
||||
self.assertEqual(self.sent,[])
|
||||
self.attachments.remove(extra)
|
||||
def send(action):
|
||||
self.sent.append((0,action))
|
||||
if action=='current':self.attachments.append(extra)
|
||||
self.devices[0].link.test_command=send
|
||||
result=self.service.execute(self.command_for_pulse())
|
||||
self.assertEqual(result['state'],'complete')
|
||||
self.assertNotEqual(result['result']['outcome'],'duration')
|
||||
self.assertEqual(self.sent.count((0,'current')),1)
|
||||
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||
|
||||
def test_unmanaged_can_peer_on_other_controller_blocks_all_current(self):
|
||||
original=self.devices[1].link.query
|
||||
self.devices[1].link.query=lambda code,timeout=2:bytes([62,99]) if code==62 else original(code,timeout)
|
||||
result=self.service.execute(self.command_for_pulse())
|
||||
self.assertEqual(result['state'],'error')
|
||||
self.assertEqual(self.sent,[])
|
||||
|
||||
def test_slow_multi_controller_cycle_never_sends_torque(self):
|
||||
original=self.devices[1].link.query
|
||||
def delayed(code,timeout=2):
|
||||
if code==31:self.clock.sleep(0.13)
|
||||
return original(code,timeout)
|
||||
self.devices[1].link.query=delayed
|
||||
result=self.service.execute(self.command_for_pulse())
|
||||
self.assertNotEqual(result['result']['outcome'],'duration')
|
||||
self.assertEqual(self.sent,[])
|
||||
|
||||
def test_duration_is_allowlisted_and_stop_interrupts_ten_second_pulse(self):
|
||||
for duration in (True,0,30.01,100,'10'):
|
||||
command=self.command_for_pulse();command['parameters']['duration_s']=duration
|
||||
with self.assertRaises(ValueError):self.service.execute(command)
|
||||
def send(action):
|
||||
self.sent.append((0,action))
|
||||
if action=='current':self.service.motor.cancel()
|
||||
self.devices[0].link.test_command=send
|
||||
command=self.command_for_pulse();command['parameters']['duration_s']=10
|
||||
result=self.service.execute(command)
|
||||
self.assertEqual(result['result']['outcome'],'stopped')
|
||||
self.assertEqual(self.sent.count((0,'current')),1)
|
||||
self.assertLess(self.clock.now,2)
|
||||
|
||||
def test_can_timeout_reports_preflight_failure_without_current(self):
|
||||
original=self.devices[0].link.query
|
||||
def query(code,timeout=2):
|
||||
if code==62:
|
||||
self.assertEqual(timeout,8)
|
||||
raise TimeoutError('synthetic CAN timeout')
|
||||
return original(code,timeout)
|
||||
self.devices[0].link.query=query
|
||||
result=self.service.execute(self.command_for_pulse())
|
||||
self.assertEqual(result['state'],'error')
|
||||
self.assertIn('CAN',result['error'])
|
||||
self.assertEqual(self.sent,[])
|
||||
|
||||
def test_can_inspection_does_not_claim_or_drive_motors(self):
|
||||
result=self.service.execute(self.command(action='vesc.can.read'))
|
||||
self.assertEqual(result['state'],'complete')
|
||||
self.assertEqual(result['result']['can_ids'],[10,11])
|
||||
self.assertEqual(self.sent,[])
|
||||
|
||||
def test_old_coasting_thresholds_no_longer_interrupt_current(self):
|
||||
original=self.devices[0].link.query
|
||||
speeds=iter([0,210,880,1200,3000])
|
||||
def query(code,timeout=2):
|
||||
raw=original(code,timeout)
|
||||
if code==4:
|
||||
speed=next(speeds,3000)
|
||||
raw=raw[:21]+struct.pack('>hi',120 if speed else 0,speed)+raw[27:]
|
||||
return raw
|
||||
self.devices[0].link.query=query
|
||||
command=self.command_for_pulse();command['parameters'].update(current_a=5,duration_s=30)
|
||||
result=self.service.execute(command)['result']
|
||||
self.assertEqual(result['outcome'],'duration')
|
||||
self.assertEqual(result['duration_limit_s'],30)
|
||||
self.assertGreater(len(result['samples']),590)
|
||||
self.assertTrue(all(s['commanded_current_a'] > 0 for s in result['samples']))
|
||||
self.assertEqual(self.sent.count((0,'release')),1)
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
|
||||
def test_current_ramps_from_half_amp_to_entered_ceiling(self):
|
||||
command=self.command_for_pulse();command['parameters'].update(current_a=5,duration_s=10)
|
||||
result=self.service.execute(command)['result']
|
||||
amps=[value for _,value in self.currents]
|
||||
self.assertEqual(amps[0],0.5)
|
||||
self.assertEqual(max(amps),5)
|
||||
self.assertTrue(all(0<=b-a<=0.10001 for a,b in zip(amps,amps[1:])))
|
||||
self.assertEqual(result['outcome'],'duration')
|
||||
self.assertEqual(result['current_ramp_a_per_s'],2)
|
||||
|
||||
def test_thirty_amp_thirty_second_moving_target_and_bounded_receipt(self):
|
||||
original=self.devices[0].link.query
|
||||
tacho=0
|
||||
def query(code,timeout=2):
|
||||
nonlocal tacho
|
||||
raw=original(code,timeout)
|
||||
if code==4 and self.currents:
|
||||
tacho+=4
|
||||
raw=raw[:23]+struct.pack('>i',1200)+raw[27:]
|
||||
raw=raw[:45]+struct.pack('>ii',tacho,tacho)+raw[53:]
|
||||
return raw
|
||||
self.devices[0].link.query=query
|
||||
command=self.command_for_pulse();command['parameters'].update(current_a=30,duration_s=30)
|
||||
result=self.service.execute(command)['result']
|
||||
self.assertEqual(result['outcome'],'duration')
|
||||
self.assertEqual(max(amps for _,amps in self.currents),30)
|
||||
self.assertEqual({i for i,_ in self.currents},{0})
|
||||
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
import json
|
||||
self.assertLess(len(json.dumps(result).encode()),1024*1024)
|
||||
|
||||
def test_high_current_without_motion_stops_and_does_not_auto_restart(self):
|
||||
command=self.command_for_pulse();command['parameters'].update(current_a=30,duration_s=30)
|
||||
result=self.service.execute(command)['result']
|
||||
self.assertIn('движение не подтверждается',result['outcome'])
|
||||
self.assertLess(result['samples'][-1]['at'],5)
|
||||
self.assertLess(max(amps for _,amps in self.currents),10)
|
||||
self.assertIsNone(result['samples'][-1]['commanded_current_a'])
|
||||
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
|
||||
def test_reported_speed_alone_cannot_reset_stall_without_tachometer_motion(self):
|
||||
original=self.devices[0].link.query
|
||||
def query(code,timeout=2):
|
||||
raw=original(code,timeout)
|
||||
if code==4 and self.currents:raw=raw[:23]+struct.pack('>i',1200)+raw[27:]
|
||||
return raw
|
||||
self.devices[0].link.query=query
|
||||
command=self.command_for_pulse();command['parameters'].update(current_a=30,duration_s=30)
|
||||
result=self.service.execute(command)['result']
|
||||
self.assertIn('движение не подтверждается',result['outcome'])
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
|
||||
def test_target_configuration_limits_remain_binding(self):
|
||||
self.motor=configuration('motor',{'motor_type':2,'l_current_max':10,'l_in_current_max':10,'l_min_erpm':-2000,'l_max_erpm':2000,'l_max_duty':0.1})
|
||||
command=self.command_for_pulse();command['parameters'].update(current_a=30,duration_s=30)
|
||||
result=self.service.execute(command)
|
||||
self.assertEqual(result['state'],'error')
|
||||
self.assertIn('настроенный предел',result['error'])
|
||||
self.assertEqual(self.sent,[])
|
||||
limits=decode(self.motor,'motor')
|
||||
baseline={'fault_code':0,'input_voltage_v':50,'mos_temperature_c':30,'motor_current_a':0,'erpm':0,'duty':0}
|
||||
for field,value in [('erpm',2001),('duty',0.101),('motor_current_a',10.1)]:
|
||||
with self.assertRaises(LimitExceeded):check_values({**baseline,field:value},moving=True,current_a=10,motor=limits)
|
||||
|
||||
def test_arbitrary_current_cannot_enter_motor_loop(self):
|
||||
for amps in (True,0,0.4,30.001,31,100,'5',float('nan'),float('inf')):
|
||||
command=self.command_for_pulse();command['parameters']['current_a']=amps
|
||||
with self.assertRaises(ValueError):self.service.execute(command)
|
||||
self.assertEqual(self.sent,[])
|
||||
for amps in (0.5,1.7,3,5,10,30):
|
||||
self.assertEqual(Decoder().feed(current_packet(amps)),[bytes([6])+struct.pack('>i',round(amps*1000))])
|
||||
for amps in (False,-1,0,30.001,float('nan'),float('inf')):
|
||||
with self.assertRaises(ValueError):current_packet(amps)
|
||||
|
||||
def test_limit_stop_keeps_triggering_sample_and_releases_every_controller(self):
|
||||
original=self.devices[0].link.query
|
||||
def query(code,timeout=2):
|
||||
raw=original(code,timeout)
|
||||
if code==4 and self.sent.count((0,'current'))==1:
|
||||
raw=raw[:21]+struct.pack('>h',260)+raw[23:]
|
||||
return raw
|
||||
self.devices[0].link.query=query
|
||||
result=self.service.execute(self.command_for_pulse())['result']
|
||||
self.assertIn('заполнение PWM 26 %',result['outcome'])
|
||||
self.assertEqual(result['limit_violation'],{'field':'duty','value':0.26,'minimum':-0.25,'maximum':0.25})
|
||||
self.assertEqual(result['samples'][-1]['devices'][self.devices[0].id]['duty'],0.26)
|
||||
self.assertIsNone(result['samples'][-1]['commanded_current_a'])
|
||||
self.assertEqual(self.sent.count((0,'current')),1)
|
||||
self.assertEqual(self.sent[-2:],[(0,'release'),(1,'release')])
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
|
||||
def test_each_telemetry_limit_reports_its_own_measured_value(self):
|
||||
baseline={'fault_code':0,'input_voltage_v':50,'mos_temperature_c':30,'motor_current_a':0,'erpm':0,'duty':0}
|
||||
for field,actual in [('fault_code',1),('input_voltage_v',61),('mos_temperature_c',66),('motor_current_a',-8.1),('erpm',6001),('duty',-0.251)]:
|
||||
with self.subTest(field=field):
|
||||
with self.assertRaises(LimitExceeded) as raised:check_values({**baseline,field:actual},moving=True,current_a=5)
|
||||
self.assertEqual(raised.exception.violation['field'],field)
|
||||
self.assertEqual(raised.exception.violation['value'],actual)
|
||||
|
||||
# Parent ServiceTests use the generic firmware/read-only fake.
|
||||
test_two_attachments_promote_independently = None
|
||||
test_uuid_collision_never_aliases_a_controller = None
|
||||
test_reconnect_invalidates_old_session_but_retains_uuid = None
|
||||
test_receipt_replay_never_repeats_serial_query = None
|
||||
test_backup_is_raw_hashed_and_bound_to_identity = None
|
||||
test_arbitrary_writes_parameters_and_expired_requests_rejected = None
|
||||
|
||||
|
||||
if __name__=='__main__':unittest.main()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Faults at the actual subprocess/RPC boundary, with no serial hardware."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from runtime.native_link import NativeLink
|
||||
|
||||
|
||||
class NativeBoundaryTests(unittest.TestCase):
|
||||
def link(self, handler):
|
||||
value = object.__new__(NativeLink)
|
||||
value.buffer = b""; value.sequence = 0; value.hall_pending = False
|
||||
value.check = lambda:None
|
||||
script = 'import sys,json,time\nfor line in sys.stdin:\n r=json.loads(line)\n ' + handler + '\n'
|
||||
value.process = subprocess.Popen([sys.executable, '-u', '-c', script],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, bufsize=0)
|
||||
self.addCleanup(value.close)
|
||||
return value
|
||||
|
||||
def test_success_returns_upstream_payload_and_keeps_one_process(self):
|
||||
link = self.link('print(json.dumps({"id":r["id"],"ok":True,"result":{"payload":"BA=="}}),flush=True)')
|
||||
pid = link.process.pid
|
||||
self.assertEqual(link.query(4),b'\x04')
|
||||
self.assertEqual(link.query(4),b'\x04')
|
||||
self.assertEqual(link.process.pid,pid)
|
||||
self.assertEqual(link.sequence,2)
|
||||
trace=link.last_rpc
|
||||
self.assertEqual(trace["stage"],"complete")
|
||||
stages=[trace[k] for k in ("attachment_before_ms","request_write_ms","native_response_ms","attachment_after_ms")]
|
||||
self.assertTrue(all(value>=0 for value in stages))
|
||||
self.assertLessEqual(sum(stages),trace["elapsed_ms"]+.1)
|
||||
|
||||
def test_wrong_identity_closes_stream_and_cannot_replay_motor_command(self):
|
||||
link = self.link('print(json.dumps({"id":r["id"]+1,"ok":True,"result":{}}),flush=True)')
|
||||
with self.assertRaises(ValueError):link.test_current(5)
|
||||
self.assertFalse(link.alive)
|
||||
with self.assertRaises(OSError):link.test_current(5)
|
||||
|
||||
def test_missing_ack_terminates_process_without_automatic_retry(self):
|
||||
link = self.link('time.sleep(5)')
|
||||
process = link.process
|
||||
with self.assertRaises(TimeoutError):link.rpc('lease',timeout=.03)
|
||||
self.assertFalse(link.alive)
|
||||
self.assertIsNotNone(process.poll())
|
||||
self.assertEqual(link.sequence,1)
|
||||
|
||||
def test_attachment_change_closes_owner_before_sending(self):
|
||||
link = self.link('print(json.dumps({"id":r["id"],"ok":True,"result":{}}),flush=True)')
|
||||
link.check = lambda: (_ for _ in ()).throw(OSError('changed attachment'))
|
||||
with self.assertRaises(OSError):link.rpc('engine')
|
||||
self.assertFalse(link.alive)
|
||||
|
||||
def test_native_error_and_malformed_result_close_owner(self):
|
||||
for response in ('{"id":r["id"],"ok":False,"error":"disconnected"}',
|
||||
'{"id":r["id"],"ok":True,"result":[]}'):
|
||||
with self.subTest(response=response):
|
||||
link=self.link('print(json.dumps('+response+'),flush=True)')
|
||||
with self.assertRaises((OSError,ValueError)):link.rpc('engine')
|
||||
self.assertFalse(link.alive)
|
||||
|
||||
def test_blocked_pipe_closes_owner(self):
|
||||
link=self.link('time.sleep(5)')
|
||||
with patch('runtime.native_link.select.select',return_value=([],[],[])):
|
||||
with self.assertRaises(TimeoutError):link.rpc('engine')
|
||||
self.assertFalse(link.alive)
|
||||
|
||||
def test_timeout_retains_command_budget_and_attachment_state(self):
|
||||
link=self.link('print(json.dumps({"id":r["id"],"ok":False,"error":"Native query timed out or disconnected"}),flush=True)')
|
||||
with self.assertRaises(OSError) as caught: link.query(31,timeout=.06)
|
||||
trace=caught.exception.native_rpc
|
||||
self.assertEqual(trace['command'],31)
|
||||
self.assertEqual(trace['timeout_ms'],60)
|
||||
self.assertTrue(trace['process_alive'])
|
||||
self.assertTrue(trace['attachment_present'])
|
||||
self.assertGreater(trace['elapsed_ms'],0)
|
||||
self.assertFalse(link.alive)
|
||||
|
||||
def test_native_packet_diagnostics_survive_fail_closed_boundary(self):
|
||||
detail = {"request_emitted": True, "packets_sent": 1, "packets_received": 0,
|
||||
"serial_bytes_written": 6, "port_connected": True, "serial_error": 0,
|
||||
"events": [{"event": "serial_written", "bytes": 6, "at_ms": 0.2}]}
|
||||
link = self.link('print(json.dumps({"id":r["id"],"ok":False,"error":"Native query timed out or disconnected","diagnostics":'+repr(detail)+'}),flush=True)')
|
||||
with self.assertRaises(OSError) as caught: link.query(31, timeout=.06)
|
||||
self.assertEqual(caught.exception.native_rpc['transport'], detail)
|
||||
self.assertEqual(link.sequence, 1)
|
||||
self.assertFalse(link.alive)
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Synthetic protocol and ownership tests; never opens a real serial port."""
|
||||
import base64
|
||||
import binascii
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "src"))
|
||||
from runtime import SCHEMA
|
||||
from runtime.protocol import Decoder, firmware, request, values
|
||||
from runtime.serial import Attachment
|
||||
from runtime.service import Service
|
||||
|
||||
|
||||
def frame(payload):
|
||||
header = bytes([2, len(payload)]) if len(payload) < 256 else b"\x03" + len(payload).to_bytes(2, "big")
|
||||
return header + payload + binascii.crc_hqx(payload, 0).to_bytes(2, "big") + b"\x03"
|
||||
|
||||
|
||||
def fw(index=1):
|
||||
return b"\x00\x06\x06SYNTHETIC\x00" + bytes([index]) * 12 + b"\x00\x00\x00\x00"
|
||||
|
||||
|
||||
def telemetry():
|
||||
return b"\x04" + struct.pack(">hhiiiihihiiiiiiB", 215, 190, -123, 456, 0, 0, 123, 600, 481,
|
||||
100, 0, 200, 0, 30, 30, 0)
|
||||
|
||||
|
||||
class ProtocolTests(unittest.TestCase):
|
||||
def test_transmit_has_no_motor_or_write_escape_hatch(self):
|
||||
for code in range(256):
|
||||
if code in (0, 4, 14, 17, 31, 62):
|
||||
self.assertEqual(Decoder().feed(request(code)), [bytes([code])])
|
||||
else:
|
||||
with self.assertRaises(ValueError): request(code)
|
||||
for value in (True, b"\0", "0", -1, 256):
|
||||
with self.assertRaises(ValueError): request(value)
|
||||
|
||||
def test_fragmented_and_combined_frames(self):
|
||||
samples = [fw(), bytes([14]) + bytes(range(256)) * 4, telemetry()]
|
||||
decoder = Decoder(); actual = []
|
||||
for byte in b"noise" + b"".join(map(frame, samples)):
|
||||
actual += decoder.feed(bytes([byte]))
|
||||
self.assertEqual(actual, samples)
|
||||
|
||||
def test_crc_and_bounds(self):
|
||||
bad = bytearray(frame(fw())); bad[-2] ^= 1
|
||||
self.assertEqual(Decoder().feed(bad), [])
|
||||
self.assertEqual(Decoder().feed(b"\x04\xff\xff\xff" + frame(fw())), [fw()])
|
||||
with self.assertRaises(ValueError): Decoder().feed(bytes(30000))
|
||||
|
||||
def test_identity_requires_uuid_and_bounded_name(self):
|
||||
self.assertEqual(firmware(fw())["uuid"], "01" * 12)
|
||||
for data in (b"", fw()[:5], fw()[:20], b"\0\6\6" + b"x" * 150, fw(0)):
|
||||
with self.assertRaises(ValueError): firmware(data)
|
||||
|
||||
def test_telemetry_scales_signed_values_and_truncation(self):
|
||||
result = values(telemetry())
|
||||
self.assertEqual(result["motor_current_a"], -1.23)
|
||||
self.assertEqual(result["input_voltage_v"], 48.1)
|
||||
self.assertEqual(result["erpm"], 600)
|
||||
self.assertNotIn("timeout", result)
|
||||
for length in range(54):
|
||||
with self.assertRaises(ValueError): values(telemetry()[:length])
|
||||
|
||||
|
||||
class FakeLink:
|
||||
instances = []
|
||||
identities = {}
|
||||
|
||||
def __init__(self, attachment):
|
||||
self.attachment = attachment; self.commands = []; self.closed = False
|
||||
self.instances.append(self)
|
||||
|
||||
def query(self, code, timeout=2):
|
||||
request(code)
|
||||
self.commands.append(code)
|
||||
if code == 0: return fw(self.identities.get(self.attachment.usb, int(self.attachment.address)))
|
||||
if code == 4: return telemetry()
|
||||
return bytes([code]) + b"\x12\x34\x56\x78opaque-config"
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class ServiceTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.attachments = [Attachment("1-2", "1", "ttyACM0", "12"), Attachment("1-3", "2", "ttyACM1", "12")]
|
||||
FakeLink.instances = []; FakeLink.identities = {}
|
||||
self.service = Service(self.temp.name, lambda: self.attachments, FakeLink)
|
||||
self.service.scan()
|
||||
|
||||
def tearDown(self): self.temp.cleanup()
|
||||
|
||||
def command(self, item=None, action="vesc.telemetry.read"):
|
||||
item = item or self.service.inventory("node_synthetic")["items"][0]
|
||||
now = datetime.now(timezone.utc); identifier = "op_" + uuid.uuid4().hex
|
||||
return {"api_version": SCHEMA, "kind": "OperationRequest", "operation_id": identifier,
|
||||
"idempotency_key": identifier, "session": {"session_id": item["snapshot"]["context"]["session_id"], "device_id": item["id"]},
|
||||
"requested_at": now.isoformat(), "deadline_at": (now + timedelta(seconds=60)).isoformat(),
|
||||
"action_id": action, "parameters": {}}
|
||||
|
||||
def test_two_attachments_promote_independently(self):
|
||||
items = self.service.inventory("node_synthetic")["items"]
|
||||
self.assertEqual(len({i["id"] for i in items}), 2)
|
||||
self.assertTrue(all(i["id"] != i["attachment_id"] and i["verified"] for i in items))
|
||||
result = self.service.execute(self.command(items[1]))
|
||||
self.assertEqual(result["state"], "complete")
|
||||
self.assertEqual(FakeLink.instances[0].commands, [0])
|
||||
self.assertEqual(FakeLink.instances[1].commands, [0, 0, 4])
|
||||
|
||||
def test_uuid_collision_never_aliases_a_controller(self):
|
||||
FakeLink.identities = {"1-2": 1, "1-3": 1}
|
||||
for device in self.service.devices.values(): device.close()
|
||||
self.service.scan()
|
||||
items = self.service.inventory("node_synthetic")["items"]
|
||||
self.assertEqual(len({i["id"] for i in items}), 2)
|
||||
self.assertTrue(all(not i["verified"] for i in items))
|
||||
with self.assertRaises(ValueError): self.service.execute(self.command(items[0]))
|
||||
|
||||
def test_reconnect_invalidates_old_session_but_retains_uuid(self):
|
||||
old = self.service.inventory("node_synthetic")["items"][0]; command = self.command(old)
|
||||
self.attachments = self.attachments[1:]; self.service.scan()
|
||||
self.assertTrue(FakeLink.instances[0].closed)
|
||||
FakeLink.identities["1-2"] = 1
|
||||
self.attachments.append(Attachment("1-2", "3", "ttyACM4", "12")); self.service.scan()
|
||||
current = next(i for i in self.service.inventory("node_synthetic")["items"] if i["id"] == old["id"])
|
||||
self.assertNotEqual(current["snapshot"]["context"]["session_id"], old["snapshot"]["context"]["session_id"])
|
||||
with self.assertRaises(ValueError): self.service.execute(command)
|
||||
|
||||
def test_receipt_replay_never_repeats_serial_query(self):
|
||||
command = self.command(); first = self.service.execute(command)
|
||||
before = list(FakeLink.instances[0].commands)
|
||||
self.assertEqual(self.service.execute(command), first)
|
||||
self.assertEqual(FakeLink.instances[0].commands, before)
|
||||
command["action_id"] = "vesc.config.backup"
|
||||
with self.assertRaises(ValueError): self.service.execute(command)
|
||||
|
||||
def test_native_process_exit_invalidates_session_before_reconnection(self):
|
||||
old = self.service.inventory("node_synthetic")["items"][0]
|
||||
command = self.command(old)
|
||||
process = FakeLink.instances[0]
|
||||
process.alive = False
|
||||
self.service.scan()
|
||||
current = next(i for i in self.service.inventory("node_synthetic")["items"] if i["id"] == old["id"])
|
||||
self.assertTrue(process.closed)
|
||||
self.assertNotEqual(current["snapshot"]["context"]["session_id"], old["snapshot"]["context"]["session_id"])
|
||||
with self.assertRaises(ValueError): self.service.execute(command)
|
||||
|
||||
def test_backup_is_raw_hashed_and_bound_to_identity(self):
|
||||
result = self.service.execute(self.command(action="vesc.config.backup"))
|
||||
self.assertEqual(result["state"], "complete")
|
||||
value = result["result"]
|
||||
self.assertFalse(value["decoded"])
|
||||
self.assertEqual(base64.b64decode(value["configs"]["motor"]["payload"])[0], 14)
|
||||
backups = list(Path(self.temp.name).glob("backup_*.json"))
|
||||
self.assertEqual(json.loads(backups[0].read_text()), value)
|
||||
self.assertEqual(backups[0].stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(FakeLink.instances[0].commands, [0, 0, 14, 17, 0])
|
||||
|
||||
def test_arbitrary_writes_parameters_and_expired_requests_rejected(self):
|
||||
for action in ("start", "stop", "settings.apply", "raw", "firmware.write"):
|
||||
with self.assertRaises(ValueError): self.service.execute(self.command(action=action))
|
||||
command = self.command(); command["parameters"] = {"packet": "anything"}
|
||||
with self.assertRaises(ValueError): self.service.execute(command)
|
||||
command = self.command(); command["deadline_at"] = command["requested_at"]
|
||||
with self.assertRaises(ValueError): self.service.execute(command)
|
||||
self.assertTrue(all(link.commands == [0] for link in FakeLink.instances))
|
||||
|
||||
|
||||
if __name__ == "__main__": unittest.main()
|
||||
@@ -0,0 +1,206 @@
|
||||
"""Fault injection for true hold time, volatile limits and native Hall lifecycle."""
|
||||
import struct
|
||||
import unittest
|
||||
|
||||
import test_motor_test
|
||||
from test_motor_test import configuration
|
||||
from runtime.configuration import decode
|
||||
from runtime.protocol import Decoder, speed_packet, hall_packet
|
||||
from runtime.temporary_limits import FIELDS, packet
|
||||
from runtime.speed_hold import SpeedHold
|
||||
from runtime.hall_detection import parse_result
|
||||
|
||||
|
||||
class RunTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.case = test_motor_test.PulseTests('test_fixed_wire_commands_no_broadcast_and_no_parameters')
|
||||
self.case.setUp()
|
||||
self.addCleanup(self.case.tearDown)
|
||||
c = self.case
|
||||
self.configs = {}
|
||||
self.speeds = []
|
||||
self.moving = True
|
||||
self.tacho = 0
|
||||
self.hall_started = None
|
||||
self.sets = []
|
||||
for device in c.devices:
|
||||
self.configs[device.id] = configuration('motor', {**decode(c.motor,'motor'),
|
||||
'l_current_min':-60,'l_current_max_scale':1,'l_current_min_scale':1})
|
||||
original = device.link.query
|
||||
def query(code,timeout=2,original=original,device=device):
|
||||
if code == 14: return self.configs[device.id]
|
||||
raw = original(code,timeout)
|
||||
if code == 4 and device is c.devices[0]:
|
||||
if self.hall_started is not None and c.clock.now - self.hall_started >= 12:
|
||||
device.link.hall_result = bytes([28,255,1,34,67,100,134,167,255,0])
|
||||
if self.moving and self.speeds:
|
||||
self.tacho += 6
|
||||
rpm = round(self.speeds[-1][1])
|
||||
raw = raw[:23]+struct.pack('>i',rpm)+raw[27:]
|
||||
raw = raw[:45]+struct.pack('>ii',self.tacho,self.tacho)+raw[53:]
|
||||
return raw
|
||||
device.link.query = query
|
||||
def set_limits(config,device=device):
|
||||
self.sets.append((device.id,config))
|
||||
old = decode(self.configs[device.id],'motor')
|
||||
self.configs[device.id] = configuration('motor',{**old,**{k:config[k] for k in FIELDS}})
|
||||
device.link.set_temporary_limits = set_limits
|
||||
device.link.test_speed = lambda speed,device=device:self.speeds.append((device.id,speed))
|
||||
device.link.hall_result = None
|
||||
device.link.hall_pending = False
|
||||
def hall(device=device):
|
||||
self.hall_started = c.clock.now
|
||||
device.link.hall_pending = True
|
||||
device.link.detect_hall = hall
|
||||
self.originals = dict(self.configs)
|
||||
|
||||
def run_command(self):
|
||||
command = self.case.command_for_pulse()
|
||||
command['action_id'] = 'vesc.motor.run'
|
||||
command['parameters'].update(erpm=1200,current_a=30,duration_s=30)
|
||||
from datetime import datetime,timedelta
|
||||
command['deadline_at']=(datetime.fromisoformat(command['requested_at'])+timedelta(seconds=90)).isoformat()
|
||||
return command
|
||||
|
||||
def test_thirty_seconds_excludes_ramp_and_settle_and_restores_limits(self):
|
||||
result=self.case.service.execute(self.run_command())['result']
|
||||
self.assertEqual(result['outcome'],'duration',result['outcome'])
|
||||
self.assertGreaterEqual(result['rotation_s'],30)
|
||||
self.assertLess(result['rotation_s'],30.1)
|
||||
self.assertGreater(result['samples'][-1]['at'],32)
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
self.assertTrue(result['limits_restored'])
|
||||
self.assertEqual(self.configs,self.originals)
|
||||
self.assertEqual({device for device,_ in self.speeds},{self.case.devices[0].id})
|
||||
applied=self.sets[0][1]
|
||||
self.assertAlmostEqual(applied['l_current_max']*applied['l_current_max_scale'],30)
|
||||
self.assertAlmostEqual(applied['l_current_min']*applied['l_current_min_scale'],-30)
|
||||
self.assertFalse(list(self.case.service.root.glob('limits_*.json')))
|
||||
|
||||
def test_stationary_motor_never_counts_command_time_as_rotation(self):
|
||||
self.moving=False
|
||||
result=self.case.service.execute(self.run_command())['result']
|
||||
self.assertEqual(result['rotation_s'],0)
|
||||
self.assertIn('не вышел',result['outcome'])
|
||||
self.assertGreaterEqual(result['samples'][-1]['at'],15)
|
||||
self.assertTrue(result['limits_restored'])
|
||||
|
||||
def test_lost_ack_after_limit_write_restores_before_any_speed_command(self):
|
||||
target=self.case.devices[0]
|
||||
original=target.link.set_temporary_limits
|
||||
def send(config):
|
||||
original(config)
|
||||
if len(self.sets)==1:raise TimeoutError('lost ACK after applying')
|
||||
target.link.set_temporary_limits=send
|
||||
result=self.case.service.execute(self.run_command())['result']
|
||||
self.assertEqual(self.speeds,[])
|
||||
self.assertTrue(result['limits_restored'])
|
||||
self.assertEqual(self.configs,self.originals)
|
||||
|
||||
def test_pending_limits_recovered_after_interruption_without_motor_command(self):
|
||||
target=self.case.devices[0]
|
||||
self.case.service.motor.limits.apply(target,self.originals[target.id],5)
|
||||
self.assertTrue(self.case.service.motor.limits.pending(target))
|
||||
self.case.service.scan()
|
||||
self.assertEqual(self.configs,self.originals)
|
||||
self.assertEqual(self.speeds,[])
|
||||
self.assertFalse(self.case.service.motor.limits.pending(target))
|
||||
|
||||
def test_external_configuration_change_never_overwritten_by_recovery(self):
|
||||
target=self.case.devices[0]
|
||||
self.case.service.motor.limits.apply(target,self.originals[target.id],5)
|
||||
self.configs[target.id]=configuration('motor',{**decode(self.configs[target.id],'motor'),'foc_motor_r':0.1})
|
||||
before=self.configs[target.id]
|
||||
self.case.service.scan()
|
||||
self.assertEqual(before,self.configs[target.id])
|
||||
self.assertTrue(self.case.service.motor.limits.pending(target))
|
||||
self.assertEqual(self.case.service.execute(self.run_command())['state'],'error')
|
||||
self.assertEqual(self.speeds,[])
|
||||
|
||||
def test_native_hall_returns_measured_table_without_applying_it(self):
|
||||
command=self.case.command(action='vesc.hall.measure')
|
||||
command['parameters']={'rig_clear':True,'native_cycle_confirmed':True,
|
||||
'sessions':{d.id:d.session for d in self.case.devices}}
|
||||
receipt=self.case.service.execute(command)
|
||||
self.assertEqual(receipt['state'],'complete',receipt)
|
||||
result=receipt['result']
|
||||
self.assertTrue(result['completed'])
|
||||
self.assertTrue(result['measurement']['valid_six_states'])
|
||||
self.assertTrue(result['configuration_restored'])
|
||||
self.assertTrue(result['release_confirmed'])
|
||||
self.assertFalse(result['configuration_written'])
|
||||
self.assertEqual(self.configs,self.originals)
|
||||
self.assertEqual(self.speeds,[])
|
||||
self.assertFalse((self.case.service.root/'hall-pending.json').exists())
|
||||
started=self.hall_started
|
||||
self.assertEqual(self.case.service.execute(command),receipt)
|
||||
self.assertEqual(started,self.hall_started)
|
||||
|
||||
def test_native_hall_requires_its_own_explicit_confirmation(self):
|
||||
command=self.case.command(action='vesc.hall.measure')
|
||||
command['parameters']={'rig_clear':True,'native_cycle_confirmed':False,
|
||||
'sessions':{d.id:d.session for d in self.case.devices}}
|
||||
with self.assertRaises(ValueError):self.case.service.execute(command)
|
||||
self.assertIsNone(self.hall_started)
|
||||
|
||||
def test_rc_preemption_releases_speed_control_and_restores_limits(self):
|
||||
original=self.case.devices[0].link.test_speed
|
||||
def speed(value):
|
||||
original(value)
|
||||
self.case.changed=True
|
||||
self.case.devices[0].link.test_speed=speed
|
||||
result=self.case.service.execute(self.run_command())['result']
|
||||
self.assertEqual(len(self.speeds),1)
|
||||
self.assertEqual(result['rotation_s'],0)
|
||||
self.assertTrue(self.case.service.motor.latched)
|
||||
# Restoring while a receiver actively commands would enlarge RC torque;
|
||||
# keep the lower limits until neutral, then recover without auto-start.
|
||||
self.assertFalse(result['limits_restored'])
|
||||
self.case.changed=False
|
||||
self.case.service.scan()
|
||||
self.assertEqual(self.configs,self.originals)
|
||||
self.assertTrue(self.case.service.motor.latched)
|
||||
self.assertEqual(len(self.speeds),1)
|
||||
|
||||
def test_hall_result_timeout_latches_and_never_starts_a_second_cycle(self):
|
||||
self.case.devices[0].link.detect_hall=lambda:None
|
||||
command=self.case.command(action='vesc.hall.measure')
|
||||
command['parameters']={'rig_clear':True,'native_cycle_confirmed':True,
|
||||
'sessions':{d.id:d.session for d in self.case.devices}}
|
||||
result=self.case.service.execute(command)['result']
|
||||
self.assertFalse(result['completed'])
|
||||
self.assertTrue((self.case.service.root/'hall-pending.json').exists())
|
||||
self.assertTrue(self.case.service.motor.latched)
|
||||
self.assertEqual(self.case.service.execute(self.run_command())['state'],'error')
|
||||
|
||||
|
||||
class HoldClockTests(unittest.TestCase):
|
||||
def test_interrupted_motion_and_usb_gap_are_excluded(self):
|
||||
hold=SpeedHold(1200,30,0)
|
||||
for i in range(40):hold.update(i/10,{'erpm':1200,'tachometer':i})
|
||||
before=hold.rotation_s
|
||||
hold.update(4.5,{'erpm':1200,'tachometer':50})
|
||||
self.assertEqual(hold.rotation_s,before)
|
||||
hold.update(4.6,{'erpm':0,'tachometer':50})
|
||||
self.assertEqual(hold.rotation_s,before)
|
||||
_,_,error=hold.update(6.7,{'erpm':0,'tachometer':50})
|
||||
self.assertIn('перестал удерживать',error)
|
||||
|
||||
def test_speed_without_changing_tachometer_never_starts_timer(self):
|
||||
hold=SpeedHold(1200,30,0)
|
||||
for i in range(151):_,_,error=hold.update(i/10,{'erpm':1200,'tachometer':0})
|
||||
self.assertEqual(hold.rotation_s,0)
|
||||
self.assertIsNone(hold.hold_started)
|
||||
self.assertIn('не вышел',error)
|
||||
|
||||
def test_exact_native_wire_no_flash_no_can_and_hall_failure(self):
|
||||
self.assertEqual(Decoder().feed(speed_packet(1200)),[bytes([8])+struct.pack('>i',1200)])
|
||||
self.assertEqual(Decoder().feed(hall_packet()),[bytes([28])+struct.pack('>i',5000)])
|
||||
wire=Decoder().feed(packet(dict.fromkeys(FIELDS,1.0)))[0]
|
||||
self.assertEqual(wire[:5],bytes([48,0,0,1,0]))
|
||||
self.assertEqual(len(wire),45)
|
||||
failure=parse_result(bytes([28,255,255,255,100,255,255,255,255,1]))
|
||||
self.assertFalse(failure['valid_six_states'])
|
||||
self.assertEqual(failure['observed_states'],[3])
|
||||
for value in (float('nan'),float('inf'),-1,3001,True):
|
||||
with self.assertRaises(ValueError):speed_packet(value)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Regression for deterministic package mtimes and root-created Python caches."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import py_compile
|
||||
import runpy
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
class UpgradeTests(unittest.TestCase):
|
||||
def test_same_size_source_with_old_pyc_loads_new_version_after_installer_step(self):
|
||||
clear = runpy.run_path(str(Path(__file__).parents[1] / "packaging/clear_runtime_cache.py"))["clear"]
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp); package = root / "runtime"; package.mkdir()
|
||||
init = package / "__init__.py"; init.write_text('VERSION="0.1.0"\n'); os.utime(init,(0,0))
|
||||
py_compile.compile(str(init),doraise=True)
|
||||
init.write_text('VERSION="0.2.0"\n');os.utime(init,(0,0))
|
||||
backup=root/'backup.json';backup.write_text('preserve')
|
||||
code='import sys;sys.path.insert(0,sys.argv[1]);import runtime;print(runtime.VERSION)'
|
||||
def version():return subprocess.check_output([sys.executable,'-I','-B','-c',code,str(root)],text=True).strip()
|
||||
self.assertEqual(version(),'0.1.0')
|
||||
clear(root);clear(root)
|
||||
self.assertEqual(version(),'0.2.0')
|
||||
self.assertEqual(backup.read_text(),'preserve')
|
||||
Reference in New Issue
Block a user