diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f12b077d..9a130d31 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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 diff --git a/frontend/css/settings.css b/frontend/css/settings.css index 19225d0c..209a7708 100644 --- a/frontend/css/settings.css +++ b/frontend/css/settings.css @@ -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; +} diff --git a/frontend/index.html b/frontend/index.html index 239398cc..3bbad431 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -656,6 +656,7 @@

Display units

+

Service actions

Each requires confirmation and is recorded in the audit log.

@@ -706,6 +707,7 @@

Service actions

+ diff --git a/frontend/js/app.js b/frontend/js/app.js index b6b237ed..1e3c1b52 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -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; diff --git a/frontend/js/settings/companion_flash_card.js b/frontend/js/settings/companion_flash_card.js new file mode 100644 index 00000000..160d426c --- /dev/null +++ b/frontend/js/settings/companion_flash_card.js @@ -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 = ` +
+

Flash companion firmware

+

+ Upload a .bin 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. +

+ + +
+ + +
+

+
+ + +
+

+            
+ `; + + 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; diff --git a/requirements.txt b/requirements.txt index 89b9c0b1..83f8b445 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,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 diff --git a/src/api/routes/firmware_routes.py b/src/api/routes/firmware_routes.py new file mode 100644 index 00000000..c02fffae --- /dev/null +++ b/src/api/routes/firmware_routes.py @@ -0,0 +1,232 @@ +"""USB companion firmware upload, flash, and live log WebSocket (PR 14).""" +from __future__ import annotations + +import asyncio +import logging +import os +import tempfile +from pathlib import Path +from typing import Awaitable, Callable + +from fastapi import ( + APIRouter, + Depends, + File, + HTTPException, + UploadFile, + WebSocket, + WebSocketDisconnect, +) +from pydantic import BaseModel, Field + +from src.api.audit import AuditLogWriter +from src.api.audit.dependencies import get_audit_writer +from src.api.auth.dependencies import require_admin +from src.api.auth.jwt_session import ROLE_ADMIN, JwtSessionService, SessionClaims +from src.api.auth.ws_guard import WS_AUTH_CLOSE_CODE, authenticate_websocket +from src.firmware.flasher import FlashJob, get_port_lock, run_flash_job +from src.firmware.log_broadcast import FlashLogBroadcaster +from src.firmware.upload_store import FirmwareUploadStore + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/firmware", tags=["firmware"]) + +MAX_UPLOAD_BYTES = 10 * 1024 * 1024 +DEFAULT_BAUD = 460800 +DEFAULT_OFFSET = "0x10000" + +_upload_store = FirmwareUploadStore() +_broadcaster = FlashLogBroadcaster() +_jwt_service: JwtSessionService | None = None +_suspend_meshcore: Callable[[str], Awaitable[None]] | None = None +_default_port: str = "/dev/ttyUSB0" + + +def init_routes( + *, + jwt_service: JwtSessionService | None, + suspend_meshcore: Callable[[str], Awaitable[None]] | None = None, + default_serial_port: str | None = None, +) -> None: + global _jwt_service, _suspend_meshcore, _default_port + _jwt_service = jwt_service + _suspend_meshcore = suspend_meshcore + if default_serial_port: + _default_port = default_serial_port + + +def reset_routes() -> None: + global _jwt_service, _suspend_meshcore + _jwt_service = None + _suspend_meshcore = None + + +class FlashRequest(BaseModel): + upload_id: str = Field(..., min_length=8, max_length=64) + serial_port: str = Field(default="/dev/ttyUSB0", min_length=1, max_length=128) + baud_rate: int = Field(default=DEFAULT_BAUD, ge=9600, le=921600) + partition_offset: str = Field(default=DEFAULT_OFFSET, min_length=3, max_length=16) + + +@router.websocket("/ws/flash-log") +async def flash_log_ws(websocket: WebSocket) -> None: + if _jwt_service is None: + await websocket.accept() + await websocket.close(code=WS_AUTH_CLOSE_CODE) + return + + claims = authenticate_websocket(websocket, _jwt_service) + if claims is None or claims.role != ROLE_ADMIN: + await websocket.accept() + await websocket.close(code=WS_AUTH_CLOSE_CODE) + return + + await websocket.accept() + await _broadcaster.subscribe(websocket) + try: + while True: + await websocket.receive_text() + except WebSocketDisconnect: + pass + except Exception: + pass + finally: + await _broadcaster.unsubscribe(websocket) + + +@router.post("/upload") +async def upload_firmware( + firmware_file: UploadFile = File(...), + _claims: SessionClaims = Depends(require_admin), +): + filename = (firmware_file.filename or "").strip() + if not filename.lower().endswith(".bin"): + raise HTTPException(status_code=400, detail="Only .bin firmware files are accepted.") + + content = await firmware_file.read() + if len(content) > MAX_UPLOAD_BYTES: + raise HTTPException( + status_code=400, + detail=f"Firmware file exceeds {MAX_UPLOAD_BYTES // (1024 * 1024)} MB limit.", + ) + if not content: + raise HTTPException(status_code=400, detail="Empty firmware file.") + + tmp = tempfile.NamedTemporaryFile( + suffix=".bin", + prefix="mp_fw_", + delete=False, + ) + try: + tmp.write(content) + tmp.flush() + tmp.close() + upload_id = _upload_store.store( + Path(tmp.name), + filename, + len(content), + ) + except Exception as exc: + try: + os.unlink(tmp.name) + except OSError: + pass + raise HTTPException(status_code=500, detail=f"Failed to store upload: {exc}") from exc + + return { + "upload_id": upload_id, + "filename": filename, + "size_bytes": len(content), + } + + +@router.get("/defaults") +async def firmware_defaults(_claims: SessionClaims = Depends(require_admin)): + return { + "serial_port": _default_port, + "baud_rate": DEFAULT_BAUD, + "partition_offset": DEFAULT_OFFSET, + "max_upload_bytes": MAX_UPLOAD_BYTES, + } + + +@router.post("/flash") +async def flash_companion( + req: FlashRequest, + claims: SessionClaims = Depends(require_admin), + audit: AuditLogWriter = Depends(get_audit_writer), +): + record = _upload_store.pop(req.upload_id) + if record is None: + raise HTTPException(status_code=404, detail="Unknown or expired upload_id.") + + port = req.serial_port.strip() + if not port: + raise HTTPException(status_code=400, detail="serial_port is required.") + + port_lock = get_port_lock(port) + if port_lock.locked(): + _upload_store.store(record.path, record.filename, record.size_bytes) + raise HTTPException( + status_code=409, + detail=f"A flash operation is already in progress on {port}.", + ) + + async def _task() -> None: + async with port_lock: + exit_code: int | None = None + success = False + with audit.timed_action( + user=claims.subject, + action="firmware_flash", + params={ + "filename": record.filename, + "port": port, + "baud": req.baud_rate, + "offset": req.partition_offset, + "size_bytes": record.size_bytes, + }, + ) as ctx: + try: + if _suspend_meshcore is not None: + await _broadcaster.broadcast( + f"[flasher] releasing MeshCore serial on {port}…" + ) + await _suspend_meshcore(port) + + job = FlashJob( + port=port, + baud=req.baud_rate, + offset=req.partition_offset, + bin_path=record.path, + log_callback=_broadcaster.broadcast, + ) + success = await run_flash_job(job) + exit_code = 0 if success else 1 + ctx.params["exit_code"] = exit_code + ctx.params["success"] = success + if not success: + ctx.set_result("error") + except Exception as exc: + logger.exception("Firmware flash task failed") + await _broadcaster.broadcast(f"[flasher] EXCEPTION: {exc}") + ctx.set_result("error") + ctx.params["error"] = str(exc) + finally: + try: + os.unlink(record.path) + except OSError: + pass + await _broadcaster.broadcast( + "[flasher] MeshCore will auto-reconnect when the port is free." + ) + + asyncio.create_task(_task(), name="firmware-flash") + + return { + "status": "queued", + "port": port, + "filename": record.filename, + "ws_path": "/api/firmware/ws/flash-log", + } diff --git a/src/api/server.py b/src/api/server.py index 3bd4fb06..9d7f1a28 100644 --- a/src/api/server.py +++ b/src/api/server.py @@ -41,6 +41,7 @@ dangerous_routes, device, device_config_routes, + firmware_routes, gps_status, identity_routes, messages, @@ -259,6 +260,7 @@ async def lifespan(app: FastAPI): app.include_router(auth_config_routes.router) app.include_router(public_radar_routes.router) app.include_router(terminal_routes.router) + app.include_router(firmware_routes.router) app.include_router(update_routes.router) app.include_router(dangerous_routes.router) @@ -1266,6 +1268,29 @@ def _init_routes( device_config_routes.init_routes(config=config, identity=identity) gps_status.init_routes(location_source=coord.location_source) system_config_routes.init_routes(config=config) + + async def _suspend_meshcore_for_flash(port: str) -> None: + src = _find_meshcore_source(coord) + if src is None: + return + resolved = getattr(src, "_resolved_port", None) or port + configured = getattr(src, "_configured_port", None) + if resolved != port and configured != port: + logger.info( + "MeshCore USB port %s does not match active source %s — skip release", + port, + resolved, + ) + return + await src.release_serial_for_flash() + + mc_usb = config.capture.meshcore_usb + default_mc_port = (mc_usb.serial_port or "/dev/ttyUSB0").strip() + firmware_routes.init_routes( + jwt_service=auth_subsystem.jwt_service, + suspend_meshcore=_suspend_meshcore_for_flash, + default_serial_port=default_mc_port, + ) meshcore_config_routes.init_routes(config=config, tx_service=tx_service) diff --git a/src/capture/meshcore_usb_source.py b/src/capture/meshcore_usb_source.py index 28e0479e..d393ec5b 100644 --- a/src/capture/meshcore_usb_source.py +++ b/src/capture/meshcore_usb_source.py @@ -208,6 +208,10 @@ async def _connect(self, port: str) -> None: ) self._connected = False + async def release_serial_for_flash(self) -> None: + """Disconnect so an external tool (esptool) can open the serial port.""" + await self._disconnect() + async def _disconnect(self) -> None: self._connected = False if self._meshcore: diff --git a/src/firmware/__init__.py b/src/firmware/__init__.py new file mode 100644 index 00000000..452a8f40 --- /dev/null +++ b/src/firmware/__init__.py @@ -0,0 +1 @@ +"""USB companion firmware flashing (esptool) for field operations.""" diff --git a/src/firmware/flasher.py b/src/firmware/flasher.py new file mode 100644 index 00000000..8ec6d922 --- /dev/null +++ b/src/firmware/flasher.py @@ -0,0 +1,82 @@ +"""Async esptool subprocess runner for companion firmware flashes.""" +from __future__ import annotations + +import asyncio +import logging +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Awaitable, Callable + +logger = logging.getLogger(__name__) + +_port_locks: dict[str, asyncio.Lock] = {} + + +def get_port_lock(port: str) -> asyncio.Lock: + if port not in _port_locks: + _port_locks[port] = asyncio.Lock() + return _port_locks[port] + + +@dataclass +class FlashJob: + port: str + baud: int + offset: str + bin_path: Path + log_callback: Callable[[str], Awaitable[None]] + + +async def run_flash_job(job: FlashJob) -> bool: + """Run esptool; stream stdout to log_callback. Caller holds port lock.""" + command = [ + sys.executable, + "-m", + "esptool", + "--port", + job.port, + "--baud", + str(job.baud), + "write_flash", + "-z", + job.offset, + str(job.bin_path), + ] + + await job.log_callback(f"[flasher] {' '.join(command)}") + + try: + proc = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + if proc.stdout is None: + await job.log_callback("[flasher] ERROR: no subprocess stdout") + return False + + async for raw in proc.stdout: + line = raw.decode("utf-8", errors="replace").rstrip() + if line: + await job.log_callback(line) + + await proc.wait() + success = proc.returncode == 0 + await job.log_callback( + "[flasher] SUCCESS" + if success + else f"[flasher] FAILED (exit {proc.returncode})" + ) + return success + + except FileNotFoundError: + await job.log_callback( + "[flasher] ERROR: esptool not found — " + "install with: pip install esptool" + ) + return False + except Exception as exc: + logger.exception("Flash job failed") + await job.log_callback(f"[flasher] EXCEPTION: {exc}") + return False diff --git a/src/firmware/log_broadcast.py b/src/firmware/log_broadcast.py new file mode 100644 index 00000000..9a6f3771 --- /dev/null +++ b/src/firmware/log_broadcast.py @@ -0,0 +1,33 @@ +"""Authenticated WebSocket subscribers for live firmware flash logs.""" +from __future__ import annotations + +import asyncio +import logging + +from fastapi import WebSocket + +logger = logging.getLogger(__name__) + + +class FlashLogBroadcaster: + def __init__(self) -> None: + self._subscribers: set[WebSocket] = set() + self._lock = asyncio.Lock() + + async def subscribe(self, websocket: WebSocket) -> None: + async with self._lock: + self._subscribers.add(websocket) + + async def unsubscribe(self, websocket: WebSocket) -> None: + async with self._lock: + self._subscribers.discard(websocket) + + async def broadcast(self, message: str) -> None: + async with self._lock: + dead: set[WebSocket] = set() + for ws in self._subscribers: + try: + await ws.send_text(message) + except Exception: + dead.add(ws) + self._subscribers.difference_update(dead) diff --git a/src/firmware/upload_store.py b/src/firmware/upload_store.py new file mode 100644 index 00000000..28f8ed06 --- /dev/null +++ b/src/firmware/upload_store.py @@ -0,0 +1,61 @@ +"""In-memory staging for uploaded firmware binaries.""" +from __future__ import annotations + +import secrets +import time +from dataclasses import dataclass +from pathlib import Path +from threading import Lock + + +@dataclass +class UploadRecord: + upload_id: str + path: Path + filename: str + size_bytes: int + created_at: float + + +class FirmwareUploadStore: + """Short-lived upload registry keyed by opaque upload_id.""" + + def __init__(self, *, ttl_seconds: float = 3600.0) -> None: + self._ttl = ttl_seconds + self._records: dict[str, UploadRecord] = {} + self._lock = Lock() + + def store(self, path: Path, filename: str, size_bytes: int) -> str: + self._prune_expired() + upload_id = secrets.token_urlsafe(16) + record = UploadRecord( + upload_id=upload_id, + path=path, + filename=filename, + size_bytes=size_bytes, + created_at=time.monotonic(), + ) + with self._lock: + self._records[upload_id] = record + return upload_id + + def get(self, upload_id: str) -> UploadRecord | None: + self._prune_expired() + with self._lock: + return self._records.get(upload_id) + + def pop(self, upload_id: str) -> UploadRecord | None: + self._prune_expired() + with self._lock: + return self._records.pop(upload_id, None) + + def _prune_expired(self) -> None: + cutoff = time.monotonic() - self._ttl + with self._lock: + expired = [ + uid + for uid, rec in self._records.items() + if rec.created_at < cutoff + ] + for uid in expired: + self._records.pop(uid, None) diff --git a/tests/test_firmware_flasher.py b/tests/test_firmware_flasher.py new file mode 100644 index 00000000..8fa5543a --- /dev/null +++ b/tests/test_firmware_flasher.py @@ -0,0 +1,114 @@ +"""USB companion firmware flasher (PR 14).""" + +from __future__ import annotations + +import asyncio +import unittest +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +from src.firmware.flasher import FlashJob, get_port_lock, run_flash_job +from src.firmware.upload_store import FirmwareUploadStore + + +def _run(coro): + return asyncio.run(coro) + + +class _AsyncStdout: + def __init__(self, lines: list[bytes]) -> None: + self._lines = iter(lines) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self._lines) + except StopIteration: + raise StopAsyncIteration from None + + +class TestRunFlashJob(unittest.TestCase): + def _make_job(self, callback=None): + cb = callback or AsyncMock() + job = FlashJob( + port="/dev/ttyUSB0", + baud=460800, + offset="0x10000", + bin_path=Path("/tmp/test_fw.bin"), + log_callback=cb, + ) + return job, cb + + def test_success_returns_true(self): + job, _cb = self._make_job() + mock_proc = MagicMock() + mock_proc.returncode = 0 + mock_proc.stdout = _AsyncStdout([b"Writing at 0x10000...\n", b"Hash OK\n"]) + mock_proc.wait = AsyncMock() + + with patch("asyncio.create_subprocess_exec", return_value=mock_proc): + result = _run(run_flash_job(job)) + self.assertTrue(result) + + def test_failure_returns_false(self): + job, _cb = self._make_job() + mock_proc = MagicMock() + mock_proc.returncode = 2 + mock_proc.stdout = _AsyncStdout([b"Failed to connect\n"]) + mock_proc.wait = AsyncMock() + + with patch("asyncio.create_subprocess_exec", return_value=mock_proc): + result = _run(run_flash_job(job)) + self.assertFalse(result) + + def test_missing_esptool_returns_false(self): + job, _cb = self._make_job() + with patch( + "asyncio.create_subprocess_exec", + side_effect=FileNotFoundError("esptool not found"), + ): + result = _run(run_flash_job(job)) + self.assertFalse(result) + + def test_log_callback_receives_stdout_lines(self): + lines_received: list[str] = [] + + async def capture(msg: str) -> None: + lines_received.append(msg) + + job, _ = self._make_job(callback=capture) + mock_proc = MagicMock() + mock_proc.returncode = 0 + mock_proc.stdout = _AsyncStdout([b"line one\n", b"line two\n"]) + mock_proc.wait = AsyncMock() + + with patch("asyncio.create_subprocess_exec", return_value=mock_proc): + _run(run_flash_job(job)) + + joined = "\n".join(lines_received) + self.assertIn("line one", joined) + self.assertIn("line two", joined) + + def test_port_lock_is_per_port(self): + lock_a = get_port_lock("/dev/ttyUSB0") + lock_b = get_port_lock("/dev/ttyACM0") + lock_a2 = get_port_lock("/dev/ttyUSB0") + self.assertIs(lock_a, lock_a2) + self.assertIsNot(lock_a, lock_b) + + +class TestFirmwareUploadStore(unittest.TestCase): + def test_store_and_pop(self): + store = FirmwareUploadStore() + path = Path("/tmp/fake.bin") + upload_id = store.store(path, "fw.bin", 1024) + record = store.pop(upload_id) + self.assertIsNotNone(record) + self.assertEqual(record.filename, "fw.bin") + self.assertIsNone(store.pop(upload_id)) + + +if __name__ == "__main__": + unittest.main()