feat(rover): add drive profiles and leased remote command channel

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 16:37:56 +03:00
parent 53818230f9
commit 63cbb08ea0
18 changed files with 1845 additions and 0 deletions
+107
View File
@@ -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.
+134
View File
@@ -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));
}
}
+72
View File
@@ -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;
}