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

@@ -0,0 +1,160 @@
const {test} = require('node:test');
const assert = require('node:assert/strict');
const {ViewSession, sameContext} = require('./view.js');
function view(revision, connection = '1', draft = '0', value = 20, instance = 'host') {
return {
state: {config_revision: String(revision), context: {instance, connection, configuration: '1', draft}},
config: {fields: [{id: 401, value}]},
};
}
function deferred() {
let resolve, reject;
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
return {promise, resolve, reject};
}
function harness(send = async () => { throw Error('unexpected mutation'); }, edited = () => {}) {
const reads = [], rendered = [], errors = [];
const session = new ViewSession({
read: () => { const next = deferred(); reads.push(next); return next.promise; },
send, edited, render: snapshot => rendered.push(snapshot), error: message => errors.push(message), permissions() {},
});
return {session, reads, rendered, errors};
}
test('late A view cannot replace displayed B or supply a reset context', async () => {
const sent = [];
const h = harness(async (path, body) => { sent.push({path, body}); return view(4, '2'); });
h.session.accept(view(1), 0);
const old = h.session.refresh();
const fresh = h.session.refresh();
h.reads[1].resolve(view(3, '2'));
await fresh;
h.reads[0].resolve(view(2));
assert.equal(await old, false);
assert.equal(h.rendered.at(-1).state.context.connection, '2');
await h.session.mutate('/api/config/reset');
assert.equal(sent[0].body.context.connection, '2');
});
test('an A action already queued is discarded rather than rebound to B', async () => {
const first = deferred(), sent = [];
const h = harness((path, body) => { sent.push({path, body}); return first.promise; });
h.session.accept(view(1), 0);
const edit = h.session.mutate('/api/config/edit', {field_id: 401, value: 30});
const reset = h.session.mutate('/api/config/reset');
const fresh = h.session.refresh();
h.reads[0].resolve(view(5, '2'));
await fresh;
first.resolve(view(2, '1', '1', 30));
await Promise.all([edit, reset]);
assert.equal(sent.length, 1);
assert.equal(h.rendered.at(-1).state.context.connection, '2');
assert.match(h.errors.at(-1), /queued action discarded/);
});
test('an old input change event retains the context of its displayed value', async () => {
const h = harness();
const a = view(1);
h.session.accept(a, 0);
h.session.accept(view(3, '2'), 1);
assert.equal(await h.session.mutate('/api/config/edit', {field_id: 401, value: 30}, a.state.context), null);
assert.match(h.errors.at(-1), /queued action discarded/);
assert.equal(h.rendered.at(-1).state.context.connection, '2');
});
test('rapid same-input edits serialize and only our accepted draft advances queued context', async () => {
const replies = [deferred(), deferred(), deferred()], sent = [];
let displayedContext = view('9007199254740993').state.context;
const h = harness((path, body) => { sent.push({path, body}); return replies[sent.length - 1].promise; },
(before, after) => { if (sameContext(displayedContext, before)) displayedContext = after; });
h.session.accept(view('9007199254740993'), 0);
const first = h.session.mutate('/api/config/edit', {field_id: 401, value: 30}, displayedContext);
const second = h.session.mutate('/api/config/edit', {field_id: 401, value: 40}, displayedContext);
assert.equal(sent.length, 1);
assert.equal(h.session.editing(401), true);
replies[0].resolve(view('9007199254740994', '1', '1', 30));
await first;
assert.equal(sent.length, 2);
assert.equal(sent[1].body.context.draft, '1');
assert.equal(sent[1].body.value, 40);
assert.equal(h.session.editing(401), true);
const third = h.session.mutate('/api/config/edit', {field_id: 401, value: 50}, displayedContext);
replies[1].resolve(view('9007199254740995', '1', '2', 40));
await second;
assert.equal(sent[2].body.context.draft, '2');
replies[2].resolve(view('9007199254740996', '1', '3', 50));
await third;
assert.equal(h.session.editing(401), false);
assert.equal(h.rendered.at(-1).config.fields[0].value, 50);
assert.equal(h.session.accept(view('9007199254740994', '1', '1', 30), 100), false);
});
test('another editors draft cannot silently rebase queued local edits', async () => {
const reply = deferred(), sent = [];
const h = harness((path, body) => { sent.push(body); return reply.promise; });
h.session.accept(view(1), 0);
const first = h.session.mutate('/api/config/edit', {field_id: 401, value: 30});
const next = h.session.mutate('/api/config/edit', {field_id: 401, value: 40});
const poll = h.session.refresh();
h.reads[0].resolve(view(4, '1', '3', 50));
await poll;
reply.resolve(view(2, '1', '1', 30));
await Promise.all([first, next]);
assert.equal(sent.length, 1);
assert.equal(h.rendered.at(-1).config.fields[0].value, 50);
});
test('failed refresh can retry and prior server-instance responses stay retired', async () => {
const h = harness();
const first = h.session.refresh();
h.reads[0].reject(Error('HTTP failure'));
await assert.rejects(first, /HTTP failure/);
const retry = h.session.refresh();
h.reads[1].resolve(view(100));
assert.equal(await retry, true);
const prior = h.session.refresh();
const current = h.session.refresh();
h.reads[3].resolve(view(1, '1', '0', 60, 'new-host'));
await current;
h.reads[2].resolve(view(101));
assert.equal(await prior, false);
assert.equal(h.rendered.at(-1).config.fields[0].value, 60);
});
test('decimal controls retain signed zero and nonfinite bit strings when submitting edits', async () => {
const vm = require('node:vm');
const fs = require('node:fs');
const sent = [];
function element() {
return {
dataset: {}, value: '', events: {},
addEventListener(name, callback) { this.events[name] = callback; },
};
}
const pending = () => new Promise(() => {});
const context = vm.createContext({
document: {querySelector: element, createElement: element},
fetch: pending,
ViewSession: class {
refresh() { return pending(); }
editing() { return false; }
async mutate(path, body) { sent.push({path, body}); }
},
});
vm.runInContext(fs.readFileSync(`${__dirname}/app.js`, 'utf8'), context);
const field = {id: 401, kind: 'decimal', options: [], minimum: null, maximum: null};
context.field = field;
const control = vm.runInContext('fieldControl(field)', context);
assert.equal(control.type, 'text');
for (const value of ['-0', 'bits:0xfff0000000000042', 'bits:0x7f800000']) {
control.value = value;
await control.events.change();
const serialized = JSON.parse(JSON.stringify(sent.at(-1)));
assert.equal(serialized.body.value, value);
assert.equal(serialized.path, '/api/config/edit');
}
const original = vm.runInContext('JSON.stringify(fieldStructure(field))', context);
field.finite = true;
assert.notEqual(vm.runInContext('JSON.stringify(fieldStructure(field))', context), original);
});