feat(firmware): validate configuration and migrate sensor-driven flight APIs

Validate configuration through its owning schemas and keep board
declarations separate from shared F722 resource ownership. Bound USB
transitions, isolate transport failures and retain restart-only activation.

Migrate navigation and flight consumers with the sensor and schema
contracts. Require explicit installation data and synchronous output
authority; board motor outputs remain inhibited.

Validation: assembled-prefix host tests, strict Clippy, platform/USB tests
and both ARM release builds. Final-source coverage and scoped Miri/Kani
evidence are retained separately from hardware qualification.
This commit is contained in:
Onur Özkan committed 2026-11-07 18:12:11 +03:00
1 parent fb1b8af2d1
commit f9a61a086f
211 files changed
+45657 -12740

No files matched your search

+84 -44
View File
@@ -1,14 +1,27 @@
const q = selector => document.querySelector(selector);
const pendingActions = new Set();
let latestState = null;
let latestConfig = null;
let configRevision = -1;
let editorStructure = '';
const expandedPackets = new Set();
const packetRows = new Map();
let logSession = null;
let latestLogId = -1;
const views = new ViewSession({
read: () => api('/api/view'),
send: post,
render: view => { renderState(view.state); renderConfig(view.config); },
error: showError,
permissions: renderPermissions,
edited: (before, after) => {
for (const control of q('#draft-fields').elements) {
if (control.dataset.dirty === 'true'
&& sameContext(control.intentContext ?? control.viewContext, before)) {
control.intentContext = after;
}
}
},
});
function setText(element, value) {
const text = value ?? '';
@@ -30,7 +43,9 @@ async function api(path, options = {}) {
const value = await responseValue(response);
if (!response.ok) {
const detail = typeof value === 'object' && value !== null ? value.error : value;
throw Error(detail || `Request failed with HTTP ${response.status}`);
const error = Error(detail || `Request failed with HTTP ${response.status}`);
error.view = value?.view;
throw error;
}
return value;
}
@@ -47,41 +62,30 @@ function showError(message) {
setText(q('#connect-error'), message);
}
async function runAction(id, action) {
pendingActions.add(id);
renderPermissions();
try {
const value = await action();
showError('');
return value;
} catch (error) {
showError(error.message);
return null;
} finally {
pendingActions.delete(id);
renderPermissions();
}
}
q('#connect-form').addEventListener('submit', event => {
event.preventDefault();
runAction('connect', () => post('/api/connect', {path: q('#path').value}));
views.mutate('/api/connect', {path: q('#path').value}, q('#connect-serial').viewContext);
});
q('#disconnect').addEventListener('click', () => {
runAction('disconnect', () => post('/api/disconnect'));
views.mutate('/api/disconnect', {}, q('#disconnect').viewContext);
});
q('#draft-fields').addEventListener('submit', event => event.preventDefault());
q('#save').addEventListener('click', () => {
runAction('save', () => post('/api/config/save'));
views.mutate('/api/config/save', {}, q('#save').viewContext);
});
q('#reset-saved').addEventListener('click', () => {
views.mutate('/api/config/reset', {}, q('#reset-saved').viewContext);
});
function fieldStructure(field) {
return [
field.id, field.key, field.label, field.group, field.unit, field.kind,
field.minimum, field.maximum, field.factory, field.help, field.options,
field.finite, field.nonzero,
];
}
@@ -104,24 +108,37 @@ function fieldControl(field) {
if (field.kind === 'bool') {
control.type = 'checkbox';
} else {
control.type = 'number';
// Text retains signed zero and IEEE bit strings for nonfinite values.
control.type = field.kind === 'decimal' ? 'text' : 'number';
control.step = field.kind === 'decimal' ? 'any' : '1';
control.inputMode = field.kind === 'decimal' ? 'decimal' : 'numeric';
if (field.minimum !== null) control.min = String(field.minimum);
if (field.maximum !== null) control.max = String(field.maximum);
}
}
control.addEventListener('input', () => {
if (control.dataset.dirty !== 'true') control.intentContext = control.viewContext;
control.dataset.dirty = 'true';
renderPermissions();
});
control.addEventListener('change', async () => {
let value = control.value;
if (field.kind === 'bool') {
value = field.options.length === 0 ? control.checked : control.value === 'true';
}
const action = `edit:${field.id}`;
const config = await runAction(action, () => post('/api/config/edit', {
control.dataset.dirty = 'true';
const displayed = control.type === 'checkbox' ? control.checked : control.value;
await views.mutate('/api/config/edit', {
field_id: field.id,
value,
}));
if (config !== null) renderConfig(config);
}, control.intentContext ?? control.viewContext);
if (displayed === (control.type === 'checkbox' ? control.checked : control.value)
&& !views.editing(field.id)) {
delete control.dataset.dirty;
delete control.intentContext;
}
if (latestConfig) syncEditorValues(latestConfig.fields);
renderPermissions();
});
return control;
}
@@ -148,11 +165,19 @@ function buildEditor(fields) {
key.textContent = `${field.key} [${field.id}]`;
const control = fieldControl(field);
label.append(title, key, control);
const values = document.createElement('small');
values.id = `values-${field.id}`;
const error = document.createElement('small');
error.id = `error-${field.id}`;
error.className = 'error';
error.setAttribute('role', 'alert');
label.append(values, error);
const bounds = field.minimum !== null || field.maximum !== null
? `Inclusive bounds: ${field.minimum ?? 'unbounded'} to ${field.maximum ?? 'unbounded'}.`
: '';
const helpText = [field.help, `Factory: ${field.factory}.`, bounds].filter(Boolean).join(' ');
const rules = [field.finite ? 'Must be finite.' : '', field.nonzero ? 'Must be nonzero.' : ''].filter(Boolean).join(' ');
const helpText = [field.help, `Factory: ${field.factory}.`, bounds, rules].filter(Boolean).join(' ');
if (helpText) {
const help = document.createElement('small');
help.id = `help-${field.id}`;
@@ -171,9 +196,18 @@ function syncEditorValues(fields) {
for (const field of fields) {
const control = q(`[data-field-id="${field.id}"]`);
if (!control) continue;
const display = value => {
if (value === null) return 'unknown';
return field.options.find(option => String(option.value) === String(value))?.label ?? String(value);
};
setText(q(`#values-${field.id}`), `Boot-active: ${display(field.active)} · Saved: ${display(field.saved)} · Draft: ${field.draft === null ? 'unchanged' : display(field.draft)}`);
setText(q(`#error-${field.id}`), field.error ?? '');
control.setAttribute('aria-invalid', String(field.error !== null));
if (views.editing(field.id) || control.dataset.dirty === 'true') continue;
control.viewContext = latestState.context;
if (field.kind === 'bool' && field.options.length === 0) {
if (document.activeElement !== control) control.checked = field.value;
} else if (document.activeElement !== control && control.value !== String(field.value)) {
control.checked = field.value;
} else if (control.value !== String(field.value)) {
control.value = String(field.value);
}
}
@@ -191,25 +225,32 @@ function renderConfig(config) {
q('#restart-guidance').hidden = config.state !== 'restart required';
renderValidation(config.validation);
const structure = JSON.stringify(config.fields.map(fieldStructure));
const structure = JSON.stringify([
latestState.context.instance, latestState.context.connection, latestState.context.configuration,
config.fields.map(fieldStructure),
]);
if (structure !== editorStructure) {
editorStructure = structure;
buildEditor(config.fields);
}
syncEditorValues(config.fields);
q('#save').viewContext = latestState.context;
q('#reset-saved').viewContext = latestState.context;
renderPermissions();
}
function renderPermissions() {
if (latestState === null) return;
const busy = pendingActions.size !== 0;
const busy = views.busy;
const dirty = [...q('#draft-fields').elements].some(control => control.dataset.dirty === 'true');
q('#path').disabled = latestState.can_disconnect || busy;
q('#connect-serial').disabled = latestState.can_disconnect || busy;
q('#disconnect').hidden = !latestState.can_disconnect;
q('#disconnect').disabled = busy;
q('#save').disabled = !latestState.can_save || busy;
q('#save').disabled = !latestState.can_save || busy || dirty;
q('#reset-saved').disabled = !latestState.can_reset || busy;
for (const control of q('#draft-fields').elements) {
control.disabled = !latestState.can_edit || busy;
control.disabled = !latestState.can_edit || (busy && !views.onlyEdits);
}
}
@@ -406,24 +447,23 @@ function renderState(state) {
q('#configuration').hidden = !state.has_config;
q('#packet-monitor').hidden = !state.can_disconnect;
q('#log-monitor').hidden = !state.can_disconnect;
q('#connect-serial').viewContext = state.context;
q('#disconnect').viewContext = state.context;
renderPermissions();
}
async function refresh() {
try {
const [state, packets, logs] = await Promise.all([
api('/api/state'),
const context = views.view?.state.context;
const [, packets, logs] = await Promise.all([
views.refresh(),
api('/api/packets'),
api('/api/logs'),
]);
renderState(state);
renderPackets(packets);
renderLogs(logs);
if (state.config_revision !== configRevision) {
const config = await api('/api/config');
renderConfig(config);
configRevision = state.config_revision;
q('#configuration').hidden = latestConfig.schema_id === null;
if (context?.connection === views.view?.state.context.connection
&& context?.instance === views.view?.state.context.instance) {
renderPackets(packets);
renderLogs(logs);
}
} catch (error) {
showError(error.message);