Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,16 @@ The setup wizard configures sources automatically. To add or remove a MeshCore c

When running both Meshtastic concentrator capture and a MeshCore USB companion, pin `meshcore_usb.serial_port` explicitly. Auto-detect can grab the wrong device when multiple Espressif boards are attached.

### Companion firmware flash (dashboard)

**Settings → System → Flash companion firmware** uploads a `.bin` and runs `esptool` against the USB companion. MeshCore USB capture releases the serial port during the flash; the existing auto-reconnect loop restores the connection after the ESP reboots (~5–15 s).

- Admin-only; every attempt is audit-logged (`firmware_flash`).
- Default port comes from `capture.meshcore_usb.serial_port` (falls back to `/dev/ttyUSB0`).
- Default partition offset `0x10000` (typical MeshCore/Meshtastic app slot — verify for your board).
- Requires `esptool>=4.7.0` (installed with Meshpoint dependencies).
- **Not** OTA over LoRa — local USB only.

---

## Location (GPS) source
Expand Down
21 changes: 21 additions & 0 deletions frontend/css/settings.css
Original file line number Diff line number Diff line change
Expand Up @@ -1018,3 +1018,24 @@ select.update-field__input option:checked {
}

.dangerous-panel__status[data-kind="error"] { color: #ff6b6b; }

.companion-flash-log {
margin: 12px 0 0;
padding: 10px 12px;
max-height: 220px;
overflow: auto;
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 11px;
line-height: 1.45;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
white-space: pre-wrap;
word-break: break-word;
}

.cfg-field--row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
2 changes: 2 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,7 @@ <h3 class="auth-card__title">Display units</h3>
</fieldset>
<p class="auth-status" data-display-units-status aria-live="polite"></p>
</article>
<div id="companion-flash-host"></div>
<header class="dangerous-panel__head dangerous-panel__head--secondary">
<h3 class="dangerous-panel__title">Service actions</h3>
<p class="dangerous-panel__subtitle">Each requires confirmation and is recorded in the audit log.</p>
Expand Down Expand Up @@ -706,6 +707,7 @@ <h3 class="dangerous-panel__title">Service actions</h3>
<script src="js/settings/release_notes_view.js"></script>
<script src="js/settings/update_panel_controller.js"></script>
<script src="js/settings/meshpoint_display_form.js"></script>
<script src="js/settings/companion_flash_card.js"></script>
<script src="js/settings/dangerous_panel_controller.js"></script>
<script src="js/configuration/identity_card.js"></script>
<script src="js/configuration/radio_card.js"></script>
Expand Down
5 changes: 5 additions & 0 deletions frontend/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,11 @@ function _bootDangerousPanel(router) {
if (prefsRoot && window.MeshpointDisplayForm) {
new window.MeshpointDisplayForm(prefsRoot);
}
const flashHost = document.getElementById('companion-flash-host');
if (flashHost && window.CompanionFlashCard) {
const flashCard = new window.CompanionFlashCard(flashHost);
flashCard.mount();
}
const controller = new window.DangerousPanelController(root);
controller.bind();
let primed = false;
Expand Down
225 changes: 225 additions & 0 deletions frontend/js/settings/companion_flash_card.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/**
* Settings → System — USB companion firmware flasher (PR 14).
*
* Upload a .bin, confirm via DangerousModal, POST /api/firmware/flash,
* and stream esptool output from /api/firmware/ws/flash-log.
*/

class CompanionFlashCard {
constructor(rootEl) {
this._host = rootEl;
this._modal = new window.DangerousModal();
this._uploadId = null;
this._filename = '';
this._ws = null;
this._logLines = [];
}

mount() {
if (!this._host) return;
this._host.innerHTML = `
<article class="auth-card companion-flash-card" id="companion-flash-card">
<h3 class="auth-card__title">Flash companion firmware</h3>
<p class="auth-card__hint">
Upload a <code>.bin</code> and flash the USB MeshCore or Meshtastic companion.
MeshCore capture pauses during the flash and auto-reconnects afterward.
Admin only; recorded in the audit log.
</p>
<label class="cfg-field">
<span class="cfg-field__label">Firmware file (.bin)</span>
<input class="cfg-field__input" type="file" accept=".bin,application/octet-stream"
data-fw-file>
</label>
<label class="cfg-field">
<span class="cfg-field__label">Serial port</span>
<input class="cfg-field__input" type="text" data-fw-port placeholder="/dev/ttyUSB0">
</label>
<div class="cfg-field cfg-field--row">
<label class="cfg-field">
<span class="cfg-field__label">Baud</span>
<input class="cfg-field__input" type="number" min="9600" max="921600"
step="1" data-fw-baud value="460800">
</label>
<label class="cfg-field">
<span class="cfg-field__label">Offset</span>
<input class="cfg-field__input" type="text" data-fw-offset value="0x10000">
</label>
</div>
<p class="auth-card__hint" data-fw-upload-status aria-live="polite"></p>
<div class="auth-card__actions">
<button type="button" class="terminal-button terminal-button--danger"
data-fw-flash disabled>Flash firmware</button>
<button type="button" class="terminal-button terminal-button--ghost"
data-fw-clear-log>Clear log</button>
</div>
<pre class="companion-flash-log" data-fw-log aria-live="polite"></pre>
</article>
`;

this._fileInput = this._host.querySelector('[data-fw-file]');
this._portInput = this._host.querySelector('[data-fw-port]');
this._baudInput = this._host.querySelector('[data-fw-baud]');
this._offsetInput = this._host.querySelector('[data-fw-offset]');
this._flashBtn = this._host.querySelector('[data-fw-flash]');
this._uploadStatus = this._host.querySelector('[data-fw-upload-status]');
this._logEl = this._host.querySelector('[data-fw-log]');

this._fileInput.addEventListener('change', () => this._onFileSelected());
this._flashBtn.addEventListener('click', () => this._onFlashClick());
this._host.querySelector('[data-fw-clear-log]').addEventListener('click', () => {
this._logLines = [];
this._paintLog();
});

this._loadDefaults();
this._connectLogWs();
}

async _loadDefaults() {
try {
const res = await fetch('/api/firmware/defaults', { credentials: 'same-origin' });
if (!res.ok) return;
const data = await res.json();
if (data.serial_port && this._portInput) {
this._portInput.value = data.serial_port;
}
if (data.baud_rate != null && this._baudInput) {
this._baudInput.value = data.baud_rate;
}
if (data.partition_offset && this._offsetInput) {
this._offsetInput.value = data.partition_offset;
}
} catch (_e) { /* best-effort */ }
}

_connectLogWs() {
if (this._ws) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/api/firmware/ws/flash-log`;
try {
this._ws = new WebSocket(url);
this._ws.onmessage = (event) => {
this._appendLog(event.data);
};
this._ws.onclose = () => {
this._ws = null;
setTimeout(() => this._connectLogWs(), 5000);
};
} catch (_e) {
this._appendLog('[flasher] WebSocket unavailable');
}
}

async _onFileSelected() {
const file = this._fileInput?.files?.[0];
if (!file) return;
if (!file.name.toLowerCase().endsWith('.bin')) {
this._setUploadStatus('error', 'Only .bin files are accepted.');
this._uploadId = null;
this._flashBtn.disabled = true;
return;
}
this._setUploadStatus('pending', 'Uploading…');
const form = new FormData();
form.append('firmware_file', file);
try {
const res = await fetch('/api/firmware/upload', {
method: 'POST',
credentials: 'same-origin',
body: form,
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
this._setUploadStatus('error', body.detail || `Upload failed (${res.status}).`);
this._flashBtn.disabled = true;
return;
}
const data = await res.json();
this._uploadId = data.upload_id;
this._filename = data.filename || file.name;
this._setUploadStatus(
'success',
`Ready: ${this._filename} (${this._formatBytes(data.size_bytes)})`,
);
this._flashBtn.disabled = false;
} catch (_e) {
this._setUploadStatus('error', 'Upload failed (network error).');
this._flashBtn.disabled = true;
}
}

async _onFlashClick() {
if (!this._uploadId) return;
const port = (this._portInput?.value || '').trim();
const baud = Number(this._baudInput?.value || 460800);
const offset = (this._offsetInput?.value || '0x10000').trim();
const ok = await this._modal.confirm({
label: 'flash firmware',
command: `Flash ${this._filename}`,
description:
`This halts MeshCore USB on ${port} for ~20–30 seconds while esptool writes the image. `
+ 'The companion will reboot and reconnect automatically.',
});
if (!ok) return;

this._appendLog(`[ui] Starting flash on ${port}…`);
this._flashBtn.disabled = true;
try {
const res = await fetch('/api/firmware/flash', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
upload_id: this._uploadId,
serial_port: port,
baud_rate: baud,
partition_offset: offset,
}),
});
const body = await res.json().catch(() => ({}));
if (res.status === 409) {
this._appendLog(`[ui] ${body.detail || 'Flash already in progress.'}`);
} else if (!res.ok) {
this._appendLog(`[ui] Flash request failed: ${body.detail || res.status}`);
} else {
this._appendLog(`[ui] Queued — watch log below for esptool output.`);
this._uploadId = null;
this._fileInput.value = '';
this._setUploadStatus('', '');
}
} catch (_e) {
this._appendLog('[ui] Flash request failed (network error).');
} finally {
this._flashBtn.disabled = !this._uploadId;
}
}

_appendLog(line) {
this._logLines.push(String(line));
if (this._logLines.length > 500) {
this._logLines = this._logLines.slice(-500);
}
this._paintLog();
}

_paintLog() {
if (!this._logEl) return;
this._logEl.textContent = this._logLines.join('\n');
this._logEl.scrollTop = this._logEl.scrollHeight;
}

_setUploadStatus(kind, message) {
if (!this._uploadStatus) return;
this._uploadStatus.dataset.kind = kind || '';
this._uploadStatus.textContent = message || '';
}

static _formatBytes(n) {
const v = Number(n) || 0;
if (v < 1024) return `${v} B`;
if (v < 1024 * 1024) return `${(v / 1024).toFixed(1)} KB`;
return `${(v / (1024 * 1024)).toFixed(2)} MB`;
}
}

window.CompanionFlashCard = CompanionFlashCard;
4 changes: 3 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ uvicorn[standard]>=0.29.0
aiosqlite>=0.20.0
meshtastic>=2.3.0
pycryptodome>=3.20.0
cryptography>=43.0.0
protobuf>=4.25.0
pyyaml>=6.0
websockets>=12.0
Expand All @@ -13,3 +12,6 @@ meshcore>=2.1.0
paho-mqtt>=2.1.0
bcrypt>=4.2.0
PyJWT>=2.10.0
pyserial>=3.5
httpx>=0.27.0
esptool>=4.7.0
Loading
Loading