Claude/raspberry pi noise measurement 6vllbo - #84
Open
yohan2256 wants to merge 46 commits into
Open
Conversation
…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
Author
|
1 |
1 similar comment
Author
|
1 |
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
force-pushed
the
claude/raspberry-pi-noise-measurement-6vllbo
branch
from
August 1, 2026 05:30
acd3cf3 to
087b34c
Compare
…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
force-pushed
the
claude/raspberry-pi-noise-measurement-6vllbo
branch
from
August 1, 2026 21:41
4e70617 to
6900aa0
Compare
…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
…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
force-pushed
the
claude/raspberry-pi-noise-measurement-6vllbo
branch
from
August 17, 2026 09:18
52088db to
b80f662
Compare
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.