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.
476 lines
16 KiB
JavaScript
476 lines
16 KiB
JavaScript
const q = selector => document.querySelector(selector);
|
|
|
|
let latestState = null;
|
|
let latestConfig = null;
|
|
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 ?? '';
|
|
if (element.textContent !== String(text)) element.textContent = text;
|
|
}
|
|
|
|
async function responseValue(response) {
|
|
const type = response.headers.get('content-type') || '';
|
|
if (!type.includes('application/json')) return response.text();
|
|
try {
|
|
return await response.json();
|
|
} catch (_) {
|
|
throw Error(`HTTP ${response.status}: invalid JSON response`);
|
|
}
|
|
}
|
|
|
|
async function api(path, options = {}) {
|
|
const response = await fetch(path, options);
|
|
const value = await responseValue(response);
|
|
if (!response.ok) {
|
|
const detail = typeof value === 'object' && value !== null ? value.error : value;
|
|
const error = Error(detail || `Request failed with HTTP ${response.status}`);
|
|
error.view = value?.view;
|
|
throw error;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function post(path, body) {
|
|
return api(path, {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
function showError(message) {
|
|
setText(q('#connect-error'), message);
|
|
}
|
|
|
|
q('#connect-form').addEventListener('submit', event => {
|
|
event.preventDefault();
|
|
views.mutate('/api/connect', {path: q('#path').value}, q('#connect-serial').viewContext);
|
|
});
|
|
|
|
q('#disconnect').addEventListener('click', () => {
|
|
views.mutate('/api/disconnect', {}, q('#disconnect').viewContext);
|
|
});
|
|
|
|
q('#draft-fields').addEventListener('submit', event => event.preventDefault());
|
|
|
|
q('#save').addEventListener('click', () => {
|
|
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,
|
|
];
|
|
}
|
|
|
|
function fieldControl(field) {
|
|
let control;
|
|
if (field.options.length !== 0) {
|
|
control = document.createElement('select');
|
|
for (const choice of field.options) {
|
|
const option = document.createElement('option');
|
|
option.value = String(choice.value);
|
|
option.textContent = choice.label;
|
|
control.append(option);
|
|
}
|
|
} else {
|
|
control = document.createElement('input');
|
|
}
|
|
control.name = String(field.id);
|
|
control.dataset.fieldId = String(field.id);
|
|
if (field.options.length === 0) {
|
|
if (field.kind === 'bool') {
|
|
control.type = 'checkbox';
|
|
} else {
|
|
// 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';
|
|
}
|
|
control.dataset.dirty = 'true';
|
|
const displayed = control.type === 'checkbox' ? control.checked : control.value;
|
|
await views.mutate('/api/config/edit', {
|
|
field_id: field.id,
|
|
value,
|
|
}, 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;
|
|
}
|
|
|
|
function buildEditor(fields) {
|
|
const groups = new Map();
|
|
for (const field of fields) {
|
|
if (!groups.has(field.group)) groups.set(field.group, []);
|
|
groups.get(field.group).push(field);
|
|
}
|
|
|
|
const sections = [];
|
|
for (const [group, groupFields] of groups) {
|
|
const section = document.createElement('fieldset');
|
|
const legend = document.createElement('legend');
|
|
legend.textContent = group;
|
|
section.append(legend);
|
|
for (const field of groupFields) {
|
|
const label = document.createElement('label');
|
|
const title = document.createElement('span');
|
|
title.textContent = `${field.label}${field.unit ? ` (${field.unit})` : ''}`;
|
|
const key = document.createElement('code');
|
|
key.className = 'field-key';
|
|
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 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}`;
|
|
help.textContent = helpText;
|
|
control.setAttribute('aria-describedby', help.id);
|
|
label.append(help);
|
|
}
|
|
section.append(label);
|
|
}
|
|
sections.push(section);
|
|
}
|
|
q('#draft-fields').replaceChildren(...sections);
|
|
}
|
|
|
|
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) {
|
|
control.checked = field.value;
|
|
} else if (control.value !== String(field.value)) {
|
|
control.value = String(field.value);
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderValidation(messages) {
|
|
q('#validation').replaceChildren(...messages.map(message =>
|
|
Object.assign(document.createElement('li'), {textContent: message})));
|
|
}
|
|
|
|
function renderConfig(config) {
|
|
latestConfig = config;
|
|
setText(q('#schema-id'), config.schema_id || '—');
|
|
setText(q('#config-state'), config.state);
|
|
q('#restart-guidance').hidden = config.state !== 'restart required';
|
|
renderValidation(config.validation);
|
|
|
|
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 = 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 || dirty;
|
|
q('#reset-saved').disabled = !latestState.can_reset || busy;
|
|
for (const control of q('#draft-fields').elements) {
|
|
control.disabled = !latestState.can_edit || (busy && !views.onlyEdits);
|
|
}
|
|
}
|
|
|
|
function frequency(value) {
|
|
if (!Number.isFinite(value)) return '—';
|
|
const digits = value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
|
return `${value.toFixed(digits)} Hz`;
|
|
}
|
|
|
|
function packetValues(values) {
|
|
return typeof values === 'string' ? values : JSON.stringify(values, null, 2);
|
|
}
|
|
|
|
function createPacketRows(key) {
|
|
const row = document.createElement('tr');
|
|
const messageCell = document.createElement('td');
|
|
const toggle = document.createElement('button');
|
|
toggle.type = 'button';
|
|
toggle.className = 'packet-toggle';
|
|
messageCell.append(toggle);
|
|
row.append(messageCell);
|
|
|
|
const cells = [];
|
|
for (let index = 0; index < 4; index += 1) {
|
|
const cell = document.createElement('td');
|
|
cells.push(cell);
|
|
row.append(cell);
|
|
}
|
|
|
|
const detail = document.createElement('tr');
|
|
detail.className = 'packet-detail';
|
|
detail.hidden = true;
|
|
const detailCell = document.createElement('td');
|
|
detailCell.colSpan = 5;
|
|
const values = document.createElement('pre');
|
|
detailCell.append(values);
|
|
detail.append(detailCell);
|
|
|
|
const entry = {row, toggle, cells, detail, values, latestValues: null};
|
|
toggle.addEventListener('click', () => {
|
|
if (expandedPackets.has(key)) {
|
|
expandedPackets.delete(key);
|
|
} else {
|
|
expandedPackets.add(key);
|
|
setText(values, packetValues(entry.latestValues));
|
|
}
|
|
const expanded = expandedPackets.has(key);
|
|
toggle.setAttribute('aria-expanded', String(expanded));
|
|
detail.hidden = !expanded;
|
|
});
|
|
return entry;
|
|
}
|
|
|
|
function renderPackets(snapshot) {
|
|
const body = q('#packet-rows');
|
|
const packets = [...snapshot.packets].sort((left, right) =>
|
|
left.name.localeCompare(right.name)
|
|
|| left.system_id - right.system_id
|
|
|| left.component_id - right.component_id
|
|
|| left.message_id - right.message_id);
|
|
const liveKeys = new Set(packets.map(packet =>
|
|
`${packet.system_id}:${packet.component_id}:${packet.message_id}`));
|
|
for (const key of expandedPackets) {
|
|
if (!liveKeys.has(key)) expandedPackets.delete(key);
|
|
}
|
|
for (const [key, entry] of packetRows) {
|
|
if (liveKeys.has(key)) continue;
|
|
entry.row.remove();
|
|
entry.detail.remove();
|
|
packetRows.delete(key);
|
|
}
|
|
if (packets.length === 0) {
|
|
if (!body.querySelector('.empty-row')) {
|
|
const row = document.createElement('tr');
|
|
row.className = 'empty-row';
|
|
const cell = document.createElement('td');
|
|
cell.colSpan = 5;
|
|
cell.textContent = 'No MAVLink packets received on this link.';
|
|
row.append(cell);
|
|
body.replaceChildren(row);
|
|
}
|
|
return;
|
|
}
|
|
|
|
body.querySelector('.empty-row')?.remove();
|
|
const orderedRows = [];
|
|
for (const packet of packets) {
|
|
const key = `${packet.system_id}:${packet.component_id}:${packet.message_id}`;
|
|
let entry = packetRows.get(key);
|
|
if (!entry) {
|
|
entry = createPacketRows(key);
|
|
packetRows.set(key, entry);
|
|
}
|
|
entry.latestValues = packet.values;
|
|
setText(entry.toggle, packet.name);
|
|
entry.toggle.setAttribute('aria-expanded', String(expandedPackets.has(key)));
|
|
for (const [index, value] of [
|
|
`${packet.system_id}:${packet.component_id}`,
|
|
packet.message_id,
|
|
frequency(packet.frequency_hz),
|
|
packet.count,
|
|
].entries()) {
|
|
setText(entry.cells[index], value);
|
|
}
|
|
entry.detail.hidden = !expandedPackets.has(key);
|
|
setText(entry.values, packetValues(packet.values));
|
|
orderedRows.push(entry.row, entry.detail);
|
|
}
|
|
|
|
let cursor = body.firstChild;
|
|
for (const row of orderedRows) {
|
|
if (row !== cursor) body.insertBefore(row, cursor);
|
|
cursor = row.nextSibling;
|
|
}
|
|
}
|
|
|
|
function logTime(receivedAtMs) {
|
|
const received = new Date(receivedAtMs);
|
|
if (Number.isNaN(received.getTime())) return '—';
|
|
return received.toLocaleTimeString([], {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
fractionalSecondDigits: 3,
|
|
hour12: false,
|
|
});
|
|
}
|
|
|
|
function createLogEntry(entry) {
|
|
const item = document.createElement('li');
|
|
item.dataset.logId = String(entry.id);
|
|
const levelClass = entry.level.split(/[^A-Z]+/, 1)[0].toLowerCase();
|
|
item.className = `log-entry log-${levelClass}`;
|
|
|
|
const time = document.createElement('time');
|
|
time.dateTime = new Date(entry.received_at_ms).toISOString();
|
|
time.textContent = logTime(entry.received_at_ms);
|
|
|
|
const level = document.createElement('span');
|
|
level.className = 'log-level';
|
|
level.textContent = entry.level;
|
|
|
|
const text = document.createElement('span');
|
|
text.className = 'log-text';
|
|
if (entry.truncated) {
|
|
const truncated = document.createElement('span');
|
|
truncated.className = 'log-truncated';
|
|
truncated.textContent = 'TRUNCATED';
|
|
text.append(truncated, document.createTextNode(entry.text));
|
|
} else {
|
|
text.textContent = entry.text;
|
|
}
|
|
|
|
item.title = `MAVLink source ${entry.system_id}:${entry.component_id}`;
|
|
item.append(time, level, text);
|
|
return item;
|
|
}
|
|
|
|
function renderLogs(snapshot) {
|
|
const entries = q('#log-entries');
|
|
if (snapshot.session !== logSession) {
|
|
logSession = snapshot.session;
|
|
latestLogId = -1;
|
|
entries.replaceChildren();
|
|
}
|
|
|
|
const followsLatest = entries.scrollHeight - entries.scrollTop - entries.clientHeight < 32;
|
|
const retainedIds = new Set(snapshot.entries.map(entry => String(entry.id)));
|
|
for (const rendered of entries.querySelectorAll('[data-log-id]')) {
|
|
if (!retainedIds.has(rendered.dataset.logId)) rendered.remove();
|
|
}
|
|
const newEntries = snapshot.entries.filter(entry => entry.id > latestLogId);
|
|
if (newEntries.length !== 0) {
|
|
entries.querySelector('.empty-log')?.remove();
|
|
entries.append(...newEntries.map(createLogEntry));
|
|
latestLogId = newEntries[newEntries.length - 1].id;
|
|
if (followsLatest) entries.scrollTop = entries.scrollHeight;
|
|
} else if (entries.children.length === 0) {
|
|
const empty = document.createElement('li');
|
|
empty.className = 'empty-log';
|
|
empty.textContent = 'No Kaptan logs received on this link.';
|
|
entries.append(empty);
|
|
}
|
|
|
|
setText(q('#log-retention'), snapshot.discarded === 0
|
|
? ''
|
|
: `${snapshot.discarded} older host-side log entries discarded after reaching the display limit.`);
|
|
}
|
|
|
|
function renderState(state) {
|
|
latestState = state;
|
|
setText(q('#connection-status'), state.path ? `${state.status} (${state.path})` : state.status);
|
|
showError(state.error ?? '');
|
|
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 context = views.view?.state.context;
|
|
const [, packets, logs] = await Promise.all([
|
|
views.refresh(),
|
|
api('/api/packets'),
|
|
api('/api/logs'),
|
|
]);
|
|
if (context?.connection === views.view?.state.context.connection
|
|
&& context?.instance === views.view?.state.context.instance) {
|
|
renderPackets(packets);
|
|
renderLogs(logs);
|
|
}
|
|
} catch (error) {
|
|
showError(error.message);
|
|
} finally {
|
|
window.setTimeout(refresh, 500);
|
|
}
|
|
}
|
|
|
|
refresh();
|