Log probe measurements across power cycles
This commit is contained in:
parent
efdb8ec3fe
commit
2844efa559
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user