From 731f36aa27e3c543562db5f4e2dc3b947319601e Mon Sep 17 00:00:00 2001 From: zackees Date: Thu, 16 Apr 2026 14:37:15 -0700 Subject: [PATCH 1/3] feat(python): add reset_device() to SerialMonitor PyO3 binding Adds a reset_device(board=None) method that calls the daemon's POST /api/reset endpoint to perform platform-specific DTR/RTS reset sequences. Works without __enter__ (uses HTTP, not WebSocket). Closes #50 Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/fbuild-python/src/lib.rs | 57 +++++++++++++++++++ tests/test_serial_reset.py | 27 +++++++++ tests/test_serial_reset_integration.py | 77 ++++++++++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 tests/test_serial_reset.py create mode 100644 tests/test_serial_reset_integration.py diff --git a/crates/fbuild-python/src/lib.rs b/crates/fbuild-python/src/lib.rs index 057365e4..5fa49f72 100644 --- a/crates/fbuild-python/src/lib.rs +++ b/crates/fbuild-python/src/lib.rs @@ -429,6 +429,63 @@ impl SerialMonitor { timeout ))) } + + /// Reset the device via the daemon's DTR/RTS reset endpoint. + /// + /// Sends POST /api/reset to the daemon, which preempts any active + /// serial monitor session, toggles DTR/RTS to reset the device, + /// then clears preemption so monitors can reconnect. + /// + /// Works whether or not __enter__ has been called — the reset goes + /// through the daemon's HTTP API, not the WebSocket session. + /// + /// Args: + /// board: Board identifier (e.g. "esp32s3", "teensy40"). + /// Determines the platform-specific reset sequence. + /// If None, a generic DTR toggle is used. + /// + /// Returns: + /// True if reset succeeded, False otherwise. + #[pyo3(signature = (board=None))] + fn reset_device(&self, board: Option) -> PyResult { + let url = format!("{}/api/reset", fbuild_paths::get_daemon_url()); + + #[derive(Serialize)] + struct ResetPayload { + port: String, + #[serde(skip_serializing_if = "Option::is_none")] + board: Option, + } + + let payload = ResetPayload { + port: self.port.clone(), + board, + }; + + let resp = reqwest::blocking::Client::new() + .post(&url) + .json(&payload) + .timeout(std::time::Duration::from_secs(10)) + .send() + .map_err(|e| { + pyo3::exceptions::PyConnectionError::new_err(format!( + "failed to send reset request to daemon: {}", + e + )) + })?; + + let body: serde_json::Value = resp.json().map_err(|e| { + pyo3::exceptions::PyRuntimeError::new_err(format!( + "failed to parse reset response: {}", + e + )) + })?; + + Ok(body + .get("success") + .and_then(|v| v.as_bool()) + .unwrap_or(false)) + } } impl SerialMonitor { diff --git a/tests/test_serial_reset.py b/tests/test_serial_reset.py new file mode 100644 index 00000000..a34f73d6 --- /dev/null +++ b/tests/test_serial_reset.py @@ -0,0 +1,27 @@ +"""RED tests: SerialMonitor.reset_device() must exist and return bool. + +These tests verify the PyO3 binding exposes reset_device() on SerialMonitor. +They will FAIL until the Rust implementation is added. +""" + +from __future__ import annotations + +def test_serial_monitor_has_reset_device() -> None: + """SerialMonitor must expose a reset_device method.""" + from fbuild._native import SerialMonitor + + assert hasattr(SerialMonitor, "reset_device"), ( + "SerialMonitor is missing reset_device method. " + "Add #[pyo3] fn reset_device(&self, board: Option) -> PyResult " + "to the SerialMonitor impl block in crates/fbuild-python/src/lib.rs" + ) + + +def test_serial_monitor_reset_device_is_callable() -> None: + """reset_device must be callable (not just an attribute).""" + from fbuild._native import SerialMonitor + + mon = SerialMonitor(port="COM13", baud_rate=115200) + assert callable(getattr(mon, "reset_device", None)), ( + "SerialMonitor.reset_device exists but is not callable" + ) diff --git a/tests/test_serial_reset_integration.py b/tests/test_serial_reset_integration.py new file mode 100644 index 00000000..4bfbaf33 --- /dev/null +++ b/tests/test_serial_reset_integration.py @@ -0,0 +1,77 @@ +"""Integration test: reset a real ESP32-S3 via the fbuild daemon. + +Requires: + - fbuild daemon running + - ESP32-S3 device on COM13 + - Device flashed with AutoResearch firmware + +Skip conditions checked at import time. +""" + +from __future__ import annotations + +import time + +import pytest +import serial as pyserial + + +def _port_exists(port: str) -> bool: + """Check if a serial port exists and can be opened.""" + try: + s = pyserial.Serial(port, 115200, timeout=0.1) + s.close() + return True + except (OSError, pyserial.SerialException): + return False + + +def _daemon_running() -> bool: + """Check if the fbuild daemon is reachable.""" + try: + import urllib.request + + urllib.request.urlopen("http://127.0.0.1:8765/health", timeout=2) + return True + except Exception: + return False + + +@pytest.mark.skipif(not _port_exists("COM13"), reason="COM13 not available") +@pytest.mark.skipif(not _daemon_running(), reason="fbuild daemon not running") +def test_reset_esp32s3_and_read_output() -> None: + """After reset_device, a fresh monitor should capture device output.""" + from fbuild._native import SerialMonitor + + # Reset first (no monitor attached — avoids preemption race) + mon = SerialMonitor(port="COM13", baud_rate=115200) + result = mon.reset_device(board="esp32s3") + assert result is True, "reset_device should return True on success" + + # Wait for reboot + time.sleep(3.0) + + # Now attach a fresh monitor and read output + mon2 = SerialMonitor(port="COM13", baud_rate=115200) + mon2.__enter__() + try: + lines = mon2.read_lines(timeout=5.0) + assert len(lines) > 0, ( + "Device should produce serial output after reset. " + "Got 0 lines — device may not have rebooted." + ) + finally: + mon2.__exit__(None, None, None) + + +@pytest.mark.skipif(not _port_exists("COM13"), reason="COM13 not available") +@pytest.mark.skipif(not _daemon_running(), reason="fbuild daemon not running") +def test_reset_device_without_enter() -> None: + """reset_device should work without calling __enter__ first.""" + from fbuild._native import SerialMonitor + + mon = SerialMonitor(port="COM13", baud_rate=115200) + # Should NOT require __enter__ — reset goes through HTTP, not WebSocket + result = mon.reset_device(board="esp32s3") + assert isinstance(result, bool), "reset_device must return a bool" + assert result is True, "reset_device should succeed for a connected ESP32-S3" From a97276b2409658f80d583c38884503530970a039 Mon Sep 17 00:00:00 2001 From: zackees Date: Thu, 16 Apr 2026 14:46:48 -0700 Subject: [PATCH 2/3] fix(tests): address CodeRabbit review feedback - Use port enumeration instead of opening port (avoids daemon conflict) - Use `with` context manager instead of explicit __enter__/__exit__ Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/test_serial_reset_integration.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/tests/test_serial_reset_integration.py b/tests/test_serial_reset_integration.py index 4bfbaf33..fd2f2a3e 100644 --- a/tests/test_serial_reset_integration.py +++ b/tests/test_serial_reset_integration.py @@ -13,17 +13,16 @@ import time import pytest -import serial as pyserial +import serial.tools.list_ports as list_ports def _port_exists(port: str) -> bool: - """Check if a serial port exists and can be opened.""" - try: - s = pyserial.Serial(port, 115200, timeout=0.1) - s.close() - return True - except (OSError, pyserial.SerialException): - return False + """Check if a serial port is enumerated (without opening it). + + Uses pyserial's port enumeration instead of opening the port, + which would conflict with a daemon holding the port open. + """ + return any(info.device == port for info in list_ports.comports()) def _daemon_running() -> bool: @@ -52,16 +51,12 @@ def test_reset_esp32s3_and_read_output() -> None: time.sleep(3.0) # Now attach a fresh monitor and read output - mon2 = SerialMonitor(port="COM13", baud_rate=115200) - mon2.__enter__() - try: + with SerialMonitor(port="COM13", baud_rate=115200) as mon2: lines = mon2.read_lines(timeout=5.0) assert len(lines) > 0, ( "Device should produce serial output after reset. " "Got 0 lines — device may not have rebooted." ) - finally: - mon2.__exit__(None, None, None) @pytest.mark.skipif(not _port_exists("COM13"), reason="COM13 not available") From ad61c02a99ac8f384e447efced3c935b09c59791 Mon Sep 17 00:00:00 2001 From: zackees Date: Thu, 16 Apr 2026 14:50:15 -0700 Subject: [PATCH 3/3] =?UTF-8?q?ci:=20add=20CodeRabbit=20config=20=E2=80=94?= =?UTF-8?q?=20request=5Fchanges=5Fworkflow=20blocks=20unresolved=20reviews?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When enabled, CodeRabbit submits REQUEST_CHANGES reviews instead of just comments. PRs cannot be merged until all review comments are acknowledged and CodeRabbit auto-approves. Co-Authored-By: Claude Opus 4.6 (1M context) --- .coderabbit.yaml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000..dcd2d798 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,4 @@ +reviews: + request_changes_workflow: true + pre_merge_checks: + enabled: true