feat(rover): add drive profiles and leased remote command channel
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
# Rover control behaviour prototype
|
||||
|
||||
Status: **offline behavioural reference; no production actuation adapter**.
|
||||
Requested by the owner while unavailable for physical testing, 2026-09-24.
|
||||
Neither Core composition nor Node/VESC runtime imports these modules. No new
|
||||
driver, service, firmware, motor settings or authority capability is installed.
|
||||
|
||||
`src/profile.ts` contains the versioned desired-profile parser, Tank and Arcade
|
||||
mixers, continuous deadband, linear/squared response, relative output scaling,
|
||||
and fan-out to any number of UUID-bound motors on both sides. UUIDs must be
|
||||
unique; a missing side is rejected. `forwardSign` is the verified sign at the
|
||||
future actuator adapter, not a copy of `m_invert_direction` and not a second
|
||||
automatic inversion of the current VESC configuration.
|
||||
|
||||
The normalized result has no electrical units. It cannot be sent as amperes,
|
||||
watts, duty, ERPM or speed without a separately qualified adapter and individual
|
||||
motor, battery/BMS and braking limits. A profile output scale of 80% is **not**
|
||||
the owner's proposed 20% safety margin against verified equipment ratings.
|
||||
The current real RC path is PPM Duty Cycle; this prototype does not change it.
|
||||
|
||||
Arcade uses continuous diamond desaturation, with forward positive and right
|
||||
yaw positive. For shaped inputs `v` and `r`, the pair is `(v+r, v-r)` multiplied
|
||||
by `max(abs(v),abs(r))/(abs(v)+abs(r))`, or zero at the origin. This follows the
|
||||
WPILib ArcadeDriveIK geometry with the steering sign adapted to the UI.
|
||||
Reverse plus right still requests right yaw; it is not a car steering-wheel
|
||||
convention, curvature drive, a turn-radius controller or omnidirectional motion.
|
||||
No VESC Tool calibration algorithm is reproduced.
|
||||
|
||||
References inspected 2026-09-24:
|
||||
|
||||
- [WPILib drive classes](https://docs.wpilib.org/en/stable/docs/software/hardware-apis/motors/wpi-drive-classes.html)
|
||||
- [ArcadeDriveIK source](https://github.com/wpilibsuite/allwpilib/blob/main/wpilibc/src/main/native/cpp/drive/DifferentialDrive.cpp)
|
||||
|
||||
## Authority model
|
||||
|
||||
`src/authority.ts` is a pure deterministic model. Its output contains **intent**
|
||||
to stop all drives, revoke the Core epoch, flush queued motion and cancel
|
||||
autonomous motion tasks; it does not actually stop a motor or cancel a process.
|
||||
The future actuator must enforce the gate synchronously before asynchronous
|
||||
task cancellation. RC reception, drive supervision and telemetry must survive.
|
||||
|
||||
Boot, input loss, controller loss, stale/invalid data, a timing gap or Core
|
||||
command loss enter hold. First RC deflection while Core owns motion consumes
|
||||
the gesture and revokes the old token. Every assigned input must then be neutral
|
||||
and every motor must have trusted physical-stop evidence for the full configured
|
||||
interval. A held first gesture or repeated packet does not qualify. The next
|
||||
gesture starts RC manual operation. Neutral never resumes the previous Core
|
||||
task. Reacquisition requires a new explicit, nonreplayed request in neutral.
|
||||
Old Core commands cannot cross an epoch or process boot identity.
|
||||
|
||||
Policy values are mandatory constructor arguments. The demo uses 100 ms
|
||||
freshness / 200 ms neutral solely as synthetic fixtures, **not accepted rover
|
||||
reaction limits**. Use one monotonic clock domain from an authenticated local
|
||||
producer and a fresh unpredictable boot identity per instance. A token is a
|
||||
stale-command fence, not authentication or a replacement for access control.
|
||||
Calls and input types belong to a trusted model harness; this is not a public
|
||||
network request parser. Run a supervisory tick even when no new input arrives.
|
||||
|
||||
`link.live`, sample acquisition timestamps/sequences and `drive.stopped` must
|
||||
come from qualified evidence. A fresh USB response with old decoded PPM does
|
||||
not meet this contract. Zero motor current/duty alone is not physical stop.
|
||||
The current FW 5.02 input API does not supply all required evidence. In
|
||||
particular, stop-first independent of Mini needs an enforcement point outside
|
||||
Mini and coordination of **all** motor controllers. No such qualified mechanism
|
||||
is claimed by these tests. Receiver failsafe is also still awaiting acceptance.
|
||||
|
||||
Input axes are semantic controls, not invented receiver channels. The mixer for
|
||||
Tank requires two Y axes; Arcade requires both axes of the selected stick. The
|
||||
authority policy separately declares all `monitoredAxes`: the demo watches all
|
||||
four axes, so the other stick also stops Core in Arcade. Missing monitored axes
|
||||
are rejected, not synthesized as zero; all must return to neutral. The current two
|
||||
separate receiver-to-VESC PWM outputs do not establish access to those Arcade
|
||||
axes. The channel map, radio-link semantics and independent mixed RC path remain
|
||||
hardware integration work. Profile revision or binding changes require a fresh
|
||||
model initialized in hold, not an in-place live change.
|
||||
|
||||
## Preview and validation
|
||||
|
||||
Use the already installed Control Station toolchain; no new dependencies:
|
||||
|
||||
```sh
|
||||
cd apps/control-station
|
||||
node --test test/roverControl.test.mjs
|
||||
./node_modules/.bin/tsc --project tools/rover-control-preview/tsconfig.json
|
||||
node tools/rover-control-preview/build.mjs /absolute/artifact/directory
|
||||
```
|
||||
|
||||
The self-contained HTML uses canonical Design Guideline components. Its CSP
|
||||
disables all network connections, and no transport/serial code exists in the
|
||||
bundle. It is an owner-review artifact, outside production navigation. Browser
|
||||
storage and JSON export contain a **draft**, never confirmed applied state.
|
||||
It does not add USB controls, device-specific phantom entities or a runtime
|
||||
source-selection switch to the product. The synthetic takeover trace is
|
||||
engineering review content, not a proposed operator control panel.
|
||||
|
||||
Intended product placement remains the existing shared «Настройки борта» section
|
||||
on Core and Node, between computer details and devices. Alternatives (a new
|
||||
workspace or separate per-VESC control-mode selectors) would fragment a single
|
||||
vehicle-wide profile and are not used. Existing `Inspector`, `SettingsCard`,
|
||||
`InspectorSelectField`, `RangeControl`, `Button`, `StatusBadge` cover the review;
|
||||
no Design Guideline extensions or new visual primitives were needed.
|
||||
|
||||
Before production integration: verified input mapping and radio failsafe,
|
||||
independent stop-first actuator mechanism, desired/applied profile storage on
|
||||
Node with optimistic revision checking, all-member application receipts and
|
||||
failure recovery, then shared UI and supervised unloaded tests. Do not expose
|
||||
an enabled Apply button based only on successful model tests.
|
||||
@@ -0,0 +1,134 @@
|
||||
/** Behavioural reference ONLY. Not connected to the current VESC PPM path. */
|
||||
import {axisKeys, bounded, mix, parseProfile, type Axes, type ControlProfile, type Sides} from './profile';
|
||||
|
||||
export interface Sample { value: number; at: number; sequence: number }
|
||||
export interface DriveEvidence { at: number; healthy: boolean; stopped: boolean }
|
||||
export interface CoreCommand {
|
||||
token: string; sequence: number; at: number; expires: number; demand: Sides;
|
||||
}
|
||||
export interface Observation {
|
||||
now: number;
|
||||
// These are trusted receiver timestamps and link status, NOT USB read times.
|
||||
link: { state: 'live' | 'lost' | 'unknown'; at: number };
|
||||
axes: Partial<Record<keyof Axes, Sample>>;
|
||||
drives: Record<string, DriveEvidence>;
|
||||
command?: CoreCommand;
|
||||
requestCore?: {owner: 'remote' | 'autonomy'; sequence: number; at: number};
|
||||
}
|
||||
export interface Policy {
|
||||
maxAgeMs: number; neutralMs: number; maxCommandMs: number;
|
||||
/** Verified RC controls that can take over, including non-driving stick axes. */
|
||||
monitoredAxes: readonly (keyof Axes)[];
|
||||
}
|
||||
export type State = 'hold' | 'rc-ready' | 'rc-manual' | 'core';
|
||||
export interface Decision {
|
||||
state: State; reason: string; demand: Sides; token: string | null;
|
||||
owner: 'remote' | 'autonomy' | 'rc' | null;
|
||||
stopAll: boolean; flushMotionQueue: boolean; cancelMotionTasks: boolean;
|
||||
}
|
||||
const zero = (): Sides => ({left: 0, right: 0});
|
||||
|
||||
export class AuthorityModel {
|
||||
private state: State = 'hold';
|
||||
private epoch = 0;
|
||||
private token: string | null = null;
|
||||
private owner: Decision['owner'] = null;
|
||||
private neutralSince: number | null = null;
|
||||
private lastNow = -Infinity;
|
||||
private lastCommand = -1;
|
||||
private lastRequest = -1;
|
||||
private samples: Partial<Record<keyof Axes, Sample>> = {};
|
||||
private readonly profile: ControlProfile;
|
||||
private readonly motors: string[];
|
||||
private readonly policy: Policy;
|
||||
|
||||
constructor(profile: ControlProfile, motors: readonly string[], policy: Policy, private readonly bootId: string) {
|
||||
this.profile = parseProfile(profile);
|
||||
this.motors = [...motors]; this.policy = {...policy, monitoredAxes:[...policy.monitoredAxes]};
|
||||
if (!bootId || !motors.length || new Set(motors).size !== motors.length || motors.some(id => !id)
|
||||
|| !bounded(policy.maxAgeMs, 1, 10000) || !bounded(policy.neutralMs, 1, 10000)
|
||||
|| !bounded(policy.maxCommandMs, 1, 10000)
|
||||
|| new Set(policy.monitoredAxes).size !== policy.monitoredAxes.length
|
||||
|| policy.monitoredAxes.some(key => !['leftY','rightY','leftX','rightX'].includes(key))
|
||||
|| axisKeys(this.profile).some(key => !policy.monitoredAxes.includes(key))) throw Error('Invalid authority policy');
|
||||
}
|
||||
private result(reason: string, demand = zero(), revoke = false): Decision {
|
||||
return {state: this.state, reason, demand, token: this.token, owner: this.owner,
|
||||
stopAll: this.state === 'hold', flushMotionQueue: revoke, cancelMotionTasks: revoke};
|
||||
}
|
||||
private hold(reason: string): Decision {
|
||||
const revoke = this.state === 'core';
|
||||
if (this.state !== 'hold') this.epoch++;
|
||||
this.state = 'hold'; this.token = null; this.owner = null;
|
||||
this.neutralSince = null; this.lastCommand = -1;
|
||||
return this.result(reason, zero(), revoke);
|
||||
}
|
||||
step(input: Observation): Decision {
|
||||
const now = input.now;
|
||||
if (!Number.isFinite(now) || now < 0 || now < this.lastNow) return this.hold('clock-invalid');
|
||||
const gap = now - this.lastNow; this.lastNow = now;
|
||||
const fresh = (at: number) => Number.isFinite(at) && at <= now && now - at <= this.policy.maxAgeMs;
|
||||
const request = input.requestCore;
|
||||
const newRequest = !!request && Number.isSafeInteger(request.sequence) && request.sequence > this.lastRequest;
|
||||
// Consume even a premature request: it must never become effective later.
|
||||
if (newRequest) this.lastRequest = request.sequence;
|
||||
// A gap cannot count towards continuous observed neutral.
|
||||
if (gap > this.policy.maxAgeMs) {
|
||||
const first = gap === Infinity;
|
||||
if (!first) return this.hold('observation-gap');
|
||||
this.neutralSince = null;
|
||||
}
|
||||
if (input.link?.state !== 'live' || !fresh(input.link.at)) return this.hold('receiver-unverified');
|
||||
const axes = {leftY: 0, rightY: 0, leftX: 0, rightX: 0};
|
||||
let neutral = true;
|
||||
let observedThrough = input.link.at;
|
||||
const candidates: Partial<Record<keyof Axes, Sample>> = {};
|
||||
for (const key of this.policy.monitoredAxes) {
|
||||
const sample = input.axes[key], prev = this.samples[key];
|
||||
if (!sample || !bounded(sample.value, -1, 1) || !fresh(sample.at)
|
||||
|| !Number.isSafeInteger(sample.sequence) || sample.sequence < 0
|
||||
|| (prev && (sample.sequence < prev.sequence || sample.at < prev.at
|
||||
|| (sample.sequence === prev.sequence && (sample.value !== prev.value || sample.at !== prev.at)))))
|
||||
return this.hold('axis-invalid');
|
||||
candidates[key] = {...sample}; axes[key] = sample.value;
|
||||
observedThrough = Math.min(observedThrough, sample.at);
|
||||
neutral &&= Math.abs(sample.value) <= this.profile.deadband;
|
||||
}
|
||||
this.samples = candidates;
|
||||
let stopped = true;
|
||||
for (const id of this.motors) {
|
||||
const drive = input.drives[id];
|
||||
if (!drive || drive.healthy !== true || !fresh(drive.at)) return this.hold('drive-unverified');
|
||||
observedThrough = Math.min(observedThrough, drive.at);
|
||||
stopped &&= drive.stopped === true;
|
||||
}
|
||||
// RC intent is evaluated before any Core command or reacquisition request.
|
||||
if (this.state === 'core' && !neutral) return this.hold('rc-takeover');
|
||||
if (neutral && stopped) this.neutralSince ??= now;
|
||||
else this.neutralSince = null;
|
||||
const stableNeutral = this.neutralSince !== null && observedThrough - this.neutralSince >= this.policy.neutralMs;
|
||||
if (this.state === 'hold') {
|
||||
if (!stableNeutral) return this.result(stopped ? 'await-neutral' : 'await-stop');
|
||||
this.state = 'rc-ready'; this.owner = 'rc';
|
||||
// A request queued before reaching neutral cannot acquire Core authority.
|
||||
return this.result('rc-ready');
|
||||
}
|
||||
if (newRequest && request && fresh(request.at) && this.state !== 'core' && stableNeutral) {
|
||||
if (!['remote', 'autonomy'].includes(request.owner)) return this.hold('source-invalid');
|
||||
this.epoch++; this.token = `${this.bootId}:${this.epoch}:${this.profile.revision}`;
|
||||
this.owner = request.owner; this.state = 'core'; this.lastCommand = -1;
|
||||
return this.result('core-granted');
|
||||
}
|
||||
if (this.state === 'core') {
|
||||
const c = input.command;
|
||||
if (!c || c.token !== this.token || !Number.isSafeInteger(c.sequence) || c.sequence <= this.lastCommand
|
||||
|| !fresh(c.at) || !Number.isFinite(c.expires) || c.expires <= now
|
||||
|| c.expires - c.at > this.policy.maxCommandMs || c.expires < c.at
|
||||
|| !bounded(c.demand.left, -1, 1) || !bounded(c.demand.right, -1, 1)) return this.hold('core-command-invalid');
|
||||
this.lastCommand = c.sequence;
|
||||
return this.result('core-command', {left: c.demand.left * this.profile.outputScale, right: c.demand.right * this.profile.outputScale});
|
||||
}
|
||||
if (this.state === 'rc-ready' && !neutral) this.state = 'rc-manual';
|
||||
return this.result(neutral ? 'rc-neutral' : 'rc-command', mix(this.profile, axes));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/** Executable profile proposal. Pure calculations; no hardware or transport. */
|
||||
export interface ControlProfile {
|
||||
schema: 'missioncore.rover-control/v1';
|
||||
revision: number;
|
||||
mode: 'tank' | 'arcade';
|
||||
stick: 'left' | 'right';
|
||||
deadband: number;
|
||||
response: 'linear' | 'squared';
|
||||
outputScale: number;
|
||||
}
|
||||
export interface Axes { leftY: number; rightY: number; leftX: number; rightX: number }
|
||||
export interface Sides { left: number; right: number }
|
||||
export interface MotorBinding { uuid: string; side: 'left' | 'right'; forwardSign: 1 | -1 }
|
||||
|
||||
export const defaultProfile: Readonly<ControlProfile> = Object.freeze({
|
||||
schema: 'missioncore.rover-control/v1', revision: 0, mode: 'tank', stick: 'right',
|
||||
deadband: 0.15, response: 'linear', outputScale: 1,
|
||||
});
|
||||
export function bounded(value: unknown, min: number, max: number): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max;
|
||||
}
|
||||
export function parseProfile(value: unknown): ControlProfile {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw Error('Invalid profile');
|
||||
const p = value as ControlProfile;
|
||||
if (Object.keys(p).sort().join() !== Object.keys(defaultProfile).sort().join()
|
||||
|| p.schema !== defaultProfile.schema || !Number.isSafeInteger(p.revision) || p.revision < 0
|
||||
|| !['tank', 'arcade'].includes(p.mode) || !['left', 'right'].includes(p.stick)
|
||||
|| !['linear', 'squared'].includes(p.response) || !bounded(p.deadband, 0, 0.5)
|
||||
|| !bounded(p.outputScale, 0, 1)) throw Error('Invalid profile');
|
||||
return {...p};
|
||||
}
|
||||
export function axisKeys(profile: ControlProfile): (keyof Axes)[] {
|
||||
return profile.mode === 'tank' ? ['leftY', 'rightY']
|
||||
: profile.stick === 'right' ? ['rightY', 'rightX'] : ['leftY', 'leftX'];
|
||||
}
|
||||
export function shapeAxis(value: number, profile: ControlProfile): number {
|
||||
if (!bounded(value, -1, 1)) throw Error('Invalid axis');
|
||||
const magnitude = Math.max(0, (Math.abs(value) - profile.deadband) / (1 - profile.deadband));
|
||||
return Math.sign(value) * (profile.response === 'squared' ? magnitude * magnitude : magnitude);
|
||||
}
|
||||
/** +Y = forward, +X = turn right, including while reversing (yaw convention).
|
||||
* Arcade diamond desaturation follows the documented WPILib ArcadeDriveIK
|
||||
* convention, with clockwise steering sign adapted here. Output is normalized
|
||||
* demand, NOT amps, watts, ERPM, physical velocity, or a guaranteed turn radius.
|
||||
*/
|
||||
export function mix(profile: ControlProfile, axes: Axes): Sides {
|
||||
parseProfile(profile);
|
||||
for (const key of axisKeys(profile)) if (!bounded(axes[key], -1, 1)) throw Error('Missing or invalid axis');
|
||||
let left: number, right: number;
|
||||
if (profile.mode === 'tank') {
|
||||
left = shapeAxis(axes.leftY, profile); right = shapeAxis(axes.rightY, profile);
|
||||
} else {
|
||||
const throttle = shapeAxis(profile.stick === 'right' ? axes.rightY : axes.leftY, profile);
|
||||
const turn = shapeAxis(profile.stick === 'right' ? axes.rightX : axes.leftX, profile);
|
||||
const peak = Math.max(Math.abs(throttle), Math.abs(turn));
|
||||
const scale = peak === 0 ? 0 : peak / (Math.abs(throttle) + Math.abs(turn));
|
||||
left = (throttle + turn) * scale; right = (throttle - turn) * scale;
|
||||
}
|
||||
return {left: left * profile.outputScale || 0, right: right * profile.outputScale || 0};
|
||||
}
|
||||
/** All members of both sides are required, irrespective of 1x1/2x2/6x6. */
|
||||
export function motorDemands(sides: Sides, bindings: readonly MotorBinding[]): Record<string, number> {
|
||||
if (!bounded(sides.left, -1, 1) || !bounded(sides.right, -1, 1)
|
||||
|| !bindings.some(b => b.side === 'left') || !bindings.some(b => b.side === 'right')) throw Error('Incomplete drive');
|
||||
const values: Record<string, number> = Object.create(null);
|
||||
for (const b of bindings) {
|
||||
if (!/^[0-9a-f]{24}$/.test(b.uuid) || b.uuid in values || !['left', 'right'].includes(b.side)
|
||||
|| ![1, -1].includes(b.forwardSign)) throw Error('Invalid motor binding');
|
||||
values[b.uuid] = sides[b.side] * b.forwardSign || 0;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
Reference in New Issue
Block a user