From 2844efa5593c0496b30b38650ad531b3c01bd21c Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Fri, 28 Aug 2026 19:15:43 +0200 Subject: [PATCH] Log probe measurements across power cycles --- .env.example | 4 ++++ docs/DIGITAL_PROBE.md | 10 ++++++++++ firmware/app/digital_probe.py | 34 ++++++++++++++++++++-------------- firmware/app/main.py | 32 ++++++++++++++++++++++++++++++-- firmware/manifest.json | 6 +++--- 5 files changed, 67 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index 65db35e..5c72cdf 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,10 @@ PROBE_FREQUENCY_SECONDS=2 PROBE_PULSE_TIMEOUT_US=1000000 PROBE_WATCH_SECONDS=5 PROBE_WATCH_MAX_EVENTS=200 +# Appends every probe line to this file; enable only for short capture sessions +# because frequent flash writes consume storage and contribute to flash wear. +PROBE_LOG_ENABLED=false +PROBE_LOG_PATH=/probe_result.txt # ---------- Self-update ---------- UPDATE_ENABLED=true diff --git a/docs/DIGITAL_PROBE.md b/docs/DIGITAL_PROBE.md index f41f3ca..8bd8e02 100644 --- a/docs/DIGITAL_PROBE.md +++ b/docs/DIGITAL_PROBE.md @@ -49,6 +49,8 @@ PROBE_GPIO=3 PROBE_MODE=frequency PROBE_FREQUENCY_SECONDS=2 PROBE_INTERVAL_SECONDS=3 +PROBE_LOG_ENABLED=true +PROBE_LOG_PATH=/probe_result.txt ``` `PROBE_MODE` may be `state`, `frequency`, `pulse`, or `watch`. The corresponding @@ -57,6 +59,14 @@ or event cap. The mode continuously writes its results to the serial console; it does not expose any output pin or machine-control capability. Set `PROBE_ENABLED=false` and reset to return to normal application operation. +With `PROBE_LOG_ENABLED=true`, every line emitted by the probe is also appended +to `/probe_result.txt`. A `===== POWER CYCLE / PROBE START =====` header is +written at each probe-mode boot, making separate test sessions easy to find. +Retrieve the file later with `mpremote connect COM3 fs cp :/probe_result.txt +probe_result.txt`. Logging opens and closes the file for each line to preserve +data across unexpected power loss; use it for bounded test sessions because +frequent flash writes use storage and contribute to flash wear. + `frequency()` counts rising edges in its measurement window. It is useful for signal discovery and approximate rates, but very fast or noisy signals can overflow or exceed MicroPython IRQ handling capacity. `pulse()` reports one diff --git a/firmware/app/digital_probe.py b/firmware/app/digital_probe.py index 8f7d418..3bac5e6 100644 --- a/firmware/app/digital_probe.py +++ b/firmware/app/digital_probe.py @@ -12,13 +12,19 @@ import time class DigitalProbe: """Measure level, rising-edge frequency, pulse widths, and transitions.""" - def __init__(self, pin_id): + def __init__(self, pin_id, reporter=None): self.pin_id = int(pin_id) + self.reporter = reporter # Do not enable a pull resistor: the observed circuit must determine # the level, and the probe must not intentionally bias it. self.signal = Pin(self.pin_id, Pin.IN) self._edge_count = 0 + def _emit(self, message): + print(message) + if self.reporter is not None: + self.reporter(message) + def _on_rising_edge(self, _pin): # Keep IRQ work deliberately minimal. Do not print or allocate here. self._edge_count += 1 @@ -26,7 +32,7 @@ class DigitalProbe: def state(self): """Print and return the present digital level (0 or 1).""" value = self.signal.value() - print("GPIO%d = %d" % (self.pin_id, value)) + self._emit("GPIO%d = %d" % (self.pin_id, value)) return value def frequency(self, seconds=2): @@ -46,7 +52,7 @@ class DigitalProbe: elapsed_ms = max(1, time.ticks_diff(time.ticks_ms(), start)) count = self._edge_count hz = (count * 1000.0) / elapsed_ms - print("Freq: %.2f Hz (%d rising edges / %.3fs)" % ( + self._emit("Freq: %.2f Hz (%d rising edges / %.3fs)" % ( hz, count, elapsed_ms / 1000.0)) return hz @@ -63,13 +69,13 @@ class DigitalProbe: high_us = time_pulse_us(self.signal, 1, timeout_us) low_us = time_pulse_us(self.signal, 0, timeout_us) if high_us < 0 or low_us < 0: - print("No complete pulse: HIGH=%d LOW=%d" % (high_us, low_us)) - print("-2 = level did not arrive; -1 = pulse did not end in time") + self._emit("No complete pulse: HIGH=%d LOW=%d" % (high_us, low_us)) + self._emit("-2 = level did not arrive; -1 = pulse did not end in time") return None period_us = high_us + low_us if period_us <= 0: - print("No complete pulse") + self._emit("No complete pulse") return None hz = 1_000_000.0 / period_us @@ -81,8 +87,8 @@ class DigitalProbe: "frequency_hz": hz, "duty_percent": duty, } - print("HIGH: %dus LOW: %dus" % (high_us, low_us)) - print("Freq: %.2f Hz Duty: %.1f%%" % (hz, duty)) + self._emit("HIGH: %dus LOW: %dus" % (high_us, low_us)) + self._emit("Freq: %.2f Hz Duty: %.1f%%" % (hz, duty)) return result def watch(self, seconds=5, max_events=200): @@ -108,18 +114,18 @@ class DigitalProbe: if value == last: continue now_us = time.ticks_us() - print("%9dus %d" % (time.ticks_diff(now_us, start_us), value)) + self._emit("%9dus %d" % (time.ticks_diff(now_us, start_us), value)) last = value events += 1 if events >= max_events: - print("Capture stopped after %d events" % max_events) + self._emit("Capture stopped after %d events" % max_events) break return events -def open_probe(pin_id): +def open_probe(pin_id, reporter=None): """Create a probe and print the interactive REPL commands.""" - probe = DigitalProbe(pin_id) - print("ESP32-C3 read-only logic probe on GPIO%d" % probe.pin_id) - print("Use: probe.state(), probe.frequency(), probe.pulse(), probe.watch()") + probe = DigitalProbe(pin_id, reporter) + probe._emit("ESP32-C3 read-only logic probe on GPIO%d" % probe.pin_id) + probe._emit("Use: probe.state(), probe.frequency(), probe.pulse(), probe.watch()") return probe diff --git a/firmware/app/main.py b/firmware/app/main.py index c5c0d83..2659a54 100644 --- a/firmware/app/main.py +++ b/firmware/app/main.py @@ -26,6 +26,27 @@ def as_int(value, default=0, minimum=None): return result +def make_probe_reporter(env): + """Return a durable line logger, or None when probe logging is disabled.""" + if not as_bool(env.get("PROBE_LOG_ENABLED"), False): + return None + path = env.get("PROBE_LOG_PATH", "/probe_result.txt").strip() + if not path: + raise RuntimeError("PROBE_LOG_ENABLED=true requires PROBE_LOG_PATH") + + def report(line): + try: + # Open/close each line so an unexpected power loss preserves all + # previously reported samples. + with open(path, "a") as f: + f.write(line) + f.write("\n") + except Exception as exc: + print("[probe] log write failed:", exc) + + return report + + async def run_probe_mode(ctx): """Continuously collect read-only probe data while the app keeps running.""" env = ctx["env"] @@ -42,7 +63,11 @@ async def run_probe_mode(ctx): raise RuntimeError("Unknown PROBE_MODE=" + mode) from app.digital_probe import open_probe - probe = open_probe(pin_id) + reporter = make_probe_reporter(env) + if reporter is not None: + reporter("===== POWER CYCLE / PROBE START =====") + reporter("GPIO%d mode=%s" % (pin_id, mode)) + probe = open_probe(pin_id, reporter) frequency_seconds = as_int(env.get("PROBE_FREQUENCY_SECONDS"), 2, 1) watch_seconds = as_int(env.get("PROBE_WATCH_SECONDS"), 5, 1) watch_events = as_int(env.get("PROBE_WATCH_MAX_EVENTS"), 200, 1) @@ -50,7 +75,10 @@ async def run_probe_mode(ctx): interval_seconds = as_int(env.get("PROBE_INTERVAL_SECONDS"), 3, 1) activity = ctx["activity"] - print("[probe] mode=%s interval=%ss" % (mode, interval_seconds)) + mode_line = "[probe] mode=%s interval=%ss" % (mode, interval_seconds) + print(mode_line) + if reporter is not None: + reporter(mode_line) while True: # Measurements are data collection, not a machine action, but marking # activity prevents an OTA reboot from interrupting a capture. diff --git a/firmware/manifest.json b/firmware/manifest.json index 6eeea5c..1d66213 100644 --- a/firmware/manifest.json +++ b/firmware/manifest.json @@ -1,6 +1,6 @@ { "schema": 1, - "release": "0.1.3-probe.3", + "release": "0.1.4-probe-log.1", "files": [ { "source": "firmware/launcher.py", @@ -15,12 +15,12 @@ { "source": "firmware/app/digital_probe.py", "target": "/app/digital_probe.py", - "sha256": "3d34db776bb39b5ca3a6628df2c73b07079687eaf84dc4d5c97a149064dfe409" + "sha256": "04b0b904b11da9cad37eb071aae9bb9a26fe38155975cd94b302448ecc4d8b24" }, { "source": "firmware/app/main.py", "target": "/app/main.py", - "sha256": "7eaa8b4b8f213f44a080c82a1f76c8981b06458fa4b05e239a7d6c8cdc114c18" + "sha256": "1cf80e162597ca1fc18573ca35b4473cc33ab5f7a19bc5dcb39fdd6c367f9169" } ] }