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_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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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"
}
]
}