Skip to content

Add firmware diagnostics and recovery workbench - #23

Open
hardcoreerik wants to merge 1 commit into
mainfrom
codex/firmware-workbench
Open

Add firmware diagnostics and recovery workbench#23
hardcoreerik wants to merge 1 commit into
mainfrom
codex/firmware-workbench

Conversation

@hardcoreerik

@hardcoreerik hardcoreerik commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Summary

  • turn the Firmware tab into a diagnostics, backup, serial-console, and flashing workbench
  • add verbose esptool chip/MAC/flash/security probing and safe ESP32 bootloader sequencing
  • add Meshtastic configuration backup/restore with an automatic pre-restore safety copy
  • add full-flash binary backup with SHA-256 manifest and verified USB identity
  • add a read-only serial console with explicit COM-port ownership and reconnect handling
  • bundle Qt SerialPort and preserve existing verified official-release flashing safeguards

Safety

  • no arbitrary firmware image flashing
  • no writable serial console
  • full erase requires an explicit backup waiver if no configuration backup was made in-session
  • raw flash restore is intentionally not exposed
  • COM8 was not opened or modified during development or automated verification

Verification

  • .venv\\Scripts\\python.exe -m pytest -q -> 441 passed
  • .venv\\Scripts\\python.exe -m ruff check src tests packaging -> passed
  • .venv\\Scripts\\python.exe -m mypy src -> no issues in 68 source files
  • PyInstaller Windows build completed successfully
  • packaged OrcMesh.exe, Qt6SerialPort.dll, and ESP32-S3 esptool stub verified present

Summary by CodeRabbit

  • New Features

    • Added device profile backup and restore with safety backups and validation.
    • Added firmware device probing and raw flash backup with progress logs and integrity manifests.
    • Added a read-only serial console for device diagnostics.
    • Added firmware release channel labels, including alpha, beta, and stable.
    • Improved device controls, operation status reporting, and connection handling during maintenance tasks.
  • Bug Fixes

    • Improved firmware flashing reliability with bootloader checks, retry handling, and safer reset transitions.
    • Refined control availability based on the selected device tab and connection state.

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds firmware probing and raw flash backup, profile backup and restore, serial-console controls, USB identity validation, operation logging, and PyInstaller serial-port packaging support.

Changes

Device operations

Layer / File(s) Summary
Firmware service and operation pipeline
src/meshchat/services/firmware.py, src/meshchat/controllers/firmware_controller.py, tests/test_firmware_service.py
Firmware discovery adds release channels. Esptool output is normalized and forwarded. USB validation, bootloader checks, probing, raw flash backup, and revised flashing flows are implemented and tested.
Profile persistence and controller operations
src/meshchat/services/device_profile.py, src/meshchat/controllers/meshtastic_controller.py, tests/test_device_profile.py
Profiles are validated, saved atomically with SHA-256 digests, and restored through a settings transaction with a safety backup. Worker and controller signals expose operation results.
Device controls and USB operation orchestration
src/meshchat/ui/device/device_page.py, src/meshchat/ui/main_window.py, packaging/orcmesh.spec, tests/test_device_page.py
The UI adds firmware, recovery, profile, logging, and serial-console controls. USB-dependent operations wait for disconnection and reconnect afterward. PyInstaller includes PySide6.QtSerialPort.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant DevicePage
  participant MainWindow
  participant FirmwareController
  participant firmware
  DevicePage->>MainWindow: Request probe or flash backup
  MainWindow->>FirmwareController: Start operation after USB release
  FirmwareController->>firmware: Validate USB and run esptool
  firmware-->>FirmwareController: Return logs and completion
  FirmwareController-->>MainWindow: Emit operation result
  MainWindow-->>DevicePage: Update UI and reconnect state
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the firmware diagnostics, backup, restore, and recovery workbench added by the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/firmware-workbench

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (7)
src/meshchat/services/firmware.py (2)

359-371: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

esptool output capture relies on replacing the process-wide standard streams with a minimal writer. Both sites follow from one design choice: _run_esptool swaps sys.stdout and sys.stderr for a _LineWriter that implements only write and flush. That makes the capture visible to every thread in the process, and it breaks any consumer that queries a normal file attribute.

  • src/meshchat/services/firmware.py#L359-L371: confirm that no other thread prints during a firmware operation, or replace the global redirect with an esptool-specific output hook.
  • src/meshchat/services/firmware.py#L300-L317: add isatty() and an encoding attribute to _LineWriter so attribute queries do not raise AttributeError inside the redirect.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/meshchat/services/firmware.py` around lines 359 - 371, The _run_esptool
flow uses process-wide stream redirection; confirm firmware operations prevent
other threads from printing, or replace redirect_stdout/redirect_stderr with an
esptool-specific output hook. In _LineWriter, add isatty() and an encoding
attribute so redirected consumers can query normal stream properties without
AttributeError. Apply the changes at src/meshchat/services/firmware.py lines
359-371 and 300-317.

359-371: 🩺 Stability & Availability | 🔵 Trivial | ⚖️ Poor tradeoff

Avoid process-wide stream redirection for concurrent output. The application has no runtime print() calls outside --version, and its logging handler retains the original sys.stderr. If esptool or another dependency writes from another thread, _LineWriter receives that output through the global streams without synchronization. Use an esptool-specific output hook when concurrent output is supported.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/meshchat/services/firmware.py` around lines 359 - 371, The firmware
invocation around esptool.main currently redirects process-wide stdout and
stderr, allowing concurrent writes from unrelated threads to reach _LineWriter
unsafely. Replace redirect_stdout/redirect_stderr with esptool’s supported
instance-specific output hook or equivalent, wiring it to writer while
preserving the existing arguments, exception conversion, and final flush
behavior.
src/meshchat/ui/main_window.py (2)

666-696: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Reuse _release_for_firmware in the other USB handlers.

_on_firmware_flash_requested at lines 655-664 and _on_serial_console_requested at lines 688-696 repeat the connection check, the _flash_port assignment, the _supervisor.cancel() call, and the status message that _release_for_firmware already implements. Route both through the helper to keep one definition of the release sequence.

♻️ Proposed change
     def _on_firmware_flash_requested(self, bundle, full_install: bool, expected_usb) -> None:
-        snapshot = self._device_snapshot
-        if not self._is_connected or snapshot is None or not snapshot.serial_port:
-            QMessageBox.warning(self, "USB Radio Required", "Reconnect the radio over USB before flashing.")
-            return
-        self._flash_port = snapshot.serial_port
-        self._pending_flash = (bundle, full_install, expected_usb)
-        self._supervisor.cancel()
-        self._status_bar.showMessage("Releasing the USB port for firmware flashing…")
-        self._controller.disconnect()
+        if self._release_for_firmware("Releasing the USB port for firmware flashing…"):
+            self._pending_flash = (bundle, full_install, expected_usb)
+            self._controller.disconnect()
     def _on_serial_console_requested(self, port: str, baud: int) -> None:
         snapshot = self._device_snapshot
-        if not self._is_connected or snapshot is None or snapshot.serial_port != port:
-            QMessageBox.warning(self, "USB Radio Required", "Reconnect the selected USB radio first.")
-            return
-        self._pending_serial_console = (port, baud)
-        self._supervisor.cancel()
-        self._status_bar.showMessage("Releasing USB for read-only serial console…")
-        self._controller.disconnect()
+        if snapshot is not None and snapshot.serial_port != port:
+            QMessageBox.warning(self, "USB Radio Required", "Reconnect the selected USB radio first.")
+            return
+        if self._release_for_firmware("Releasing USB for read-only serial console…"):
+            self._pending_serial_console = (port, baud)
+            self._controller.disconnect()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/meshchat/ui/main_window.py` around lines 666 - 696, Route
_on_firmware_flash_requested and _on_serial_console_requested through
_release_for_firmware instead of duplicating connection validation, _flash_port
assignment, supervisor cancellation, and status updates. Preserve each handler’s
existing pending-operation assignment and subsequent controller disconnect,
using the helper’s success result to guard those steps.

624-645: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard against a second queued USB operation.

The if/elif chain runs at most one pending operation per disconnect. _on_firmware_probe_requested, _on_firmware_backup_requested, and _on_serial_console_requested each set a different field, so two requests issued before the first disconnected signal arrive as two pending entries. Only the first branch runs, and the second operation is silently dropped with no message to the user.

Reject a new request while any pending operation exists.

♻️ Proposed guard in `_release_for_firmware`
     def _release_for_firmware(self, detail: str) -> bool:
         snapshot = self._device_snapshot
+        if any((
+            self._pending_flash, self._pending_probe,
+            self._pending_flash_backup, self._pending_serial_console,
+        )):
+            QMessageBox.warning(
+                self, "Operation In Progress",
+                "Wait for the current USB operation to finish.",
+            )
+            return False
         if not self._is_connected or snapshot is None or not snapshot.serial_port:

Apply the same guard in _on_serial_console_requested.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/meshchat/ui/main_window.py` around lines 624 - 645, Guard each firmware
operation request handler, including _on_firmware_probe_requested,
_on_firmware_backup_requested, and _on_serial_console_requested, by rejecting
the request when any pending operation field is already set. Preserve the
existing pending-operation scheduling and clear the new request without
overwriting the queued one; apply the equivalent guard in _release_for_firmware
as requested.
src/meshchat/controllers/firmware_controller.py (1)

71-76: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the Path import to module scope.

backup imports Path inside the method body. The other worker methods use module-level imports. Move the import to the top of the file for consistency.

♻️ Proposed change
     `@Slot`(str, str, object)
     def backup(self, port: str, destination: str, expected_usb) -> None:
         try:
-            from pathlib import Path
             backup_flash(port, Path(destination), expected_usb, lambda line: self.log.emit(line))

Add at the top of the file:

from pathlib import Path
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/meshchat/controllers/firmware_controller.py` around lines 71 - 76, Move
the pathlib.Path import from inside the backup method to module scope, alongside
the existing imports, and keep backup using the same Path symbol when calling
backup_flash.
src/meshchat/ui/device/device_page.py (1)

331-341: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Replace the hardcoded tab count with an explicit reference to the Firmware tab.

range(min(3, self._tabs.count())) depends on the Firmware tab being the fourth tab. If a tab is added or reordered, this loop disables the wrong tabs. Store the Firmware tab index when you create it in _build_firmware_tab, then skip that index here.

♻️ Proposed change

In _build_firmware_tab:

-        self._tabs.addTab(page, "Firmware")
+        self._firmware_tab_index = self._tabs.addTab(page, "Firmware")

In set_connected:

-        for index in range(min(3, self._tabs.count())):
-            self._tabs.setTabEnabled(index, connected)
+        for index in range(self._tabs.count()):
+            if index != self._firmware_tab_index:
+                self._tabs.setTabEnabled(index, connected)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/meshchat/ui/device/device_page.py` around lines 331 - 341, Update
_build_firmware_tab to store the created Firmware tab’s index, then revise
set_connected to iterate over the actual tab count while skipping that stored
index; remove the hardcoded min(3, ...) assumption and continue applying the
existing enabled state to all other tabs.
tests/test_firmware_service.py (1)

341-348: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Resolve the expected partial path in the assertion.

backup_flash calls destination.resolve() before it derives the partial path. The test compares against the unresolved tmp_path. On platforms where the temporary directory is a symbolic link, the two strings differ and the assertion fails. Resolve the destination in the test to remove that dependency.

♻️ Proposed change
-    destination = tmp_path / "radio.bin"
+    destination = (tmp_path / "radio.bin").resolve()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_firmware_service.py` around lines 341 - 348, Update the expected
command assertion in the backup_flash test to derive the partial path from
destination.resolve(), matching the path resolution performed by backup_flash
while preserving the existing command arguments and assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/meshchat/services/device_profile.py`:
- Around line 24-29: Update the profile backup flow around the temporary
“.cfg.partial” path to create a randomized temporary file inside
destination.parent with owner-only permissions, avoiding predictable names and
symlink races; write the raw configuration to that securely created file, then
atomically replace destination and clean up the temporary file.
- Line 16: Update src/meshchat/services/device_profile.py:16 and the
backup/restore logic at src/meshchat/services/device_profile.py:64-84 to use
APIs and DeviceProfile fields supported by the declared Meshtastic minimum
version, or raise that minimum version to one providing export_profile,
canned_messages, ringtone, and fixed_position. Update
tests/test_device_profile.py:10-12 and tests/test_device_profile.py:22-28 to
match the selected supported API and schema.

In `@src/meshchat/services/firmware.py`:
- Around line 398-410: Update probe_device so get-security-info is optional for
unsupported chips, allowing FirmwareError from that command without aborting the
probe. Ensure the radio reset is always performed by moving the final hard-reset
invocation into a finally block, while preserving the existing read-mac and
flash-id commands and completion output.

In `@src/meshchat/ui/device/device_page.py`:
- Around line 502-508: Update _save_text to catch OSError around opening and
writing the selected report file, and show the same user-facing warning used by
MainWindow._export_nodes. Keep the existing filename extension handling and
successful-write behavior unchanged.
- Around line 551-553: Update _serial_error to report every error other than
NoError without requiring self._serial.isOpen(). For fatal serial errors such as
ResourceError, also reset the console controls by enabling _console_start and
disabling _console_stop, matching the stopped state.

In `@tests/test_firmware_service.py`:
- Around line 351-362: Update
test_installed_esptool_accepts_commands_used_by_flasher so each esptool.main
help invocation is wrapped in pytest.raises(SystemExit), asserting the captured
exit code is 0 or None while preserving the existing command coverage and
arguments.

---

Nitpick comments:
In `@src/meshchat/controllers/firmware_controller.py`:
- Around line 71-76: Move the pathlib.Path import from inside the backup method
to module scope, alongside the existing imports, and keep backup using the same
Path symbol when calling backup_flash.

In `@src/meshchat/services/firmware.py`:
- Around line 359-371: The _run_esptool flow uses process-wide stream
redirection; confirm firmware operations prevent other threads from printing, or
replace redirect_stdout/redirect_stderr with an esptool-specific output hook. In
_LineWriter, add isatty() and an encoding attribute so redirected consumers can
query normal stream properties without AttributeError. Apply the changes at
src/meshchat/services/firmware.py lines 359-371 and 300-317.
- Around line 359-371: The firmware invocation around esptool.main currently
redirects process-wide stdout and stderr, allowing concurrent writes from
unrelated threads to reach _LineWriter unsafely. Replace
redirect_stdout/redirect_stderr with esptool’s supported instance-specific
output hook or equivalent, wiring it to writer while preserving the existing
arguments, exception conversion, and final flush behavior.

In `@src/meshchat/ui/device/device_page.py`:
- Around line 331-341: Update _build_firmware_tab to store the created Firmware
tab’s index, then revise set_connected to iterate over the actual tab count
while skipping that stored index; remove the hardcoded min(3, ...) assumption
and continue applying the existing enabled state to all other tabs.

In `@src/meshchat/ui/main_window.py`:
- Around line 666-696: Route _on_firmware_flash_requested and
_on_serial_console_requested through _release_for_firmware instead of
duplicating connection validation, _flash_port assignment, supervisor
cancellation, and status updates. Preserve each handler’s existing
pending-operation assignment and subsequent controller disconnect, using the
helper’s success result to guard those steps.
- Around line 624-645: Guard each firmware operation request handler, including
_on_firmware_probe_requested, _on_firmware_backup_requested, and
_on_serial_console_requested, by rejecting the request when any pending
operation field is already set. Preserve the existing pending-operation
scheduling and clear the new request without overwriting the queued one; apply
the equivalent guard in _release_for_firmware as requested.

In `@tests/test_firmware_service.py`:
- Around line 341-348: Update the expected command assertion in the backup_flash
test to derive the partial path from destination.resolve(), matching the path
resolution performed by backup_flash while preserving the existing command
arguments and assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b3fd122a-b170-4be1-b0b6-84940dcecb8e

📥 Commits

Reviewing files that changed from the base of the PR and between b580b89 and 705d54e.

📒 Files selected for processing (10)
  • packaging/orcmesh.spec
  • src/meshchat/controllers/firmware_controller.py
  • src/meshchat/controllers/meshtastic_controller.py
  • src/meshchat/services/device_profile.py
  • src/meshchat/services/firmware.py
  • src/meshchat/ui/device/device_page.py
  • src/meshchat/ui/main_window.py
  • tests/test_device_page.py
  • tests/test_device_profile.py
  • tests/test_firmware_service.py



def save_profile(interface, destination: Path) -> str:
from meshtastic.__main__ import export_profile

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the repository declaration that selects the Meshtastic version.
fd -HI -t f . | rg '/?(pyproject\.toml|requirements[^/]*\.txt|poetry\.lock|uv\.lock|Pipfile(\.lock)?)$' |
while IFS= read -r file; do
  rg -n -i 'meshtastic' "$file" || true
done

# Inspect Meshtastic 2.5.0 without importing or executing package code.
wheel="$(mktemp)"
curl -fsSL \
  'https://files.pythonhosted.org/packages/py3/m/meshtastic/meshtastic-2.5.0-py3-none-any.whl' \
  -o "$wheel"

echo '--- exported profile functions ---'
unzip -p "$wheel" meshtastic/__main__.py |
  rg -n '^def (export_profile|export_config)\b' || true

echo '--- DeviceProfile fields ---'
unzip -p "$wheel" meshtastic/protobuf/clientonly_pb2.py |
  rg -n 'fixed_position|ringtone|canned_messages|DeviceProfile' || true

rm -f "$wheel"

Repository: hardcoreerik/OrcMesh

Length of output: 1295


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- dependency declarations ---'
rg -n -i -C 2 'meshtastic' pyproject.toml requirements\*.txt 2>/dev/null || true

echo '--- device profile service ---'
cat -n src/meshchat/services/device_profile.py | sed -n '1,120p'

echo '--- device profile tests ---'
cat -n tests/test_device_profile.py | sed -n '1,180p'

echo '--- profile API references ---'
rg -n -C 2 'export_profile|export_config|fixed_position|ringtone|canned_messages' src tests

Repository: hardcoreerik/OrcMesh

Length of output: 17426


Raise the Meshtastic minimum version or remove unsupported profile APIs.

meshtastic>=2.5.0,<3 permits 2.5.0, which defines export_config, not export_profile. Its DeviceProfile schema lacks canned_messages, ringtone, and fixed_position. Backup, restore, and the first two profile tests fail with this supported dependency version.

📍 Affects 2 files
  • src/meshchat/services/device_profile.py#L16-L16 (this comment)
  • src/meshchat/services/device_profile.py#L64-L84
  • tests/test_device_profile.py#L10-L12
  • tests/test_device_profile.py#L22-L28
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/meshchat/services/device_profile.py` at line 16, Update
src/meshchat/services/device_profile.py:16 and the backup/restore logic at
src/meshchat/services/device_profile.py:64-84 to use APIs and DeviceProfile
fields supported by the declared Meshtastic minimum version, or raise that
minimum version to one providing export_profile, canned_messages, ringtone, and
fixed_position. Update tests/test_device_profile.py:10-12 and
tests/test_device_profile.py:22-28 to match the selected supported API and
schema.

Comment on lines +24 to +29
partial = destination.with_suffix(".cfg.partial")
try:
partial.write_bytes(raw)
os.replace(partial, destination)
finally:
partial.unlink(missing_ok=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Protect profile backups from disclosure and path races.

Lines 24-29 write sensitive radio configuration to a predictable partial path with the process default permissions. If the selected directory is shared, another local user can read the partial file or replace it with a symlink before write_bytes() runs.

Create a random temporary file with owner-only permissions in destination.parent. Then atomically replace the destination.

Proposed fix
+import tempfile
+
-    partial = destination.with_suffix(".cfg.partial")
+    fd, partial_name = tempfile.mkstemp(
+        dir=destination.parent,
+        prefix=f".{destination.stem}.",
+        suffix=".cfg.partial",
+    )
+    partial = Path(partial_name)
     try:
-        partial.write_bytes(raw)
+        with os.fdopen(fd, "wb") as profile_file:
+            profile_file.write(raw)
         os.replace(partial, destination)
     finally:
         partial.unlink(missing_ok=True)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
partial = destination.with_suffix(".cfg.partial")
try:
partial.write_bytes(raw)
os.replace(partial, destination)
finally:
partial.unlink(missing_ok=True)
import tempfile
fd, partial_name = tempfile.mkstemp(
dir=destination.parent,
prefix=f".{destination.stem}.",
suffix=".cfg.partial",
)
partial = Path(partial_name)
try:
with os.fdopen(fd, "wb") as profile_file:
profile_file.write(raw)
os.replace(partial, destination)
finally:
partial.unlink(missing_ok=True)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/meshchat/services/device_profile.py` around lines 24 - 29, Update the
profile backup flow around the temporary “.cfg.partial” path to create a
randomized temporary file inside destination.parent with owner-only permissions,
avoiding predictable names and symlink races; write the raw configuration to
that securely created file, then atomically replace destination and clean up the
temporary file.

Comment on lines +398 to +410
def probe_device(
port: str,
expected_usb: tuple[int | None, int | None, str | None] | None = None,
output: Callable[[str], None] = lambda _line: None,
) -> None:
_verify_usb(port, expected_usb)
port = _bootloader_port("auto", port, expected_usb, output, True)
for command in ("read-mac", "flash-id"):
_run_esptool("auto", port, [command], output, "no-reset")
_run_esptool(
"auto", port, ["get-security-info"], output, "no-reset", "hard-reset"
)
output("Read-only device probe completed.")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect the supported platform-to-chip map and probe tests.
set -euo pipefail

rg -n -A10 '_CHIP_BY_PLATFORM\s*=' src/meshchat/services/firmware.py
rg -n -C5 'get-security-info' src tests

Repository: hardcoreerik/OrcMesh

Length of output: 2661


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- firmware helpers and probe ---'
sed -n '1,90p;250,420p' src/meshchat/services/firmware.py

printf '%s\n' '--- completion reconnect path ---'
rg -n -C8 '_on_firmware_completed|probe_device|hard-reset|_run_esptool' src/meshchat tests/test_firmware_service.py

printf '%s\n' '--- dependency and esptool version declarations ---'
rg -n -C3 'esptool|ESP32|esp32s3|platform' pyproject.toml requirements*.txt setup.cfg setup.py src tests 2>/dev/null || true

Repository: hardcoreerik/OrcMesh

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

path = Path("src/meshchat/services/firmware.py")
tree = ast.parse(path.read_text())
probe = next(
    node for node in tree.body
    if isinstance(node, ast.FunctionDef) and node.name == "probe_device"
)
calls = []
for node in ast.walk(probe):
    if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
        if node.func.id == "_run_esptool":
            args = [ast.unparse(arg) for arg in node.args]
            calls.append((node.lineno, args))
print("probe calls in source order:")
for item in sorted(calls):
    print(item)
print("_CHIP_BY_PLATFORM:", next(
    ast.literal_eval(node.value)
    for node in tree.body
    if isinstance(node, ast.Assign)
    and any(isinstance(target, ast.Name) and target.id == "_CHIP_BY_PLATFORM"
            for target in node.targets)
))
PY

Repository: hardcoreerik/OrcMesh

Length of output: 383


🌐 Web query:

esptool 5 get-security-info command supported chips ESP32 original ESP32

💡 Result:

The get-security-info command in esptool is not supported on the original ESP32 [1]. This command was introduced for later Espressif chips (beginning with the ESP32-S2) and is specifically designed to interact with the ROM bootloader's security features [2][1][3]. Official esptool documentation and source code confirm that it is supported on ESP32-S2, ESP32-S3, ESP32-C3, ESP32-C6, and other newer devices, but explicitly excludes the original ESP32 and ESP8266 [4][1][5]. Attempting to run this command on an original ESP32 will result in an error, as the ROM on that chip does not implement the necessary command (0x14) [2][6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import ast
from pathlib import Path

tree = ast.parse(Path("src/meshchat/services/firmware.py").read_text())
probe = next(
    node for node in tree.body
    if isinstance(node, ast.FunctionDef) and node.name == "probe_device"
)
calls = []
for node in ast.walk(probe):
    if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
        if node.func.id == "_run_esptool":
            calls.append((node.lineno, [ast.unparse(arg) for arg in node.args]))
print("probe calls in source order:")
for item in sorted(calls):
    print(item)

mapping = next(
    ast.literal_eval(node.value)
    for node in tree.body
    if isinstance(node, ast.Assign)
    and any(
        isinstance(target, ast.Name) and target.id == "_CHIP_BY_PLATFORM"
        for target in node.targets
    )
)
print("_CHIP_BY_PLATFORM:", mapping)
PY

Repository: hardcoreerik/OrcMesh

Length of output: 383


Always reset the radio after the probe

get-security-info is unsupported on the original ESP32. Because --chip auto allows this path, _run_esptool raises FirmwareError before the configured hard-reset, leaving the radio in download mode. Treat this command as optional and put the final reset in a finally block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/meshchat/services/firmware.py` around lines 398 - 410, Update
probe_device so get-security-info is optional for unsupported chips, allowing
FirmwareError from that command without aborting the probe. Ensure the radio
reset is always performed by moving the final hard-reset invocation into a
finally block, while preserving the existing read-mac and flash-id commands and
completion output.

Comment on lines +502 to +508
def _save_text(self, title: str, suggested: str, text: str) -> None:
destination, _ = QFileDialog.getSaveFileName(self, title, suggested, "Text files (*.txt)")
if destination:
if not destination.lower().endswith(".txt"):
destination += ".txt"
with open(destination, "w", encoding="utf-8") as output:
output.write(text)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Handle OSError when the report file is written.

_save_text calls open() without error handling. If the selected path is not writable, if the disk is full, or if the file is locked, OSError propagates out of a Qt slot. PySide6 then reports an unhandled exception, and the user sees no explanation. MainWindow._export_nodes in src/meshchat/ui/main_window.py already catches OSError and shows a warning; apply the same handling here.

The static analysis path-traversal hint does not apply. The path comes from a local QFileDialog choice, not from a request.

🛡️ Proposed fix
     def _save_text(self, title: str, suggested: str, text: str) -> None:
         destination, _ = QFileDialog.getSaveFileName(self, title, suggested, "Text files (*.txt)")
-        if destination:
-            if not destination.lower().endswith(".txt"):
-                destination += ".txt"
-            with open(destination, "w", encoding="utf-8") as output:
-                output.write(text)
+        if not destination:
+            return
+        if not destination.lower().endswith(".txt"):
+            destination += ".txt"
+        try:
+            with open(destination, "w", encoding="utf-8") as output:
+                output.write(text)
+        except OSError as exc:
+            QMessageBox.warning(self, title, f"Could not write the file:\n{exc}")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _save_text(self, title: str, suggested: str, text: str) -> None:
destination, _ = QFileDialog.getSaveFileName(self, title, suggested, "Text files (*.txt)")
if destination:
if not destination.lower().endswith(".txt"):
destination += ".txt"
with open(destination, "w", encoding="utf-8") as output:
output.write(text)
def _save_text(self, title: str, suggested: str, text: str) -> None:
destination, _ = QFileDialog.getSaveFileName(self, title, suggested, "Text files (*.txt)")
if not destination:
return
if not destination.lower().endswith(".txt"):
destination += ".txt"
try:
with open(destination, "w", encoding="utf-8") as output:
output.write(text)
except OSError as exc:
QMessageBox.warning(self, title, f"Could not write the file:\n{exc}")
🧰 Tools
🪛 ast-grep (0.45.1)

[warning] 506-506: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(destination, "w", encoding="utf-8")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/meshchat/ui/device/device_page.py` around lines 502 - 508, Update
_save_text to catch OSError around opening and writing the selected report file,
and show the same user-facing warning used by MainWindow._export_nodes. Keep the
existing filename extension handling and successful-write behavior unchanged.

Source: Linters/SAST tools

Comment on lines +551 to +553
def _serial_error(self, error) -> None:
if error != QSerialPort.SerialPortError.NoError and self._serial.isOpen():
self._serial_log.append(f"\nSERIAL ERROR: {self._serial.errorString()}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Report serial errors even after Qt closes the port.

_serial_error only appends a message when self._serial.isOpen() is true. When the radio is unplugged, Qt reports ResourceError and can close the device before the slot runs. The user then sees no message, and the console stays in the started state because _console_stop remains enabled and _console_start remains disabled. Report every non-NoError value, and reset the console state on a fatal error.

🐛 Proposed fix
     def _serial_error(self, error) -> None:
-        if error != QSerialPort.SerialPortError.NoError and self._serial.isOpen():
-            self._serial_log.append(f"\nSERIAL ERROR: {self._serial.errorString()}")
+        if error == QSerialPort.SerialPortError.NoError:
+            return
+        self._serial_log.append(f"\nSERIAL ERROR: {self._serial.errorString()}")
+        if error == QSerialPort.SerialPortError.ResourceError:
+            self._stop_serial_console()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _serial_error(self, error) -> None:
if error != QSerialPort.SerialPortError.NoError and self._serial.isOpen():
self._serial_log.append(f"\nSERIAL ERROR: {self._serial.errorString()}")
def _serial_error(self, error) -> None:
if error == QSerialPort.SerialPortError.NoError:
return
self._serial_log.append(f"\nSERIAL ERROR: {self._serial.errorString()}")
if error == QSerialPort.SerialPortError.ResourceError:
self._stop_serial_console()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/meshchat/ui/device/device_page.py` around lines 551 - 553, Update
_serial_error to report every error other than NoError without requiring
self._serial.isOpen(). For fatal serial errors such as ResourceError, also reset
the console controls by enabling _console_start and disabling _console_stop,
matching the stopped state.

Comment on lines 351 to +362
def test_installed_esptool_accepts_commands_used_by_flasher():
esptool = pytest.importorskip("esptool")
assert int(version("esptool").split(".", 1)[0]) >= 5
for command in ("chip-id", "erase-flash", "write-flash"):
esptool.main([command, "--help"])
prefix = [
"--verbose", "--chip", "esp32s3", "--port", "COM8", "--baud", "115200",
"--before", "default-reset", "--after", "no-reset",
]
for command in (
"chip-id", "read-mac", "flash-id", "get-security-info",
"read-flash", "erase-flash", "write-flash",
):
esptool.main([*prefix, command, "--help"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does esptool 5.0 main() raise SystemExit when passed --help?

💡 Result:

Yes, esptool 5.0's main function raises a SystemExit exception when passed the --help argument [1][2]. In esptool 5.0, the main function processes command-line arguments using the argparse library [2]. By default, argparse handles --help by printing the help message and calling sys.exit [2]. In the esptool codebase, this behavior propagates as a SystemExit exception [1]. Because of this, invoking esptool.main(['--help']) within a Python script will terminate the parent process unless the exception is explicitly caught by the caller [3][1]. This behavior has been a known point of frustration for users integrating esptool programmatically, leading to pull requests seeking to prevent this exit [3]. While some users have requested changes to stop this process exit, it remains standard behavior in the 5.0 release [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(tests/test_firmware_service\.py|.*firmware.*service.*|.*esptool.*)$' || true
printf '%s\n' '--- relevant symbols and references ---'
rg -n -C 5 'def test_installed_esptool_accepts_commands_used_by_flasher|esptool\.main|SystemExit|_run_esptool|version\("esptool"\)' . --glob '!node_modules' --glob '!build' --glob '!dist'

Repository: hardcoreerik/OrcMesh

Length of output: 10735


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- esptool dependency constraints ---'
rg -n -C 3 'esptool|pytest' pyproject.toml requirements*.txt setup.cfg tox.ini .github 2>/dev/null || true
printf '%s\n' '--- relevant implementation ---'
sed -n '341,372p' src/meshchat/services/firmware.py
printf '%s\n' '--- test imports and nearby fixtures ---'
sed -n '1,35p' tests/test_firmware_service.py
sed -n '345,366p' tests/test_firmware_service.py
printf '%s\n' '--- standalone argparse behavior probe ---'
python3 - <<'PY'
import argparse
import sys

parser = argparse.ArgumentParser(prog="esptool")
parser.add_argument("--verbose", action="store_true")
try:
    parser.parse_args(["--help"])
except SystemExit as exc:
    print(f"argparse --help raises SystemExit with code={exc.code!r}")
    print(f"SystemExit subclasses BaseException={isinstance(exc, BaseException)!r}")
PY

Repository: hardcoreerik/OrcMesh

Length of output: 5909


Catch SystemExit from esptool help commands

esptool.main(..., "--help") raises SystemExit(0). Wrap each call with pytest.raises(SystemExit) and assert that the exit code is 0 or None.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_firmware_service.py` around lines 351 - 362, Update
test_installed_esptool_accepts_commands_used_by_flasher so each esptool.main
help invocation is wrapped in pytest.raises(SystemExit), asserting the captured
exit code is 0 or None while preserving the existing command coverage and
arguments.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant