Log probe measurements across power cycles

This commit is contained in:
Franz Rolfsvaag 2026-08-28 19:15:43 +02:00
parent efdb8ec3fe
commit 2844efa559
5 changed files with 67 additions and 19 deletions

View File

@ -52,6 +52,10 @@ PROBE_FREQUENCY_SECONDS=2
PROBE_PULSE_TIMEOUT_US=1000000 PROBE_PULSE_TIMEOUT_US=1000000
PROBE_WATCH_SECONDS=5 PROBE_WATCH_SECONDS=5
PROBE_WATCH_MAX_EVENTS=200 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 ---------- # ---------- Self-update ----------
UPDATE_ENABLED=true UPDATE_ENABLED=true

View File

@ -49,6 +49,8 @@ PROBE_GPIO=3
PROBE_MODE=frequency PROBE_MODE=frequency
PROBE_FREQUENCY_SECONDS=2 PROBE_FREQUENCY_SECONDS=2
PROBE_INTERVAL_SECONDS=3 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 `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 it does not expose any output pin or machine-control capability. Set
`PROBE_ENABLED=false` and reset to return to normal application operation. `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 `frequency()` counts rising edges in its measurement window. It is useful for
signal discovery and approximate rates, but very fast or noisy signals can signal discovery and approximate rates, but very fast or noisy signals can
overflow or exceed MicroPython IRQ handling capacity. `pulse()` reports one overflow or exceed MicroPython IRQ handling capacity. `pulse()` reports one

View File

@ -12,13 +12,19 @@ import time
class DigitalProbe: class DigitalProbe:
"""Measure level, rising-edge frequency, pulse widths, and transitions.""" """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.pin_id = int(pin_id)
self.reporter = reporter
# Do not enable a pull resistor: the observed circuit must determine # Do not enable a pull resistor: the observed circuit must determine
# the level, and the probe must not intentionally bias it. # the level, and the probe must not intentionally bias it.
self.signal = Pin(self.pin_id, Pin.IN) self.signal = Pin(self.pin_id, Pin.IN)
self._edge_count = 0 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): def _on_rising_edge(self, _pin):
# Keep IRQ work deliberately minimal. Do not print or allocate here. # Keep IRQ work deliberately minimal. Do not print or allocate here.
self._edge_count += 1 self._edge_count += 1
@ -26,7 +32,7 @@ class DigitalProbe:
def state(self): def state(self):
"""Print and return the present digital level (0 or 1).""" """Print and return the present digital level (0 or 1)."""
value = self.signal.value() value = self.signal.value()
print("GPIO%d = %d" % (self.pin_id, value)) self._emit("GPIO%d = %d" % (self.pin_id, value))
return value return value
def frequency(self, seconds=2): def frequency(self, seconds=2):
@ -46,7 +52,7 @@ class DigitalProbe:
elapsed_ms = max(1, time.ticks_diff(time.ticks_ms(), start)) elapsed_ms = max(1, time.ticks_diff(time.ticks_ms(), start))
count = self._edge_count count = self._edge_count
hz = (count * 1000.0) / elapsed_ms 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)) hz, count, elapsed_ms / 1000.0))
return hz return hz
@ -63,13 +69,13 @@ class DigitalProbe:
high_us = time_pulse_us(self.signal, 1, timeout_us) high_us = time_pulse_us(self.signal, 1, timeout_us)
low_us = time_pulse_us(self.signal, 0, timeout_us) low_us = time_pulse_us(self.signal, 0, timeout_us)
if high_us < 0 or low_us < 0: if high_us < 0 or low_us < 0:
print("No complete pulse: HIGH=%d LOW=%d" % (high_us, low_us)) self._emit("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("-2 = level did not arrive; -1 = pulse did not end in time")
return None return None
period_us = high_us + low_us period_us = high_us + low_us
if period_us <= 0: if period_us <= 0:
print("No complete pulse") self._emit("No complete pulse")
return None return None
hz = 1_000_000.0 / period_us hz = 1_000_000.0 / period_us
@ -81,8 +87,8 @@ class DigitalProbe:
"frequency_hz": hz, "frequency_hz": hz,
"duty_percent": duty, "duty_percent": duty,
} }
print("HIGH: %dus LOW: %dus" % (high_us, low_us)) self._emit("HIGH: %dus LOW: %dus" % (high_us, low_us))
print("Freq: %.2f Hz Duty: %.1f%%" % (hz, duty)) self._emit("Freq: %.2f Hz Duty: %.1f%%" % (hz, duty))
return result return result
def watch(self, seconds=5, max_events=200): def watch(self, seconds=5, max_events=200):
@ -108,18 +114,18 @@ class DigitalProbe:
if value == last: if value == last:
continue continue
now_us = time.ticks_us() 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 last = value
events += 1 events += 1
if events >= max_events: if events >= max_events:
print("Capture stopped after %d events" % max_events) self._emit("Capture stopped after %d events" % max_events)
break break
return events return events
def open_probe(pin_id): def open_probe(pin_id, reporter=None):
"""Create a probe and print the interactive REPL commands.""" """Create a probe and print the interactive REPL commands."""
probe = DigitalProbe(pin_id) probe = DigitalProbe(pin_id, reporter)
print("ESP32-C3 read-only logic probe on GPIO%d" % probe.pin_id) probe._emit("ESP32-C3 read-only logic probe on GPIO%d" % probe.pin_id)
print("Use: probe.state(), probe.frequency(), probe.pulse(), probe.watch()") probe._emit("Use: probe.state(), probe.frequency(), probe.pulse(), probe.watch()")
return probe return probe

View File

@ -26,6 +26,27 @@ def as_int(value, default=0, minimum=None):
return result 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): async def run_probe_mode(ctx):
"""Continuously collect read-only probe data while the app keeps running.""" """Continuously collect read-only probe data while the app keeps running."""
env = ctx["env"] env = ctx["env"]
@ -42,7 +63,11 @@ async def run_probe_mode(ctx):
raise RuntimeError("Unknown PROBE_MODE=" + mode) raise RuntimeError("Unknown PROBE_MODE=" + mode)
from app.digital_probe import open_probe 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) frequency_seconds = as_int(env.get("PROBE_FREQUENCY_SECONDS"), 2, 1)
watch_seconds = as_int(env.get("PROBE_WATCH_SECONDS"), 5, 1) watch_seconds = as_int(env.get("PROBE_WATCH_SECONDS"), 5, 1)
watch_events = as_int(env.get("PROBE_WATCH_MAX_EVENTS"), 200, 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) interval_seconds = as_int(env.get("PROBE_INTERVAL_SECONDS"), 3, 1)
activity = ctx["activity"] 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: while True:
# Measurements are data collection, not a machine action, but marking # Measurements are data collection, not a machine action, but marking
# activity prevents an OTA reboot from interrupting a capture. # activity prevents an OTA reboot from interrupting a capture.

View File

@ -1,6 +1,6 @@
{ {
"schema": 1, "schema": 1,
"release": "0.1.3-probe.3", "release": "0.1.4-probe-log.1",
"files": [ "files": [
{ {
"source": "firmware/launcher.py", "source": "firmware/launcher.py",
@ -15,12 +15,12 @@
{ {
"source": "firmware/app/digital_probe.py", "source": "firmware/app/digital_probe.py",
"target": "/app/digital_probe.py", "target": "/app/digital_probe.py",
"sha256": "3d34db776bb39b5ca3a6628df2c73b07079687eaf84dc4d5c97a149064dfe409" "sha256": "04b0b904b11da9cad37eb071aae9bb9a26fe38155975cd94b302448ecc4d8b24"
}, },
{ {
"source": "firmware/app/main.py", "source": "firmware/app/main.py",
"target": "/app/main.py", "target": "/app/main.py",
"sha256": "7eaa8b4b8f213f44a080c82a1f76c8981b06458fa4b05e239a7d6c8cdc114c18" "sha256": "1cf80e162597ca1fc18573ca35b4473cc33ab5f7a19bc5dcb39fdd6c367f9169"
} }
] ]
} }