Some checks failed
social-bot integration tests / Lint (flake8 + pep257) (pull_request) Failing after 2s
social-bot integration tests / Core integration tests (mock sensors, no GPU) (pull_request) Has been skipped
social-bot integration tests / Latency profiling (GPU, Orin) (pull_request) Has been cancelled
- useSettings.js: PID parameter catalogue, step-response simulation, ROS2 parameter apply via rcl_interfaces/srv/SetParameters, sensor param management, firmware info extraction from /diagnostics, diagnostics bundle export, JSON backup/restore, localStorage persist - SettingsPanel.jsx: 6-view panel (PID, Sensors, Network, Firmware, Diagnostics, Backup); StepResponseCanvas with stable/oscillating/ unstable colour-coding; GainSlider with range+number input; weight- class tabs (empty/light/heavy); parameter validation badges - App.jsx: CONFIG tab group (purple), settings tab render, FLEET_TABS set to gate ConnectionBar and footer for fleet/missions/settings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
274 lines
13 KiB
JavaScript
274 lines
13 KiB
JavaScript
/**
|
||
* useSettings.js — ROS2 parameter I/O + settings persistence + validation.
|
||
*
|
||
* Wraps rcl_interfaces/srv/GetParameters and SetParameters calls via
|
||
* the rosbridge callService helper passed from useRosbridge.
|
||
*
|
||
* Known nodes and their parameters:
|
||
* adaptive_pid_node — PID gains (empty/light/heavy), clamp bounds, overrides
|
||
* rtsp_server_node — video fps, bitrate, resolution, RTSP port
|
||
* uwb_driver_node — baudrate, range limits, Kalman filter noise
|
||
*
|
||
* Validation rules:
|
||
* kp_min 5–40, ki_min 0–5, kd_min 0–10
|
||
* override gains must be within [min, max]
|
||
* control_rate: 50–200 Hz
|
||
* balance_setpoint_rad: ±0.1 rad
|
||
*
|
||
* PID step-response simulator (pure JS, no ROS):
|
||
* Inverted pendulum: θ''(t) = g/L · θ(t) − 1/m·L² · u(t)
|
||
* Parameters: L≈0.40 m, m≈3 kg, g=9.81
|
||
* Simulate 120 steps at dt=0.02 s (2.4 s total) with 5° step input.
|
||
*/
|
||
|
||
import { useState, useCallback } from 'react';
|
||
|
||
const STORAGE_KEY = 'saltybot_settings_v1';
|
||
|
||
// ── Parameter catalogue ────────────────────────────────────────────────────────
|
||
|
||
export const PID_NODE = 'adaptive_pid_node';
|
||
|
||
export const PID_PARAMS = [
|
||
{ key: 'kp_empty', default: 15.0, min: 0, max: 60, step: 0.5, label: 'Kp empty' },
|
||
{ key: 'ki_empty', default: 0.5, min: 0, max: 10, step: 0.05, label: 'Ki empty' },
|
||
{ key: 'kd_empty', default: 1.5, min: 0, max: 20, step: 0.1, label: 'Kd empty' },
|
||
{ key: 'kp_light', default: 18.0, min: 0, max: 60, step: 0.5, label: 'Kp light' },
|
||
{ key: 'ki_light', default: 0.6, min: 0, max: 10, step: 0.05, label: 'Ki light' },
|
||
{ key: 'kd_light', default: 2.0, min: 0, max: 20, step: 0.1, label: 'Kd light' },
|
||
{ key: 'kp_heavy', default: 22.0, min: 0, max: 60, step: 0.5, label: 'Kp heavy' },
|
||
{ key: 'ki_heavy', default: 0.8, min: 0, max: 10, step: 0.05, label: 'Ki heavy' },
|
||
{ key: 'kd_heavy', default: 2.5, min: 0, max: 20, step: 0.1, label: 'Kd heavy' },
|
||
{ key: 'kp_min', default: 5.0, min: 0, max: 60, step: 0.5, label: 'Kp min (clamp)' },
|
||
{ key: 'kp_max', default: 40.0, min: 0, max: 80, step: 0.5, label: 'Kp max (clamp)' },
|
||
{ key: 'ki_min', default: 0.0, min: 0, max: 10, step: 0.05, label: 'Ki min (clamp)' },
|
||
{ key: 'ki_max', default: 5.0, min: 0, max: 20, step: 0.05, label: 'Ki max (clamp)' },
|
||
{ key: 'kd_min', default: 0.0, min: 0, max: 20, step: 0.1, label: 'Kd min (clamp)' },
|
||
{ key: 'kd_max', default: 10.0, min: 0, max: 40, step: 0.1, label: 'Kd max (clamp)' },
|
||
{ key: 'override_enabled', default: false, type: 'bool', label: 'Override gains' },
|
||
{ key: 'override_kp', default: 15.0, min: 0, max: 60, step: 0.5, label: 'Override Kp' },
|
||
{ key: 'override_ki', default: 0.5, min: 0, max: 10, step: 0.05, label: 'Override Ki' },
|
||
{ key: 'override_kd', default: 1.5, min: 0, max: 20, step: 0.1, label: 'Override Kd' },
|
||
{ key: 'control_rate', default: 100.0, min: 50, max: 200, step: 5, label: 'Control rate (Hz)' },
|
||
{ key: 'balance_setpoint_rad', default: 0.0, min: -0.1, max: 0.1, step: 0.001, label: 'Balance setpoint (rad)' },
|
||
];
|
||
|
||
export const SENSOR_PARAMS = [
|
||
{ node: 'rtsp_server_node', key: 'fps', default: 15, min: 5, max: 60, step: 1, label: 'Camera FPS' },
|
||
{ node: 'rtsp_server_node', key: 'bitrate_kbps',default: 4000, min: 500,max: 20000,step: 500, label: 'Camera bitrate (kbps)' },
|
||
{ node: 'rtsp_server_node', key: 'rtsp_port', default: 8554, min: 1024,max: 65535,step: 1, label: 'RTSP port' },
|
||
{ node: 'rtsp_server_node', key: 'use_nvenc', default: true, type: 'bool', label: 'NVENC hardware encode' },
|
||
{ node: 'uwb_driver_node', key: 'max_range_m', default: 8.0, min: 1, max: 20, step: 0.5, label: 'UWB max range (m)' },
|
||
{ node: 'uwb_driver_node', key: 'kf_process_noise', default: 0.1, min: 0.001, max: 1.0, step: 0.001, label: 'UWB Kalman noise' },
|
||
];
|
||
|
||
// ── Validation ─────────────────────────────────────────────────────────────────
|
||
|
||
export function validatePID(gains) {
|
||
const warnings = [];
|
||
const { kp_empty, ki_empty, kd_empty, kp_max, ki_max, kd_max,
|
||
override_enabled, override_kp, override_ki, override_kd,
|
||
control_rate, balance_setpoint_rad } = gains;
|
||
|
||
if (kp_empty > 35)
|
||
warnings.push({ level: 'warn', msg: `Kp empty (${kp_empty}) is high — risk of oscillation.` });
|
||
if (ki_empty > 3)
|
||
warnings.push({ level: 'warn', msg: `Ki empty (${ki_empty}) is high — risk of integral windup.` });
|
||
if (kp_empty > kp_max)
|
||
warnings.push({ level: 'error', msg: `Kp empty (${kp_empty}) exceeds kp_max (${kp_max}).` });
|
||
if (ki_empty > ki_max)
|
||
warnings.push({ level: 'error', msg: `Ki empty (${ki_empty}) exceeds ki_max (${ki_max}).` });
|
||
if (kp_empty > 0 && kd_empty / kp_empty < 0.05)
|
||
warnings.push({ level: 'warn', msg: `Low Kd/Kp ratio — under-damped response likely.` });
|
||
if (override_enabled) {
|
||
if (override_kp > kp_max)
|
||
warnings.push({ level: 'error', msg: `Override Kp (${override_kp}) exceeds kp_max (${kp_max}).` });
|
||
if (override_ki > ki_max)
|
||
warnings.push({ level: 'error', msg: `Override Ki (${override_ki}) exceeds ki_max (${ki_max}).` });
|
||
if (override_kd > kd_max)
|
||
warnings.push({ level: 'error', msg: `Override Kd (${override_kd}) exceeds kd_max (${kd_max}).` });
|
||
}
|
||
if (control_rate > 150)
|
||
warnings.push({ level: 'warn', msg: `Control rate ${control_rate} Hz — ensure STM32 UART can keep up.` });
|
||
if (Math.abs(balance_setpoint_rad) > 0.05)
|
||
warnings.push({ level: 'warn', msg: `Setpoint |${balance_setpoint_rad?.toFixed(3)}| rad > 3° — intentional lean?` });
|
||
return warnings;
|
||
}
|
||
|
||
// ── Step-response simulation ───────────────────────────────────────────────────
|
||
|
||
export function simulateStepResponse(kp, ki, kd) {
|
||
const dt = 0.02;
|
||
const N = 120;
|
||
const g = 9.81;
|
||
const L = 0.40;
|
||
const m = 3.0;
|
||
const step = 5 * Math.PI / 180;
|
||
|
||
let theta = step;
|
||
let omega = 0;
|
||
let integral = 0;
|
||
let prevErr = theta;
|
||
const result = [];
|
||
|
||
for (let i = 0; i < N; i++) {
|
||
const t = i * dt;
|
||
const err = -theta;
|
||
integral += err * dt;
|
||
integral = Math.max(-2, Math.min(2, integral));
|
||
const deriv = (err - prevErr) / dt;
|
||
prevErr = err;
|
||
|
||
const u = kp * err + ki * integral + kd * deriv;
|
||
const alpha = (g / L) * theta - u / (m * L * L);
|
||
omega += alpha * dt;
|
||
theta += omega * dt;
|
||
|
||
result.push({ t, theta: theta * 180 / Math.PI, u: Math.min(100, Math.max(-100, u)) });
|
||
|
||
if (Math.abs(theta) > Math.PI / 2) {
|
||
for (let j = i + 1; j < N; j++)
|
||
result.push({ t: j * dt, theta: theta > 0 ? 90 : -90, u: 0 });
|
||
break;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||
|
||
export function useSettings({ callService, subscribe } = {}) {
|
||
const [gains, setGains] = useState(() => {
|
||
try {
|
||
const s = JSON.parse(localStorage.getItem(STORAGE_KEY));
|
||
return s?.gains ?? Object.fromEntries(PID_PARAMS.map(p => [p.key, p.default]));
|
||
} catch { return Object.fromEntries(PID_PARAMS.map(p => [p.key, p.default])); }
|
||
});
|
||
const [sensors, setSensors] = useState(() => {
|
||
try {
|
||
const s = JSON.parse(localStorage.getItem(STORAGE_KEY));
|
||
return s?.sensors ?? Object.fromEntries(SENSOR_PARAMS.map(p => [p.key, p.default]));
|
||
} catch { return Object.fromEntries(SENSOR_PARAMS.map(p => [p.key, p.default])); }
|
||
});
|
||
const [firmwareInfo, setFirmwareInfo] = useState(null);
|
||
const [applying, setApplying] = useState(false);
|
||
const [applyResult, setApplyResult] = useState(null);
|
||
|
||
const persist = useCallback((newGains, newSensors) => {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||
gains: newGains ?? gains,
|
||
sensors: newSensors ?? sensors,
|
||
savedAt: Date.now(),
|
||
}));
|
||
}, [gains, sensors]);
|
||
|
||
const buildSetRequest = useCallback((params) => ({
|
||
parameters: params.map(({ name, value }) => {
|
||
let type = 3; let v = { double_value: value };
|
||
if (typeof value === 'boolean') { type = 1; v = { bool_value: value }; }
|
||
else if (Number.isInteger(value)) { type = 2; v = { integer_value: value }; }
|
||
return { name, value: { type, ...v } };
|
||
}),
|
||
}), []);
|
||
|
||
const applyPIDGains = useCallback(async (overrideGains) => {
|
||
const toApply = overrideGains ?? gains;
|
||
setApplying(true); setApplyResult(null);
|
||
const params = PID_PARAMS.map(p => ({ name: p.key, value: toApply[p.key] ?? p.default }));
|
||
if (!callService) {
|
||
setGains(toApply); persist(toApply, null); setApplying(false);
|
||
setApplyResult({ ok: true, msg: 'Saved locally (not connected)' }); return;
|
||
}
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
callService(`/${PID_NODE}/set_parameters`, 'rcl_interfaces/srv/SetParameters',
|
||
buildSetRequest(params), (res) => {
|
||
res.results?.every(r => r.successful) ? resolve() :
|
||
reject(new Error(res.results?.find(r => !r.successful)?.reason ?? 'failed'));
|
||
});
|
||
setTimeout(() => reject(new Error('timeout')), 5000);
|
||
});
|
||
setGains(toApply); persist(toApply, null);
|
||
setApplyResult({ ok: true, msg: 'Parameters applied to adaptive_pid_node' });
|
||
} catch (e) {
|
||
setApplyResult({ ok: false, msg: String(e.message) });
|
||
}
|
||
setApplying(false);
|
||
}, [gains, callService, buildSetRequest, persist]);
|
||
|
||
const applySensorParams = useCallback(async (overrideParams) => {
|
||
const toApply = overrideParams ?? sensors;
|
||
setApplying(true); setApplyResult(null);
|
||
if (!callService) {
|
||
setSensors(toApply); persist(null, toApply); setApplying(false);
|
||
setApplyResult({ ok: true, msg: 'Saved locally (not connected)' }); return;
|
||
}
|
||
const byNode = {};
|
||
SENSOR_PARAMS.forEach(p => {
|
||
if (!byNode[p.node]) byNode[p.node] = [];
|
||
byNode[p.node].push({ name: p.key, value: toApply[p.key] ?? p.default });
|
||
});
|
||
try {
|
||
for (const [node, params] of Object.entries(byNode)) {
|
||
await new Promise((resolve, reject) => {
|
||
callService(`/${node}/set_parameters`, 'rcl_interfaces/srv/SetParameters',
|
||
buildSetRequest(params), resolve);
|
||
setTimeout(() => reject(new Error(`timeout on ${node}`)), 5000);
|
||
});
|
||
}
|
||
setSensors(toApply); persist(null, toApply);
|
||
setApplyResult({ ok: true, msg: 'Sensor parameters applied' });
|
||
} catch (e) { setApplyResult({ ok: false, msg: String(e.message) }); }
|
||
setApplying(false);
|
||
}, [sensors, callService, buildSetRequest, persist]);
|
||
|
||
const startFirmwareWatch = useCallback(() => {
|
||
if (!subscribe) return () => {};
|
||
return subscribe('/diagnostics', 'diagnostic_msgs/DiagnosticArray', (msg) => {
|
||
const info = {};
|
||
for (const status of msg.status ?? []) {
|
||
for (const kv of status.values ?? []) {
|
||
if (kv.key === 'stm32_fw_version') info.stm32Version = kv.value;
|
||
if (kv.key === 'jetson_sw_version') info.jetsonVersion = kv.value;
|
||
if (kv.key === 'last_ota_date') info.lastOtaDate = kv.value;
|
||
if (kv.key === 'jetson_hostname') info.hostname = kv.value;
|
||
if (kv.key === 'ros_distro') info.rosDistro = kv.value;
|
||
if (kv.key === 'uptime_s') info.uptimeS = parseFloat(kv.value);
|
||
}
|
||
}
|
||
if (Object.keys(info).length > 0) setFirmwareInfo(fi => ({ ...fi, ...info }));
|
||
});
|
||
}, [subscribe]);
|
||
|
||
const exportSettingsJSON = useCallback(() =>
|
||
JSON.stringify({ gains, sensors, exportedAt: new Date().toISOString() }, null, 2),
|
||
[gains, sensors]);
|
||
|
||
const importSettingsJSON = useCallback((json) => {
|
||
const data = JSON.parse(json);
|
||
if (data.gains) { setGains(data.gains); persist(data.gains, null); }
|
||
if (data.sensors) { setSensors(data.sensors); persist(null, data.sensors); }
|
||
}, [persist]);
|
||
|
||
const exportDiagnosticsBundle = useCallback((robotData, connections) => {
|
||
const bundle = {
|
||
exportedAt: new Date().toISOString(),
|
||
settings: { gains, sensors },
|
||
firmwareInfo,
|
||
fleet: Object.entries(connections ?? {}).map(([id, c]) => ({ id, ...c, data: robotData?.[id] })),
|
||
};
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(new Blob([JSON.stringify(bundle, null, 2)], { type: 'application/json' }));
|
||
a.download = `saltybot-diagnostics-${Date.now()}.json`;
|
||
a.click();
|
||
}, [gains, sensors, firmwareInfo]);
|
||
|
||
return {
|
||
gains, setGains, sensors, setSensors, firmwareInfo,
|
||
applying, applyResult,
|
||
applyPIDGains, applySensorParams, startFirmwareWatch,
|
||
exportSettingsJSON, importSettingsJSON, exportDiagnosticsBundle,
|
||
validate: () => validatePID(gains),
|
||
};
|
||
}
|