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.
103 lines
3.8 KiB
JavaScript
103 lines
3.8 KiB
JavaScript
// Local view ordering and intent binding. Contexts are not credentials.
|
|
function sameContext(left, right) {
|
|
return ['instance', 'connection', 'configuration', 'draft'].every(key => left?.[key] === right?.[key]);
|
|
}
|
|
|
|
class ViewSession {
|
|
constructor({read, send, render, error, permissions, edited = () => {}}) {
|
|
Object.assign(this, {read, send, render, error, permissions, edited});
|
|
this.view = null;
|
|
this.queue = [];
|
|
this.ticket = 0;
|
|
this.acceptedTicket = 0;
|
|
this.retired = new Set();
|
|
this.message = '';
|
|
}
|
|
|
|
get busy() { return this.queue.length !== 0; }
|
|
get onlyEdits() { return this.queue.every(entry => entry.path === '/api/config/edit'); }
|
|
editing(id) {
|
|
return this.queue.some(entry => entry.body.field_id === id
|
|
&& entry.context.instance === this.view?.state.context.instance
|
|
&& entry.context.connection === this.view?.state.context.connection
|
|
&& entry.context.configuration === this.view?.state.context.configuration);
|
|
}
|
|
|
|
accept(view, ticket) {
|
|
const current = this.view?.state;
|
|
const next = view.state;
|
|
if (current) {
|
|
if (current.context.instance === next.context.instance) {
|
|
if (BigInt(next.config_revision) < BigInt(current.config_revision)) return false;
|
|
} else {
|
|
if (ticket < this.acceptedTicket || this.retired.has(next.context.instance)) return false;
|
|
this.retired.add(current.context.instance);
|
|
}
|
|
}
|
|
this.acceptedTicket = Math.max(ticket, this.acceptedTicket);
|
|
this.view = view;
|
|
this.render(view);
|
|
if (this.message) this.error(this.message);
|
|
return true;
|
|
}
|
|
|
|
async refresh() {
|
|
const ticket = ++this.ticket;
|
|
const view = await this.read();
|
|
return this.accept(view, ticket);
|
|
}
|
|
|
|
mutate(path, body = {}, context = this.view?.state.context) {
|
|
if (!this.view) return Promise.resolve(null);
|
|
return new Promise(resolve => {
|
|
this.queue.push({path, body, context, resolve});
|
|
this.permissions();
|
|
if (this.queue.length === 1) this.drain();
|
|
});
|
|
}
|
|
|
|
async drain() {
|
|
while (this.queue.length) {
|
|
const entry = this.queue[0];
|
|
const ticket = ++this.ticket;
|
|
let result = null;
|
|
try {
|
|
if (!sameContext(entry.context, this.view.state.context)) {
|
|
const error = Error('View changed; queued action discarded. Review the current configuration.');
|
|
error.staleIntent = true;
|
|
throw error;
|
|
}
|
|
const view = await this.send(entry.path, {...entry.body, context: entry.context});
|
|
if (this.accept(view, ticket)) {
|
|
this.message = '';
|
|
result = view;
|
|
// Only our own accepted edits can advance already queued edits.
|
|
// A poll, another editor, a reset, or a new connection cannot rebase intent.
|
|
if (entry.path === '/api/config/edit'
|
|
&& view.state.context.instance === entry.context.instance
|
|
&& view.state.context.connection === entry.context.connection
|
|
&& view.state.context.configuration === entry.context.configuration) {
|
|
for (const queued of this.queue.slice(1)) {
|
|
if (sameContext(queued.context, entry.context)) queued.context = view.state.context;
|
|
}
|
|
this.edited(entry.context, view.state.context);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const relevant = error.view
|
|
? this.accept(error.view, ticket)
|
|
: error.staleIntent || sameContext(entry.context, this.view.state.context);
|
|
if (relevant) this.message = error.message;
|
|
} finally {
|
|
this.queue.shift();
|
|
this.render(this.view);
|
|
if (this.message) this.error(this.message);
|
|
this.permissions();
|
|
entry.resolve(result);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (typeof module !== 'undefined') module.exports = {ViewSession, sameContext};
|