Skip to content

Claude/raspberry pi noise measurement 6vllbo - #84

Open
yohan2256 wants to merge 46 commits into
mccdaq:masterfrom
yohan2256:claude/raspberry-pi-noise-measurement-6vllbo
Open

Claude/raspberry pi noise measurement 6vllbo#84
yohan2256 wants to merge 46 commits into
mccdaq:masterfrom
yohan2256:claude/raspberry-pi-noise-measurement-6vllbo

Conversation

@yohan2256

Copy link
Copy Markdown

No description provided.

claude and others added 9 commits August 1, 2026 00:06
…veform streaming

Adds a headless setup for using an MCC 172 DAQ HAT on a Raspberry Pi as a
networked sound/vibration probe:

- noise_monitor.py: enables IEPE excitation, applies per-channel sensitivity
  calibration, runs a continuous scan, and streams the raw waveform to
  laptops over TCP (length-prefixed float64 frames + JSON handshake).
  Bounded per-client queues drop oldest blocks so a slow client never
  stalls acquisition.
- config.ini: sample rate, channels, IEPE, per-channel sensitivity (mV/Pa),
  and network settings.
- noise-monitor.service: systemd unit for automatic start at boot.
- laptop_client.py: dependency-light receiver with live RMS/SPL readout and
  optional raw/.npy recording.
- README.md: wiring, install, calibration, and run instructions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
Extend the noise monitor with a full command/control channel so the laptop
can remotely drive every configurable MCC 172 feature and start/stop the
raw-waveform stream, in addition to receiving data.

Server (noise_monitor.py):
- Typed downstream frames: [type][len][payload] with type 0x01 DATA (raw
  interleaved float64) and 0x02 MSG (JSON handshake/responses/events).
- Line-delimited JSON command channel. Controller serializes all device
  access and exposes: start/stop, status, info, get_config, get/set
  sensitivity, get/set IEPE, get/set sample rate + clock source,
  set_channels, set_trigger, set_options, calibration read/write, blink_led,
  and test_signals_write. Config changes are rejected while a scan is active,
  mirroring the hardware.
- Per-client reader/sender threads; DATA and events broadcast to all clients,
  command responses returned to the requester. Slow clients drop oldest
  blocks. Signal handlers guarded for non-main-thread use.
- config.ini gains [control] autostart to keep the boot-time streaming
  behavior while allowing remote control.

Client (laptop_client.py):
- Interactive control shell (help, start/stop, set_sensitivity, set_rate,
  set_iepe, set_channels, trigger, options, calibration, blink, info,
  status, record/stoprec, meter, raw send) plus a live RMS/SPL meter and
  raw/.npy recording, all over the typed-frame protocol.

README updated with the control command table and the v2 wire protocol.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
Separate the two concerns onto two TCP ports so a client can subscribe to
just data, just control, or both:

- Control port (default 5000): newline-delimited JSON, both directions --
  commands, responses, and events. No binary.
- Stream port (default 5001): typed length-prefixed frames -- JSON handshake
  on connect, then the raw waveform (float64), plus events. Upstream bytes
  on this port are ignored.

The ClientRegistry now tracks each client's kind and encodes messages
accordingly (JSON line vs MSG frame); DATA goes only to stream clients,
events broadcast to all, command responses to the requesting control client.
main() runs a control accept loop and a stream accept loop.

Also:
- config.ini: replace [network] port with control_port + stream_port.
- Remove laptop_client.py -- the client is built separately against the spec.
- Add PROTOCOL.md: language-agnostic wire specification (both ports, framing,
  handshake, full command reference, events, reliability notes, examples).
- README rewritten for the two-port model, pointing at PROTOCOL.md.

Verified end-to-end (fake daqhats, both ports): handshakes, command
dispatch, calibration/rate changes, start -> DATA on the stream port,
config-change rejection while running, status, stop + stopped event,
error handling, and stream-port input being safely ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
Stream fractional-octave (1/3-octave by default) band-filtered audio
alongside the raw waveform. Each band is a real-time Butterworth band-pass
filter whose IIR state is carried across blocks, then DECIMATED per band to
just above twice its upper edge, and sent as a new BAND frame (type 0x03) on
the stream port. The laptop does time-weighting (Fast/Slow/Impulse), Leq,
band SPL, and A/C-weighting -- the Pi stays light.

- band_filter.py: BandFilterBank -- designs a Butterworth SOS per band
  (scipy), computes each band's decimation factor and rate, and filters +
  decimates each raw block with exact phase continuity across blocks
  (verified bit-identical to whole-signal filter+decimate).
- noise_monitor.py: TYPE_BAND (0x03) frame = [band_index][channel] +
  decimated float64; build the bank on start from the actual rate/channels;
  emit BAND frames per band/channel/block (empty results skipped); include
  a band_table in the handshake and a new 'started' event so clients learn
  the band layout; set_bands command; stream_raw toggle in set_options to
  send only band frames.
- config.ini: [bands] section (enabled, f_min/f_max, fraction, order,
  decimation_margin) and [acquisition] stream_raw, with a bandwidth warning.
- PROTOCOL.md / README: BAND frame + band_table spec, set_bands, Leq/
  time-weighting math, and the bandwidth reality (full 20 Hz-20 kHz set is
  ~5x the raw stream for 2 ch; lower f_max to fit Wi-Fi).

Band output is optional and disabled by default; it needs numpy + scipy on
the Pi only when enabled. Verified end-to-end (fake daqhats): BAND frames
from both channels, band_table in the started event, and set_bands
validation, in addition to the existing two-port control/stream tests
(26/26).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
…emand metrics

Restructure the monitor around the sound-level-meter model the project
needs: continuously stream light time-weighted levels, keep raw samples
buffered on the Pi, and compute statistics on request.

- slm.py: IEC 61672 A/C/Z frequency weighting (bilinear-transformed analog
  design; verified 0.00 dB @1 kHz, -19.14 dB @100 Hz, +0.96 dB @4 kHz),
  Fast/Slow/Impulse exponential time weighting with transient-free priming,
  and window_metrics() -> Leq, Lmax, Lmin, Lpeak, LN percentiles.
- New stream frames: LEVEL (0x04) = broadband weighted level in dB at a
  configurable output rate (default 10/s, the SLM needle); BAND_LEVEL (0x05)
  = per-band Fast level with the A/C offset applied per band center (octave-
  analyzer bars). Band output mode selects BAND_LEVEL (level, default) or
  the previous decimated BAND waveforms (waveform).
- RawRingBuffer keeps the last [storage] buffer_seconds of raw interleaved
  samples; get_metrics computes Leq/Lmax/Lmin/Lpeak/LN (+ optional per-band
  Leq) over a requested window, while running or after stop, with optional
  weighting overrides per call.
- New commands: set_weighting (A/C/Z, Fast/Slow/Impulse), set_level
  (enable/output_rate), set_storage (buffer_seconds), get_metrics; set_bands
  gains 'output'. stream_raw now defaults to false (levels are the default
  product; raw remains available on demand).
- config.ini: [weighting], [level], [storage] sections and [bands] output.
- PROTOCOL.md/README updated: frame formats, command schemas, metrics
  example, and the SLM behavior overview.

Verified end-to-end with the fake MCC 172 (37/37): LEVEL and BAND_LEVEL
frames on both channels, metrics accuracy (1 kHz sine at 20 Pa -> ~117 dB
Leq, Lmax==Leq steady), LN percentiles, post-stop metrics, and the new
commands, plus all previous control/stream tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
Support a Data Translation DT9837A USB module (4 IEPE channels, uldaq
library) alongside the MCC 172 (2 IEPE channels, daqhats), for 6 channels
total. Protocol bumped to noise-monitor/3.

- devices.py: backend abstraction with Mcc172Backend and Dt9837aBackend
  behind one interface (configure/start/read_new/stop/close, sensitivity
  and IEPE per local channel), plus ChannelMap assigning GLOBAL channel
  numbers in device order (default 0-1 = mcc172, 2-5 = dt9837a). The
  DT9837A backend drives uldaq's IEPE mode, AC coupling, and sensor
  sensitivity (converting the monitor's mV/unit convention to uldaq's
  V/unit) and reads new samples from the continuous-scan circular buffer
  with wrap/overrun detection. Devices listed in config but not attached
  are skipped with a log message.
- noise_monitor.py: Controller manages a list of backends; all frames and
  commands use global channels. DATA frames now carry a 4-byte device
  index (each device blocks separately -- clocks are NOT synchronized
  across devices, which the handshake and docs state explicitly).
  Per-device ring buffers, band banks, and weighting states; LEVEL /
  BAND_LEVEL / BAND frames are keyed by global channel. Handshake gains
  devices, channel_map, and a per-device band_table list. set_channels is
  now per-device (dt9837a requires contiguous channels from 0);
  set_sample_rate applies to all devices and reports per-device actual
  rates; calibration/trigger/test_signals route to the MCC 172 and are
  rejected for DT channels; blink_led can target one or all devices.
- config.ini: [devices] enabled list and per-device [mcc172]/[dt9837a]
  sections (channels, iepe_enable, per-channel sensitivity).
- PROTOCOL.md/README: v3 frame formats, global channel numbering,
  channel_map, per-device band tables, uldaq install steps, and the
  clock-sync caveat.

Verified end-to-end with fake daqhats + fake uldaq (31/31): 6-channel
handshake/channel_map, DATA from both devices, LEVEL and BAND_LEVEL from
all 6 channels, per-device band tables, cross-device get_metrics (~117 dB
on a 20 Pa 1 kHz tone, both devices), per-device set_channels with global
renumbering, DT contiguity validation, MCC-only command routing, and the
existing control/stream behaviors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
RAM-only storage workflow: raw samples are never written to the SD card.
The laptop records via live streaming and/or pulls the buffered window on
demand -- like a sound level meter's event capture.

- RawRingBuffer now stores packed array('d') blocks (8 bytes/sample, 4x
  less RAM than the previous list-of-floats), so buffer_seconds can be
  minutes: default raised 60 -> 300 s (2 ch ~246 MB, 6 ch ~740 MB).
  _acquire packs each block once and shares it between the buffer, DATA
  frames, and the DSP paths.
- New get_raw command + RAW_DUMP frame (type 0x06): dumps the most recent
  N seconds of the per-device ring buffers to all stream clients as
  chunked frames ([dump_id][device][chunk_index][is_last] + float64,
  512 KiB chunks). The control response returns decode metadata per device;
  chunks follow asynchronously and interleave with live frames.
- Dump chunks are delivered reliably (blocking send with per-client
  timeout) instead of the drop-oldest backpressure used for live frames,
  so a post-event get_raw can recover gaps in a live recording.
- get_raw and get_metrics now run outside the device lock -- they only read
  the ring buffers (own lock) and run-frozen config -- so a large dump or
  metrics window can no longer stall acquisition into an overrun.
- config.ini storage comment rewritten with packed RAM math; PROTOCOL.md
  gains §2.5 RAW_DUMP + get_raw reference; README describes the
  storage-free wired-LAN workflow.

Verified end-to-end (mock daqhats + uldaq, 37/37): dump on both devices
while running, chunk ordering, reassembled sizes matching metadata, sample
plausibility, and all previous control/stream/metrics tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
Arm the MCC 172 and DT9837A to begin their scans on a shared rising edge,
then fire that edge from a Raspberry Pi GPIO pin -- so both devices start
on the same pulse (about +-1 sample per device plus each ADC's fixed group
delay). Rising edge only: the DT9837A's external digital trigger supports
no other edge (confirmed in the uldaq source; only the DT9837C has
falling-edge support).

- gpio_trigger.py: GpioTrigger output pin (idle low, clean rising-edge
  pulse) with three access methods tried in order -- libgpiod v2, libgpiod
  v1, RPi.GPIO -- so it works across OS generations including Pi 5/Trixie.
  Validates the pin against the BCM pins the MCC 172 HAT itself uses.
- devices.py: arm_trigger() + start(triggered=) on both backends (MCC 172:
  trigger_config LOCAL/RISING_EDGE + OptionFlags.EXTTRIGGER, previously
  configured but never armed; DT9837A: set_trigger POS_EDGE +
  ScanOption.EXTTRIGGER) and has_triggered() status. The DT9837A read path
  no longer misreports an armed, waiting scan as an overrun.
- noise_monitor.py: [trigger] config (sync_start, source gpio|external,
  gpio_pin, pulse_ms); set_trigger reworked to configure the synchronized
  start (reserved-pin validation included); start() arms all devices,
  broadcasts 'started' (armed) before pulsing so it always precedes the
  first data, fires the GPIO pulse, then polls and reports per-device
  trigger status in the response; 'triggered' event per device on first
  samples (for external edges); status reports per-device triggered;
  GPIO line closed on exit.
- config.ini documents the wiring (GPIO pin -> MCC 172 TRIG + DT9837A Ext
  Trigger, common ground) and the alignment-vs-clock-drift caveat;
  PROTOCOL.md and README updated to match.

Verified end-to-end with mock daqhats/uldaq/RPi.GPIO honoring EXTTRIGGER:
no data until the pulse, exactly one pulse per start, both devices fire,
per-device triggered status and events, reserved-pin rejection, and all
previous tests (45/45, three consecutive runs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
…ement-6vllbo

Add multi-device noise monitor for MCC 172 and DT9837A
@yohan2256

Copy link
Copy Markdown
Author

1

1 similar comment
@yohan2256

Copy link
Copy Markdown
Author

1

claude added 2 commits August 1, 2026 05:29
The level/band computation was the one heavy step and ran inline in the
acquisition thread: 6 ch at 51.2 kHz with 1/3-octave bands to 20 kHz took
~91% of a single Pi 4 core, leaving no headroom. It parallelizes cleanly by
channel, so it now runs in worker processes.

Threads are not usable here -- scipy.signal.sosfilt holds the GIL, and a
thread pool measured 0.47x (slower than serial). Worker processes measured
2.2-2.5x, taking the same workload to ~41% spread over 3 cores.

- dsp_pool.py: plan_workers() splits channels into balanced per-device
  groups (a worker serves one device, since blocks arrive per device);
  DspPool starts the workers, hands blocks over through shared memory
  (sample data is never pickled) and collects ready-to-send frames. Each
  worker owns the filter state for its channels. Slots are recycled per
  block; a lagging worker drops blocks instead of stalling acquisition.
- [dsp] workers config + set_dsp command: -1 auto (cpu_count-1), 0 inline
  (previous behavior, still fully supported), N to cap. The inline path is
  also the automatic fallback if the pool cannot start.
- handshake/get_config expose the pool layout and dropped-block count; the
  per-device band_table is merged back from the workers.

Memory/copy reductions along the whole path:
- Mcc172Backend uses a_in_scan_read_numpy, and Dt9837aBackend wraps the
  uldaq ctypes scan buffer with np.frombuffer, so samples never pass
  through a Python list (8 B/sample instead of ~32, no per-sample boxing).
- The ring buffer stores those float64 arrays directly -- the acquisition
  loop no longer repacks each block -- and returns one concatenated array.
- band_filter gains process_2d(), the zero-copy entry point used by the
  workers (shared-memory view) and by _band_metrics (was converting a numpy
  window to a Python list).
- get_metrics fetches each device's window once instead of once per
  channel, avoiding a repeated multi-hundred-MB concatenation.

Verified end-to-end with mock daqhats/uldaq (the uldaq mock now returns a
ctypes buffer like the real library): 49/49 with the pool (3 workers,
channels [[0,1],[2,3],[4,5]], no dropped blocks) and 47/47 with
workers = 0 on the inline path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
Rename the project from the working title "noise monitor" to PiSLM
(Raspberry Pi Sound Level Meter) across the tree:

- directory examples/python/mcc172/noise_monitor -> pislm
- noise_monitor.py -> pislm.py, noise-monitor.service -> pislm.service
- protocol version string noise-monitor/3 -> pislm/3
- config env var NOISE_MONITOR_CONFIG -> PISLM_CONFIG
- systemd unit name, GPIO consumer string, and all prose references

Add INSTALL.md: a complete field installation manual covering the bill of
materials (and why a Pi 4 rather than a Zero 2 W), hardware assembly and
grounding per the MCC 172 spec, the power budget, the GPIO trigger wiring
with the pins the HAT reserves, OS setup, daqhats and uldaq installation
(including the udev rule for non-root USB access), Python dependencies,
static-IP networking for a direct laptop link, low-power/low-noise tuning,
configuration and end-to-end calibration with an acoustic calibrator
(including the correction formula), first run and systemd enablement, a
pre-session field checklist, and a troubleshooting table. README links to
it for new builds.

Verified after the rename: 49/49 with the DSP pool and 47/47 inline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
@yohan2256
yohan2256 force-pushed the claude/raspberry-pi-noise-measurement-6vllbo branch from acd3cf3 to 087b34c Compare August 1, 2026 05:30
yohan2256 and others added 8 commits August 1, 2026 14:32
…ement-6vllbo

PiSLM: multi-core DSP, memory optimization, and installation manual
Calibration could only be entered as a known mV/Pa value, and only survived
a restart by hand-editing config.ini on the Pi. Both are now laptop
commands, so a microphone can be calibrated later, in the field.

- calibrate: fit an acoustic calibrator, leave the scan running, and send
  {"cmd":"calibrate","channel":0,"level_db":94}. The Pi measures the
  buffered signal, derives the sensitivity that makes that tone read the
  calibrator level, and applies it -- no arithmetic on the client side.
  * A 1/3-octave zero-phase bandpass around the calibrator frequency
    (default 1 kHz, configurable) rejects background noise that would
    otherwise inflate the RMS; disable with bandpass=false.
  * The sensitivity is derived from the RMS ratio rather than a dB
    reference, so it works whether the channel starts calibrated (Pa) or
    not (volts). Verified to recover 50.000 mV/Pa exactly from both.
  * apply=false measures only -- a drift check before a session, where
    change_db is how far the channel has moved.
  * Applying briefly stops and restarts the scan, since sensitivity is a
    stopped-only device setting; the response reports restarted=true.
    calibrate is therefore dispatched without the device lock.
  * measured_level_db is what the current calibration reports for the tone
    (true SPL when calibrated, dBV when not -- see measured_units), so it
    reads back the target level after a successful calibration.
  * A short buffer is used as-is above a 0.1 s floor, with the duration
    actually measured reported, instead of failing outright.

- save_config: writes the current calibration back to config.ini so it
  survives a restart; include_settings=true also persists rate, weighting,
  level rate, buffer, bands, DSP workers, and trigger settings. The new
  update_ini() helper rewrites only the affected lines -- configparser
  would drop every comment, and config.ini is largely documentation --
  appends missing keys to their section, tags each written line with
  "; saved <date>", and replaces the file atomically.

README, PROTOCOL.md, and INSTALL.md §12 updated; the install manual's
calibration procedure is now calibrate-per-channel then one save_config,
replacing the manual correction formula.

Verified end-to-end: 62/62 with the DSP pool, 60/60 inline. Coverage
includes the dry run, apply + restart, the applied value being live and
identical to the reported one, re-calibration converging to <0.1 dB and
reading back 94 dB, rejection of an implausible target, and config.ini
keeping its comments and still parsing after save_config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
The MCC 172 and the DT9837A have independent ADC crystals (each +-50 ppm),
so their streams slip by up to ~100 us/s -- 36 degrees of phase at 1 kHz
after one second. The GPIO trigger aligns the scans' start; nothing kept
them aligned afterwards, and the two devices cannot share a clock.

Note that simply requesting a common rate does not help: the MCC 172 can
only sample at 51200/n, so 48 kHz is unreachable in its hardware, and
resampling to NOMINAL rates only lines both up on the same nominal grid --
the slip is untouched (simulated: phase error swings +-180 deg within
seconds). The fix has to use each device's MEASURED rate.

- clock_sync.ClockTracker estimates a device's true sample rate by
  regressing delivered frame counts against the Pi's monotonic clock. The
  Pi's own clock error cancels in the RATIO between two devices measured
  against the same reference -- verified: a 200 ppm reference bias leaves
  0.05 ppm of ratio error. The window is kept in seconds (points thinned to
  one per 50 ms) because accuracy is set by the time span, not the point
  count: ~6 ppm at 30 s, 2 ppm at 60 s, 0.15 ppm at 300 s. Tracking is
  always on and reported per device under "clock", so a client can correct
  drift offline from get_raw data even with resampling off.

- clock_sync.Resampler converts a stream to a common rate at an arbitrary,
  slowly-varying ratio (windowed-sinc polyphase bank + fractional phase
  accumulator), stateful across blocks. It interpolates between neighbouring
  phase kernels rather than snapping to one, which removes the
  phase-quantisation staircase -- otherwise the dominant high-frequency
  error. Measured against the exact ideal signal, 51.2 kHz -> 48 kHz:
  -130 dB at 100 Hz, -100 dB at 1 kHz, -88 dB at 10 kHz, i.e. at or below
  the MCC 172's own -93 dB THD. Streamed output is bit-identical to
  single-shot to 4e-12. Cost ~20% of one Pi 4 core for 6 channels.

- [resample] config + set_resample command, off by default. When active the
  resampler is retuned from the tracker every 10 s once settled, and
  everything downstream -- ring buffers, DATA, LEVEL, bands, get_metrics,
  get_raw -- runs on the common grid via a new Controller._rate(). Device
  ADC rates are untouched; status reports both actual_rate and
  effective_rate.

README, PROTOCOL.md, INSTALL.md and config.ini document the mechanism, the
convergence figures, and when cross-device phase can be trusted.

Verified: 62/62 with resampling off (pool), 60/60 inline, and a new 13-check
resampling suite covering the advertised config, device rates staying put
while the effective rate becomes 48 kHz, get_raw metadata and payload sizes
on the resampled grid, the 1 kHz tone surviving conversion, metrics on
resampled data, and set_resample validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
Commit to Trixie (64-bit Lite) as the documented target and make the
install actually work there.

- INSTALL.md section 5 now states the OS choice with its reasons: Raspberry
  Pi OS (the daqhats installer calls raspi-config and apt directly, so other
  distributions break), 64-bit (DSP speed, ~740 MB ring buffer), Lite
  (headless), and Trixie specifically -- daqhats selects its GPIO backend
  from pkg-config --modversion libgpiod and builds gpio_v2.c for v2, and
  PiSLM's trigger already probes libgpiod v2 before v1 and RPi.GPIO. Adds a
  post-install check of release, architecture, libgpiod version and Python
  version, and notes Bookworm as the fallback if a build fails.

- Python setup reworked around a virtual environment, because Trixie
  enforces PEP 668 and refuses system-wide pip. numpy/scipy/libgpiod come
  from apt (Debian's builds are optimised for the platform and pip would
  otherwise compile them on the Pi), and the venv is created with
  --system-site-packages so those stay visible while daqhats and uldaq are
  installed into it. Verified that a --system-site-packages venv sees the
  apt scientific stack, and that every module PiSLM imports is still
  present in Python 3.13.

- pislm.service ExecStart now points at the venv interpreter; the system
  python will not have daqhats/uldaq under this scheme.

- uldaq is flagged as the one build not maintained against Trixie, with the
  relaxed-diagnostics workaround for its newer toolchain, and it is called
  out as the decision point for falling back to Bookworm.

- Troubleshooting gains rows for externally-managed-environment, the
  systemd-vs-venv ModuleNotFoundError, and the uldaq build failure.

README's dependency section updated to match. No functional code change;
all suites still pass (62/62 pool, 60/60 inline, 13/13 resampling).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
Raspberry Pi OS no longer creates a default 'pi' account -- Imager makes
you choose a username -- but pislm.service hardcoded User=pi and
/home/pi in four places, and INSTALL.md told the user to hand-edit them.
That is the kind of step people get half-right, and the failure mode
(service starts as the wrong user, or cannot find the venv) is confusing.

- INSTALL.md section 13 now generates the unit from the current login:
  sed rewrites User= and /home/pi to $USER and $HOME, and a grep prints
  the four resulting lines to check before enabling.
- pislm.service comments say the name and home are placeholders and point
  at that step, instead of claiming 'usually pi'. Also fixes a leftover
  comment fragment that had been split by an earlier edit.
- Section 5 gains guidance for the two Imager choices: hostname (pislm,
  or numbered if more than one node will exist, and the character rules)
  and username (no default exists; avoid 'pi'; everything downstream is
  derived from $USER/$HOME).
- Section 7 notes explicitly that the plugdev membership matters because
  the service runs as the login user.

Documentation only; suites unchanged (62/62).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
INSTALL.md section 6 told the reader to clone mccdaq/daqhats, but PiSLM
only exists in this fork -- upstream has no examples/python/mcc172/pislm.
Section 9 then said 'PiSLM lives in the checkout from section 6', so
anyone following the manual would hit an empty directory at exactly that
step. Section 6 now clones this fork, notes to substitute your own if it
differs, and includes the branch checkout (with an ls to confirm) for
while PiSLM is unmerged.

Also adds section 16, a condensed quick reference: every command from a
freshly booted Pi through to the running service, in order, annotated with
the section each block comes from. The header points at it for readers who
are already past the hardware and OS steps.

Documentation only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
Section 10 set a static IP on the wired port without saying which
interface it was, or that changing it can cut an SSH session using that
same port. Now states explicitly that this is the built-in wired Ethernet
(eth0), not Wi-Fi, and warns to run it from the local console or be ready
to reconnect at the new address if connected over that same port via SSH.

Documentation only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
A minimal, dependency-free client for exercising a PiSLM node during
commissioning and day-to-day checks -- connects both ports, decodes the
handshake and every stream frame type, and gives an interactive shell with
shortcuts for the common commands plus a live per-channel level readout.

- ControlClient / ReaderThread: newline-JSON control port, with request/
  response matching by id (a background reader owns the socket so events
  can arrive between a command and its reply without being mistaken for
  it) and asynchronous events printed without clobbering the prompt.
- StreamReader: decodes all six frame types (DATA, MSG, BAND, LEVEL,
  BAND_LEVEL, RAW_DUMP) using only socket/struct/array from the stdlib --
  no numpy needed on the laptop. LEVEL frames drive a live meter printed
  at a fixed interval; RAW_DUMP frames are written straight to files when
  a dump is in flight (registered via the 'raw' shortcut).
- Shell: start/stop/status/info/ping, metrics [seconds] [channels...],
  calibrate <ch> [level_db] [check], save, sens/iepe/rate, raw <seconds>
  <prefix> (saves to <prefix>_devN.f64), blink, meter on|off, and a raw
   escape hatch for anything else in PROTOCOL.md.

Verified against the mock daqhats/uldaq server by driving the client's
actual classes (not a reimplementation): handshake parsing, command/
response matching, live meter reaching all 6 channels with plausible dB
values past the integrator's startup transient, the metrics/calibrate/sens
shortcuts round-tripping real server state (a set_sensitivity via 'sens'
is confirmed with a follow-up get_config), a raw dump shortcut producing
non-empty per-device files that get closed out on completion, and
malformed/unknown input not crashing the shell. 16/16.

README and INSTALL.md point to it as the quick way to check a node (§4/§13
run-by-hand, §12 calibration walkthrough), ahead of writing a real client
against PROTOCOL.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
@yohan2256
yohan2256 force-pushed the claude/raspberry-pi-noise-measurement-6vllbo branch from 4e70617 to 6900aa0 Compare August 1, 2026 21:41
yohan2256 and others added 7 commits August 2, 2026 06:43
…ement-6vllbo

PiSLM: field calibration, cross-device clock alignment, Trixie install, and a test client
Both sockets carried a 10s timeout from create_connection(..., timeout=10.0)
that was never cleared, and that timeout applied to every subsequent recv()
too -- including the background reader threads' indefinite waits. The
control port has no traffic when the user is simply idle at the prompt (no
periodic heartbeat, and events/responses are irregular), so after 10s of
silence the blocking recv() raised a timeout, ReaderThread's broad
"except (ConnectionError, OSError, ValueError)" caught it (socket.timeout
is an OSError subclass), and the thread exited printing "[control]
connection closed" -- even though nothing was actually wrong. Reported
live: a real Pi session showed exactly this after sitting idle, while the
stream reader stayed up because LEVEL frames kept arriving and never hit
the idle window.

Fix: keep the 10s timeout only for the initial TCP handshake in
create_connection(), then explicitly settimeout(None) before any ongoing
reads, for both ControlClient and StreamReader. A graceful close is still
detected instantly either way (recv() returns b'' or raises immediately on
FIN/RST); only unbounded *idle* waiting changes.

Added a regression test: idle both readers for 11s, confirm both threads
are still alive and the control port still answers a ping afterward.
19/19 (was 16); other suites unaffected (62/62, 60/60, 13/13).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
The DT9837A's ADC only supports single-ended inputs; requesting
DIFFERENTIAL leaves get_ranges() with an empty list, and indexing it
raised "list index out of range" on real hardware. Confirmed against
uldaq's AiUsb9837x.cpp source, which registers ranges only for
AI_SINGLE_ENDED and zeroes out AI_DIFFERENTIAL's queue length.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
…ement-6vllbo

Fix DT9837A IndexError on real hardware (SINGLE_ENDED input mode)
…mand

Add network.stream_frames_dropped to status/handshake, counting stream
frames evicted by ClientRegistry's per-client backpressure (previously
invisible -- a slow link silently lost frames with no signal to the
client). Add a `bench <seconds>` shorthand to pislm_test.py that measures
observed stream throughput (KB/s, Mbps, frames/s per frame type) and
reports the dsp.dropped_blocks / network.stream_frames_dropped deltas
over the window, so a bandwidth test can tell a healthy link from one
that's dropping data. Document expected bandwidth per streaming mode and
the bench workflow in PROTOCOL.md section 8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
…ement-6vllbo

Add streaming bandwidth measurement: drop counter + bench command
…L.md

pislm_test.py's `bench` polled `status` for dsp.dropped_blocks and
network.stream_frames_dropped, but _cmd_status() never returns those
fields (only get_config/the handshake do) -- the deltas always silently
read as 0 regardless of the real count. Switch bench to get_config, and
correct the same "status" claim in PROTOCOL.md's §3 and §8 wording.

Add PROTOCOL.md §9, an implementation checklist of the concrete pitfalls
hit while building the reference client (TCP partial reads, newline
buffering, the idle-timeout-looks-like-a-disconnect bug, matching
responses by id, re-reading channel_map/units, and which query carries
which fields) so the wire spec is sufficient on its own to write a new
client without reading pislm_test.py's source. Also record the real
10-minute Wi-Fi/wired bandwidth measurements from this session in §8.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
yohan2256 and others added 13 commits August 2, 2026 10:12
…ement-6vllbo

Fix bench drop-counter bug; add PROTOCOL.md client implementation checklist
Four changes, adopted from a client-side protocol amendment proposal
after review:

1. Every stream frame header (DATA/BAND/LEVEL/BAND_LEVEL/RAW_DUMP) now
   ends with a u64 start_index -- the index, on that stream's own grid
   (reset to 0 at start(), never skipped even across a network-dropped
   frame), of the frame's first sample. Lets a client size a lost gap
   exactly instead of just seeing "the next frame". RAW_DUMP shares its
   device's DATA grid so a pulled dump lines up with the live stream.

2. handshake/get_config now carry an `epoch` (wall-clock + monotonic
   reference for sample index 0) and a per-channel `overload` tally.

3. New `overload` event: per-channel ADC clipping, detected on the raw
   voltage reconstructed from the calibrated sample (not the calibrated
   value itself, so the threshold doesn't move with sensitivity),
   throttled to at most one event per channel per level-output period
   with the tally still accumulating underneath.

4. LEVEL/BAND_LEVEL/MSG (events/handshake/responses) get a separate,
   generously-sized queue instead of sharing DATA/BAND's drop-oldest
   queue, so the sound-level-meter's primary output is never silently
   lost to backpressure. Implemented as a second per-client queue merged
   onto the one socket by _sender, woken by a doorbell queue rather than
   polling -- an earlier poll-with-timeout version added enough latency
   to a control response that it lost a race with get_raw's own dump
   thread; the doorbell removes that delay entirely (found via a real
   regression: raw dump files were arriving empty).
   network.stream_frames_dropped now also breaks down by frame kind.

Skipped the amendment's proposed dual-version negotiation
(protocol_version config switch, frame_layout field): with only this
repo's own clients, a straight cutover to pislm/4 is simpler to maintain.

PROTOCOL.md sections 1-9 updated throughout (frame layouts, handshake
example, event table, get_raw fields, reliability notes, implementation
checklist). All four existing mock test suites updated for the new frame
layout and pass (62/60/13/23), plus a new test_overload.py unit test
(8/8) covering the clipping detector directly, since the mock signal
generators' fixed +-1V amplitude can't exercise real ADC clipping
end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019SaEpdKhSDouXvxZ31kccq
…ement-6vllbo

Bump wire protocol to pislm/4: sample-accurate gaps, epoch, overload detection
…rement

Adds a D/A excitation subsystem for the DT9837A's single analog output
channel, enabling white/pink noise, exponential sine sweep, and MLS
signal playback for ISO 3382-2 reverberation-time measurement.

- excitation.py: signal generators (Farina exponential sweep, MLS via
  Fibonacci LFSR, shaped white/pink noise), each verified against
  analytic properties (MLS peak-to-sidelobe ratio, sweep instantaneous
  frequency, pink noise octave-band flatness).
- devices.py: AO detection and control on Dt9837aBackend (start/stop/
  progress/zero-on-init/ramp-to-zero-on-stop); Mcc172Backend reports
  has_output()=False since it has no DAC.
- pislm.py: set_output/output_start/output_stop/output_status commands,
  output_started/output_finished events, output block in the handshake
  and config snapshot; stopping the AI scan also stops an in-flight
  output.
- PROTOCOL.md: new section documenting the output subsystem, corrected
  from the original proposal to reflect that the DT9837A has exactly
  one output channel (confirmed against uldaq's AoUsb9837x source), and
  explicit about start_index being a best-effort software timestamp
  correlation rather than a hardware-verified alignment -- GPIO-trigger
  sync (used for AI scan starts) doesn't apply here since the AI scan
  is already running and can't be re-armed for a new edge.
- pislm_test.py: output/outstart/outstop/outstatus shell commands; also
  fixes StreamReader dropping RAW_DUMP frames that arrive before their
  get_raw response registers file handles (control responses and stream
  frames travel on independent connections with no ordering guarantee),
  found while investigating unrelated test flakiness.
…ement-6vllbo

Add analog output (excitation signal) support for reverberation measurement
- shutdown_button.py: standalone GPIO-polling service, independent of
  pislm.service so it still works if the acquisition service crashes.
  Reuses the same three-backend GPIO strategy as gpio_trigger.py (gpiod
  v2/v1, RPi.GPIO) for an input pin with internal pull-up; on a
  continuous 3-second hold it blinks the Pi's own onboard status LED
  (ACT/led0 via sysfs -- no dedicated LED/resistor needed) and issues
  `systemctl poweroff`. Pin/hold time are overridable via env vars.
- pislm-shutdown-button.service: runs as root (needed for poweroff and
  the LED sysfs files), no dependency on pislm.service or the network.
- INSTALL.md: new §14 documenting wiring and install steps; renumbers
  §14-17 to §15-18 accordingly; adds troubleshooting rows for the button
  and for Wi-Fi/BT antenna RF noise on the MCC 172 (the low-power/
  low-noise dtoverlay in §11 already covers disabling both radios).

Hold-timer logic (continuous-hold trigger, reset-on-release, no false
trigger just under the threshold) verified against a faked GPIO/LED/
subprocess harness, 6/6 checks passing.
Impulse time weighting (slm.py):
- tau_for() now returns (tau_rise, tau_decay) instead of a single tau.
  Fast/Slow are symmetric (unchanged behavior, still the vectorized
  scipy.signal.lfilter path). Impulse is now genuinely asymmetric per
  IEC 60651/60804 -- 35 ms rise, 1.5 s decay -- instead of the previous
  approximation that used 35 ms for both directions (silently missing
  the standard's slow decay entirely). IEC 61672-1, the current standard,
  no longer defines Impulse at all; this exists for older/local
  regulations that still call for it.
- The asymmetric filter is nonlinear (its pole depends on the sign of
  input-minus-state each sample), so it can't be expressed as an LTI
  filter; ExpLevel now branches to a per-sample loop (_asymmetric_exp)
  only for Impulse, keeping Fast/Slow on the fast vectorized path.
  Verified: correct fast-rise/slow-decay asymmetry, bit-identical
  Fast/Slow output to the old single-tau lfilter reference, correct
  streaming state across chunk boundaries, and real-time headroom
  (5s of 51.2kHz audio processes in ~0.1s).

Band-pass filter order (band_filter.py, pislm.py, config.ini):
- Default order dropped from 6 (12-pole band-pass -- scipy.signal.butter's
  band-pass design doubles the requested order) to 3 (6-pole). Measured
  on the worst-case narrow low-frequency 1/3-octave band (100 Hz center):
  the old 12-pole default rings for ~208ms after an impulsive transient,
  vs ~124ms at 6-pole -- a real, audible difference on impact/impulsive
  content, which is what prompted this. Stopband margin at 6-pole is
  still >40dB at 1.5x the band edge, comfortably inside IEC 61260.

New scratchpad test (not part of this commit) covers both: asymmetric
rise/decay behavior, Fast/Slow regression-safety, streaming continuity,
and the ringing/attenuation tradeoff for the new default order -- 17/17
passing, plus the full existing suite (e2e/inline/resample/overload/
excitation/output/shutdown_button) still green. Also smoke-tested live
against the mock hardware harness through both the inline and
multiprocess DSP-pool paths with Impulse + bands(order=3) active.
Root-caused a real report of inflated 1/3-octave band readings to the
order=6->3 default change made earlier in this session: measured
adjacent-band rejection for a pure tone drops from ~35 dB (order=6) to
only ~18 dB (order=3), so tonal/resonant energy in real-world impacts
visibly leaks into neighboring bands and reads them higher than expected.
That's the filter's actual selectivity at low order, not a bug.

Full order-vs-tradeoff sweep (ring time at the worst-case low-frequency
band, and adjacent-band rejection for a tone at the next band's center):

  order | ring @100hz | adjacent rejection
    2   |    92 ms    |  -12.4 dB
    3   |   124 ms    |  -18.3 dB  (current default)
    4   |   128 ms    |  -24.3 dB
    5   |   167 ms    |  -30.1 dB
    6   |   208 ms    |  -35.3 dB  (previous default)

Given the choice, keeping order=3 (prioritizing minimal ringing over
spectral separation) -- no default changed here, just corrected the
config.ini/band_filter.py comments (which previously and incorrectly
claimed order=3 was "comfortably steep enough" with no real cost) and
PROTOCOL.md's stale order=6 handshake examples, so this tradeoff is
documented accurately for whoever revisits it next.
A user report of levels appearing "DC-influenced" after an impact --
jumping up then slowly decaying, longer than Fast (125ms) should ever
show -- traced to real ADC clipping, confirmed by an overload event
firing at the same instant. A synthetic electrical-DC-shift test showed
A-weighting suppresses that almost completely (+0.4dB at 1s post-event),
ruling out a software DC-leakage bug; a clipped IEPE input's actual
recovery time is a hardware/sensor characteristic no DSP filter can fix
after the fact, since clipping is lossy.

Added a field-checklist item (impact/shock measurement needs peak
headroom beyond what a steady-state calibrator check validates) and a
troubleshooting row pointing at the overload event, since this project
had no clipping-related guidance at all despite being built specifically
for impact/reverberation measurement (excitation.py, ISO 3382).
Prompted by a question about the current voltage range: there was no way
to query it -- full_scale_v existed internally (devices.py) for overload/
clipping detection and appeared reactively in the overload event payload,
but was never surfaced in the info command or the handshake/get_config
devices list. A user wanting to judge peak headroom before an impact test
(per the overload-clipping guidance just added to INSTALL.md) had no way
to check it proactively.

- devices.py: both backends' info() now include full_scale_v; dt9837a
  also reports input_range (the uldaq range name, e.g. "BIP10VOLTS").
  Confirmed against uldaq's real source (AiUsb9837x::addSupportedRanges
  registers BIP10VOLTS before BIP1VOLTS, and this code always takes
  index [0]) that the DT9837A's range is +-10V under this codebase as it
  stands -- BIP1VOLTS is registered but never actually selected.
- pislm.py: config_snapshot()'s devices list (handshake + get_config)
  also carries full_scale_v now, so it's visible immediately on connect.
- PROTOCOL.md: documented the new field and updated the handshake example
  (mcc172 5.0V, dt9837a 10.0V).

New scratchpad test (not part of this commit) verifies full_scale_v shows
up correctly in the handshake, get_config, and info command -- 7/7
passing, plus the full existing suite still green.
Root-caused a report of periodic spikes in band data (raw recording
unaffected) to DspPool.submit(): when a worker falls behind and has no
free shared-memory slot, the whole block is silently dropped -- but the
worker's filter state (zi/phase) never learns a gap happened. The next
block it does see gets spliced onto stale pre-gap state as if the
intervening samples never existed, which for a narrow/high-Q band filter
produces a genuine discontinuity -- a spurious transient right at the
splice point. The same drop also failed to advance the affected
channel/band's start_index counter, silently defeating the pislm/4
gap-detection guarantee (the client would see false continuity instead
of a real jump).

- slm.py: ExpLevel.skip(n_frames) advances the decimation phase exactly
  as process() would for n_frames of un-seen input, resets the filter
  state (forces a clean re-prime on the next process() call instead of
  splicing across the gap), and returns the equivalent output sample
  count for start_index accounting.
- band_filter.py: BandFilterBank.skip(n_frames) does the same per
  band/channel, matching process_2d()'s phase arithmetic exactly.
- dsp_pool.py: DspPool.submit() now tracks dropped/truncated frames per
  worker and carries them as gap_frames on that worker's next successful
  task (instead of losing the count). _WorkerState.process() skip()s the
  affected integrators across a nonzero gap before processing the new
  block, emitting '..._gap' entries with the skipped-sample count.
- pislm.py: _emit_pool_frames() advances the relevant start_index
  counter for a '..._gap' entry (no frame to send, just the count) so
  the next real frame's start_index correctly reflects the gap.
- PROTOCOL.md/INSTALL.md: documented this as a third, distinct
  backpressure layer (upstream of the network queues already documented
  in §6), and added a troubleshooting entry for the exact symptom.

Verified: ExpLevel/BandFilterBank skip() match a real (silent) gap's
phase/output-count exactly, and demonstrably reset state (post-gap
silence reads near the noise floor, not a decaying echo of pre-gap
signal). DspPool end-to-end test forces real worker backpressure drops
via actual subprocesses and confirms a gap marker is emitted and the
pool recovers cleanly -- 11/11 passing, plus the full existing suite
(which already exercises the pool path with workers=-1) still green.
… fallback

A discrete LED wired to GPIO 22 (BCM, header pin 15) now blinks during
shutdown instead of only the onboard status LED -- more visible on an
enclosure faceplate than the tiny onboard SMD LED. If that pin can't be
opened (not wired, or busy), it falls back to the previous onboard-LED
behavior automatically, so blinking still works either way.

- gpio_trigger.py: GpioTrigger gets a public set(high) method (thin
  wrapper over the existing _set) so it can drive a steady level, not
  just emit trigger pulses -- reused as-is for the LED pin instead of
  duplicating the three-backend (gpiod v2/v1, RPi.GPIO) opening logic.
- shutdown_button.py: new GpioLed class wraps GpioTrigger with the same
  start_blink() interface as StatusLed; main() tries GpioLed(LED_PIN)
  first and falls back to StatusLed() on any failure. New
  PISLM_SHUTDOWN_LED_GPIO_PIN env var (default 22, chosen to not collide
  with the button's GPIO 27 or the sync-start trigger's GPIO 17).
- INSTALL.md/service file: documented the new wiring and env var, and
  added an explicit note that "LED dark" means the process was killed
  partway through the OS halt, not necessarily full power-off, on a
  plain USB-powered Pi with no smart power controller.

Tests extended: GpioLed drives its pin via set() and toggles on
start_blink(); main() prefers GpioLed when it opens successfully and
falls back to StatusLed only when construction raises; both paths still
trigger poweroff correctly -- 13/13 passing (was 6/6), plus the full
existing suite still green.
Extends the shutdown-button service to also watch an optional INA219-
based UPS HAT (e.g. Waveshare's UPS HAT family, confirmed live at I2C
0x41 on the user's hardware) and trigger the same blink+poweroff sequence
on sustained low battery, not just the physical button -- deliberately in
the same independent service (not pislm.service, not a second service
competing for the LED's GPIO pin) so the safety-critical shutdown still
works if the acquisition service has crashed.

- ina219.py: new driver. Register map, 32V/2A calibration constants
  (cal_value=4096, current_lsb=0.1mA, power_lsb=2mW), and the bus-voltage-
  to-percentage formula (linear, 6.0V empty .. 8.4V full, 2S Li-ion) match
  Waveshare's own UPS HAT demo code, cross-referenced from their public
  driver source, so readings agree with any Waveshare tool. Reads are
  manual big-endian byte pairs, not smbus2's word_data helpers (SMBus
  spec is little-endian -- using word_data directly would silently
  byte-swap every value). Calibration is reloaded before every current/
  power read since the chip can self-clear it under brownout/reset.
- shutdown_button.py: polls the UPS (PISLM_UPS_* env vars) alongside the
  button in the same loop. Shutdown requires the battery to read at or
  below the threshold continuously for PISLM_UPS_LOW_HOLD_SECONDS (30s
  default) -- a sustained-low requirement mirroring the button's hold, so
  one noisy/transient reading can't trigger a shutdown. Every poll is
  written atomically to a status file (tmpfs) purely as live status.
  Auto-disables (never blocks the button) if no chip answers at the
  configured bus/address.
- pislm.py: new _ups_snapshot() reads that status file (best-effort,
  stale-flagged, never raises) and surfaces it as a `ups` field in the
  handshake/get_config -- so battery status is checkable through the same
  client used for everything else, without pislm touching I2C itself or
  a second connection. New [ups] config.ini section for the file path/
  staleness threshold.
- INSTALL.md/PROTOCOL.md/README.md/service file: documented the wiring
  (shared I2C bus, address via i2cdetect), smbus2 dependency, env vars,
  and the new `ups` handshake field.

New tests: ina219.py register/byte-order/percentage-formula correctness
against a faked I2C bus (14/14); sustained-vs-transient-low battery
shutdown logic, read-failure resilience, and status-file writing against
the real main() loop (7/7); pislm.py's ups snapshot (missing/fresh/stale/
malformed status file) against the real Controller (6/6) -- plus the full
existing suite still green.
@yohan2256
yohan2256 force-pushed the claude/raspberry-pi-noise-measurement-6vllbo branch from 52088db to b80f662 Compare August 17, 2026 09:18
yohan2256 and others added 7 commits August 17, 2026 18:20
…ement-6vllbo

Physical shutdown button, UPS monitoring, and DSP/measurement fixes
…shutdown

Requested for clarity: the LED now has three unambiguous states instead
of two -- steady on while the service is up and running normally,
blinking while a shutdown (button or low battery) is in progress, dark
once safe to remove power. Previously the LED was off/idle during normal
operation and only ever did anything at shutdown, which left "is this
even working" ambiguous.

- GpioLed/StatusLed: new on() method (steady high / brightness=1),
  alongside the existing start_blink().
- main(): calls led.on() right after opening the LED (GPIO-dedicated or
  the onboard-LED fallback), before entering the button/UPS polling loop.
  start_blink() at shutdown time overrides it as before.
- Noted in INSTALL.md that the onboard-LED fallback path repurposes ACT
  away from its normal disk-activity blinking (now steady on instead)
  while this service runs.

Tests extended: GpioLed.on()/StatusLed.on() drive the pin/sysfs file
correctly; main() calls on() exactly once at startup (before any
shutdown) regardless of which LED path was chosen -- 15/15 passing (was
13/13), plus the full existing suite still green.
Button stays GPIO 27 (already the default, unchanged). LED default moves
from GPIO 22 to GPIO 24, matching the pin actually wired in the field --
updated the code default, the service file's commented example, and
INSTALL.md's wiring diagram/prose accordingly.
…rement-6vllbo

Shutdown LED: steady-on "running" state, default pin to GPIO 24
Real bug found from field testing: holding the button powered the Pi off
correctly but the LED never blinked, even though it was steady-on
beforehand (confirming the LED/GPIO path itself was fine). Root cause:
`systemctl poweroff` stops every other unit first as part of the normal
shutdown-target ordering, and that includes this service too -- systemd's
own SIGTERM (Python's default disposition: terminate immediately) was
killing the process, and its blink thread, within a fraction of a blink
cycle, well before the OS actually finished halting.

_trigger_shutdown() now switches SIGTERM to ignored right before starting
the blink -- only from that point on, so `systemctl stop` still works
normally at every other time. The process (and blink) now survives until
the eventual SIGKILL in the final teardown phase or the existing 60s cap,
whichever comes first, instead of dying almost instantly.

Verified with a real signal: after calling _trigger_shutdown(), sending
this test process an actual SIGTERM no longer terminates it (it would,
unhandled, before the fix) -- 18/18 passing (was 17/17), plus the full
existing suite still green.
…rement-6vllbo

Ignore SIGTERM once shutdown is triggered, so the LED actually blinks
Real bug found from field data: a 3S Li-ion pack (9.0V/12.6V) read a
real, sensible ~95% (12.424V) and later ~57% (11.064V discharging) as a
meaningless 100% under the hardcoded 2S default (6.0V/8.4V) -- silently
disabling low-battery auto-shutdown for that pack entirely, since percent
can never cross a threshold it's already clamped past.

Root cause: INA219.read_percentage() already took v_min/v_max as call
arguments, but read_all() -- what shutdown_button.py actually calls every
poll -- had its own separate hardcoded (bus_v - 6.0) / 2.4 formula that
never consulted them at all.

- ina219.py: v_min/v_max are now constructor arguments (default 6.0/8.4,
  unchanged), stored on the instance. read_percentage() defaults to the
  instance's values (still overridable per call); read_all() now reads
  bus voltage once and derives percent from that same instance-configured
  range, instead of a second read through a hardcoded formula.
- shutdown_button.py: new PISLM_UPS_V_MIN/PISLM_UPS_V_MAX env vars,
  actually passed into INA219() this time.
- INSTALL.md/service file: documented that getting v_min/v_max wrong
  doesn't error, it silently disables the shutdown feature -- how to
  tell (percent pinned at 0/100 while bus_voltage_v keeps changing), and
  that this is a linear approximation of state of charge (not true SoC,
  real Li-ion discharge curves are S-shaped), so leave real margin on
  the low-battery threshold rather than cutting it close to 0.

New tests in test_ina219.py: constructor stores v_min/v_max; read_all()
uses the instance's configured range (the actual bug, reproduced with the
field's own 3S readings: 12.424V -> ~95%, 11.064V -> ~57%, both correctly
proportional instead of clamped); per-instance isolation; per-call
override still works; read_all() reads the bus-voltage register exactly
once (bus_voltage_v and percent stay mutually consistent). Extended
test_ups_shutdown.py to confirm _open_ups() actually passes
UPS_V_MIN/UPS_V_MAX through -- 21/21 and 8/8 respectively (was 14/14 and
7/7), plus the full existing suite still green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants