From b1a405e25d753eff773176dfcf37d855c12ff840 Mon Sep 17 00:00:00 2001 From: Jeremy Karst Date: Sun, 23 Aug 2026 18:59:51 -0400 Subject: [PATCH] Changed some defaults and warn on bad settings. Added bazzite support for setup.sh USB perms. --- README.md | 130 ++++++++++++++++++++++++++++++++++++++++-------------- logger.py | 74 ++++++++++++++++++++++++++++--- rm3100.py | 24 +++++----- setup.sh | 83 +++++++++++++++++++++++++++------- 4 files changed, 245 insertions(+), 66 deletions(-) diff --git a/README.md b/README.md index 2ea6fab..9fb857f 100644 --- a/README.md +++ b/README.md @@ -128,12 +128,42 @@ Idempotent. It installs a udev rule, creates `.venv`, and installs pyusb. It needs `sudo` for the udev rule only. The rule is needed because the CH347's USB node defaults to `root:root 0664`. -It grants the `plugdev` group access: +It grants access two ways, because no single mechanism covers every distro: ``` -SUBSYSTEM=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55db", GROUP="plugdev", MODE="0660" +SUBSYSTEM=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55db", TAG+="uaccess", GROUP="plugdev", MODE="0660" ``` +- **`TAG+="uaccess"`** makes systemd-logind put an ACL on the node for whoever + is logged in at the local seat. No group, no logout, and it is the only part + that works unaided on atomic Fedora. `setup.sh` triggers with + `--action=add`, not the default `change`, because the `uaccess` builtin only + runs on add — a change event installs the rule but grants nothing until the + next replug. +- **`GROUP=`/`MODE=`** is the fallback for ssh sessions and seatless systems, + where there is no local seat for `uaccess` to grant to. The group is chosen + from those that exist — `plugdev` is a Debian convention and absent on + Fedora, and naming a group that does not exist makes udev log an error and + leave the node owned by root. If neither `plugdev` nor `dialout` exists the + clause is omitted entirely. + +#### Atomic Fedora (Bazzite, Silverblue, Kinoite) + +`usermod -aG dialout $USER` fails there with *"group 'dialout' does not +exist"* even though `getent group dialout` finds it. Fedora ships +`nss_altfiles`, so `getent` reads both `/etc/group` and `/usr/lib/group`, while +`usermod` writes `/etc/group` alone — and on an rpm-ostree system the group +frequently lives only in the latter. Copy the line across first: + +```bash +grep -E '^dialout:' /usr/lib/group | sudo tee -a /etc/group +sudo usermod -aG dialout $USER # then log out and back in +``` + +None of that is needed for a local desktop session: `uaccess` covers it, which +is why the rule carries both. `setup.sh` tests the node itself at the end and +prints whichever fix applies rather than guessing up front. + Python 3.14 here is PEP 668 externally-managed, hence the venv rather than a system-wide `pip install`. @@ -147,12 +177,13 @@ system-wide `pip install`. | Flag | Default | Meaning | |---|---|---| -| `--cycle-count` | 50 | sets the rate and the LSB; see Recommended configuration | +| `--rate` | — | target Hz; derives the cycle count and TMRC. Mutually exclusive with `--cycle-count` | +| `--cycle-count` | 100 | sets the rate and the LSB; see Recommended configuration | | `--tmrc` | fastest | rate register. Default lets the cycle count set the rate; give one only to sample *slower* | | `--duration` | 0 | seconds, 0 = until Ctrl-C | | `--output` | timestamped | CSV path | | `--address` | autodetect | skip the scan | -| `--bus-speed` | 750 | I²C kHz; 100 would spend 80% of the period on the bus at cc=50 | +| `--bus-speed` | 750 | I²C kHz; 100 would spend 42% of the period on the bus at cc=100 | | `--scan-only` | — | scan and exit | Exit status is non-zero if an interval was missed. The partial capture is kept @@ -440,7 +471,7 @@ Symptoms map cleanly onto causes, so work down this list. | Symptom | Cause | |---|---| -| `Cannot claim CH347 interface 2: Access denied` | udev rule missing — run `./setup.sh`. Do **not** use `sudo`: it works as your normal user, and running as root leaves root-owned capture files | +| `Cannot claim CH347 interface 2: Access denied` | udev rule missing or not yet applied to this node — run `./setup.sh`, which diagnoses which of `uaccess` or group membership fell through. Do **not** reach for `sudo`: it works as your normal user, and masking a permissions problem with root only defers it. `--high-priority` is the one case that legitimately needs root, and it hands the capture file back to `$SUDO_UID` afterwards | | `No CH347 adapter found` | not plugged in, or not in Mode 1 | | `ABORTED: missed N interval(s)` | the host could not keep up — raise `--tmrc` (slower) or `--bus-speed`. Expected at cycle count 50 near the sensor's maximum | | `no capture header found` | a capture predating the header format — re-record it | @@ -743,30 +774,37 @@ better and 750 kHz is the default everywhere. ### Recommended configuration -**Cycle count 50 at 750 kHz**, which is what the defaults now do. That runs the -sensor at its own ceiling of ~534 Hz, with TMRC held fast so the cycle count -governs and the duty cycle stays near 100%. +**Cycle count 100 at 750 kHz**, which is what the defaults do. That runs the +sensor at its own ceiling of ~282 Hz, with TMRC held fast so the cycle count +governs and the duty cycle stays at 94%. ```bash -./.venv/bin/python logger.py --duration 60 # cc=50, 750 kHz, ~534 Hz +./.venv/bin/python logger.py --duration 60 # cc=100, 750 kHz, ~282 Hz ``` -Against cycle count 200 this costs **4.4%** in post-filter noise — 89% duty -against 97%, and nothing else — while giving **3.7x the spectrum**, 267 Hz of -Nyquist against 73 Hz. That trade is worth taking because **aliased interference -cannot be filtered out afterwards at any cycle count**, so seeing it beats a few -percent of noise. At 534 Hz mains and its first four harmonics all sit in band -and can be notched; at 145 Hz only the 60 Hz fundamental does, and the rest fold -irreversibly onto signal. +It sits deliberately between the two things that pull in opposite directions: -750 kHz is not optional at this cycle count. The period is 1.873 ms, and the +- **Against cycle count 200** it costs **1.5%** in post-filter noise — 94% duty + against 97%, and nothing else — for **1.9× the spectrum**, 141 Hz of Nyquist + against 73 Hz. Worth taking, because **aliased interference cannot be filtered + out afterwards at any cycle count**, so seeing it beats 1.5% of noise. At + 282 Hz both mains and its second harmonic sit in band and can be notched; at + 145 Hz only the fundamental does. +- **Against cycle count 50** it gives up half the spectrum (141 Hz of Nyquist + against 267 Hz) and buys **36% more dither margin**, 0.79 LSB against 0.58, + plus a 1.9× coarser rate demand on the host. cc=50 is the right choice when + something above 141 Hz needs identifying; it is not the right default, + because its dither margin is the one assumption in the whole configuration + that has not yet been checked against a real quiet-site capture. + +750 kHz is not optional at this cycle count. The period is 3.539 ms, and the irreducible I2C traffic per sample is: | bus | traffic | share of period | |---|---|---| -| 100 kHz | 1.500 ms | **80%** | -| 400 kHz | 0.375 ms | 20% | -| 750 kHz | 0.200 ms | **11%** | +| 100 kHz | 1.500 ms | **42%** | +| 400 kHz | 0.375 ms | 11% | +| 750 kHz | 0.200 ms | **6%** | 750 kHz is also the fastest measured, not merely the least bus traffic. Host cost per sample, three runs each at cycle count 228: @@ -783,19 +821,47 @@ benchmarked faster than 750; that rested on a single short run whose 4% difference was inside the run-to-run spread, and does not survive repetition. 400 kHz remains a sane fallback if 750 proves marginal on long wiring. -**Fall back to cycle count 100 or 200 if dither is thin.** The one assumption -worth checking is that cc=50 stays dithered: its intrinsic noise is 0.58 LSB, -and simulation puts the failure point below ~0.2 LSB, so there is roughly 3x of -margin. `characterize.py` prints `sd/LSB` per axis, which settles it from the -first capture — below ~0.35 and averaging stops recovering sub-LSB resolution, -at which point 100 (1.17 LSB) or 200 (1.10 LSB) buys the margin back at the cost -of bandwidth. +**Fall back to cycle count 200 if dither is still thin.** The assumption to check +is that cc=100 stays dithered: its intrinsic noise is 0.79 LSB against a +simulated failure point below ~0.2 LSB, so there is roughly 4× of margin at the +spec noise figure. That margin shrinks if the real sensor is quieter than spec, +which is the entire point of the quiet-site experiment. +`characterize.py` prints `sd/LSB` per axis, which settles it from the first +capture — below ~0.35 and averaging stops recovering sub-LSB resolution, at +which point 200 (1.10 LSB) buys the margin back at the cost of bandwidth. -| cycle count | rate | Nyquist | nT/LSB | dither | duty | -|---|---|---|---|---|---| -| **50** | **534 Hz** | **267 Hz** | 50.37 | **0.58** | 89.0% | -| 100 | 281 Hz | 141 Hz | 26.17 | 0.80 | 93.7% | -| 200 | 145 Hz | 73 Hz | 13.35 | 1.10 | 97.0% | +| cycle count | rate | Nyquist | nT/LSB | dither | duty | | +|---|---|---|---|---|---|---| +| 50 | 534 Hz | 267 Hz | 50.37 | 0.58 | 89.0% | spectrum, thin dither | +| **100** | **282 Hz** | **141 Hz** | **26.17** | **0.79** | **94.2%** | **default** | +| 200 | 145 Hz | 73 Hz | 13.35 | 1.10 | 97.0% | resolution, 60 Hz only | + +`logger.py` warns on stderr for anything below cycle count 50 +(`rm3100.RECOMMENDED_MIN_CYCLE_COUNT`), along with five other configuration +traps — see [Configuration warnings](#configuration-warnings). + +### Configuration warnings + +`rm3100.plan()` resolves a configuration and `logger.py` prints its derivation +before every run, then checks it against six known traps. Each one is a *silent* +failure: the capture completes, the numbers look plausible, and the defect is +only visible afterwards. So each is reported on stderr and **nothing is fixed +automatically** — changing a setting that was asked for would hide the problem +behind a configuration change. + +| Warning | Trigger | Why it matters | +|---|---|---| +| cycle count below the recommended 50 | `cc < RECOMMENDED_MIN_CYCLE_COUNT` | dither thins toward the ~0.2 LSB where averaging stops recovering sub-LSB resolution | +| TMRC governs and the sensor idles | TMRC-governed and idle > 20% | idle time buys nothing; measured 1.43× the ASD at 23% duty | +| rate differs from the one requested | \|error\| > 2% | the run silently uses the ceiling, and every figure above it is derived from the rate | +| Nyquist below 60 Hz | rate < 120 Hz | mains folds onto signal and no later filter undoes it | +| cycle count past 400 | `cc > MAX_SPEC_CYCLE_COUNT` | Table 3-1 ends there, so the printed gain and noise are extrapolated, not specified | +| bus over half the period | traffic / period > 50% | names the speed that would fit | + +The aliasing one is the easiest to walk into: `--rate 32` yields a clean-looking +2.91 nT/LSB capture with 60 Hz mains sitting at 4.02 Hz, indistinguishable from +signal. Sampling fast and decimating afterwards gives the same noise floor with +the line still visible. ### Rate model, and how TMRC is chosen diff --git a/logger.py b/logger.py index 869ccd7..bf86f42 100755 --- a/logger.py +++ b/logger.py @@ -23,6 +23,7 @@ import csv import os import queue import sys +import textwrap import threading import time from datetime import datetime @@ -33,6 +34,10 @@ import rm3100 CSV_FIELDS = ["sample_index", "system_time_unix", "x_raw", "y_raw", "z_raw", "warning"] +# Mains fundamental. Anything sampled below twice this folds the line onto +# signal irreversibly, which is the one error post-processing cannot undo. +MAINS_HZ = 60.0 + BUS_SPEEDS = { 20: ch347.SPEED_20KHZ, 100: ch347.SPEED_100KHZ, @@ -179,8 +184,8 @@ def parse_args(): help="I2C address, skipping the scan (default: autodetect)") p.add_argument("--bus-speed", type=int, choices=[20, 100, 400, 750], default=750, help="I2C bus speed in kHz. The default cycle count runs near " - "534 Hz, where 100 kHz would spend 80%% of each period on " - "the bus; 750 spends 11%% (default: %(default)s)") + "282 Hz, where 100 kHz would spend 42%% of each period on " + "the bus; 750 spends 6%% (default: %(default)s)") p.add_argument("--calibrate", type=float, default=1.0, help="seconds of loss-free samples used to measure the true " "measurement period before recording starts; the run " @@ -233,13 +238,70 @@ def print_plan(cfg, bus_speed, requested_rate, sampler_nice=None): print(f" priority {detail}") for note in cfg.notes: print(f" note: {note}") - # Bus speed is validated but never changed silently -- swapping it would - # hide a wiring problem behind a configuration change. + + # Nothing below is ever fixed silently: changing a setting the user asked + # for would hide the problem behind a configuration change. Say what is + # wrong and what would fix it, then run what was requested. + warnings = [] + + # Below the recommended cycle count the LSB grows faster than the sensor's + # own noise, so the quantiser stops being dithered and averaging stalls. + # The manual's hard floor is 30 (section 5.1), but the margin is already + # slim at 50, so anything under that is worth saying out loud. + if cfg.cycle_count < rm3100.RECOMMENDED_MIN_CYCLE_COUNT: + warnings.append( + f"cycle count {cfg.cycle_count} is below the recommended " + f"{rm3100.RECOMMENDED_MIN_CYCLE_COUNT}: dither is {dither:.2f} LSB " + f"at spec noise ({lsb:.1f} nT/LSB) against the ~0.2 where averaging " + "stops recovering sub-LSB resolution. characterize.py prints sd/LSB " + "per axis, which settles it from the first capture.") + + # The measured worst configuration mistake -- TMRC slower than the + # cycle-count ceiling leaves the sensor idle, and idle time buys nothing. + # Idle is the shortfall against the ceiling, which is what TMRC costs; it is + # not 1 - duty, since the fixed per-axis overhead is active time too. + idle = 1.0 - cfg.predicted_hz * rm3100.sample_period(cfg.cycle_count) + if cfg.governed_by == "TMRC" and idle > 0.2: + warnings.append( + f"TMRC 0x{cfg.tmrc:02x} governs and leaves the sensor idle " + f"{idle:.0%} of each period, holding duty to {cfg.duty:.0%}. " + "Measured cost at 23% duty was 1.43x the noise ASD. Raising the " + "cycle count instead reaches the same rate at ~100% duty.") + + # A rate that came back different from the one asked for is easy to miss in + # a note, and every figure above is derived from the rate. + if (requested_rate is not None and abs(cfg.predicted_hz / requested_rate - 1) + > rm3100.RATE_TOLERANCE): + warnings.append( + f"{requested_rate:g} Hz was requested but this configuration runs " + f"at {cfg.predicted_hz:.2f} Hz.") + + # Mains and its harmonics fold onto signal below 2x mains, and no amount of + # post-filtering undoes an alias. + if cfg.predicted_hz < 2 * MAINS_HZ: + alias = abs(MAINS_HZ - round(MAINS_HZ / cfg.predicted_hz) * cfg.predicted_hz) + warnings.append( + f"Nyquist is {cfg.predicted_hz / 2:.1f} Hz, below {MAINS_HZ:g} Hz " + f"mains: interference folds to {alias:.2f} Hz and cannot be removed " + "afterwards. Sampling faster and decimating gives the same noise " + "floor with the mains line still visible.") + + # Table 3-1 stops at 400, so the noise figure printed above is extrapolation + # past that point rather than a specification. + if cfg.cycle_count > rm3100.MAX_SPEC_CYCLE_COUNT: + warnings.append( + f"cycle count {cfg.cycle_count:,} is past the {rm3100.MAX_SPEC_CYCLE_COUNT} " + "where Table 3-1 ends, so the spec noise and gain above are " + "extrapolated, not specified.") + if share > 0.5: faster = [s for s in sorted(BUS_SPEEDS) if i2c_bus_time(s) / period < 0.25] fix = f"; {faster[0]} kHz would fit" if faster else "" - print(f" WARNING: the bus needs {share:.0%} of every period{fix}", - file=sys.stderr) + warnings.append(f"the bus needs {share:.0%} of every period{fix}.") + + for text in warnings: + print(textwrap.fill(text, width=79, initial_indent=" WARNING: ", + subsequent_indent=" "), file=sys.stderr) def find_sensor(bus, address): diff --git a/rm3100.py b/rm3100.py index c16cbf5..3522112 100644 --- a/rm3100.py +++ b/rm3100.py @@ -46,18 +46,18 @@ STATUS_DRDY = 0x80 EXPECTED_REVID = 0x22 -# 50 buys bandwidth almost for free. Against cycle count 200 it costs 4.4% in -# post-filter noise (89% duty against 97%) and nothing else, while giving 3.7x -# the spectrum -- 267 Hz of Nyquist against 73 Hz. That matters because aliased -# interference cannot be filtered out afterwards at any cycle count, so seeing -# it is worth more than a few percent of noise. -# -# The one thing to verify rather than assume is dither: 50 sits at 0.58 LSB of -# intrinsic noise, which simulation puts safely in the region where averaging -# still recovers sub-LSB resolution (it fails below ~0.2). characterize.py -# prints sd/LSB, which answers it from the first capture. Fall back to 100 or -# 200 if that comes back low. -DEFAULT_CYCLE_COUNT = 50 +# 100 cycle count gives a good balance of sensor duty cycle, spectrum, and post-filter noise floor. +# This should be raised if data rates are higher than desired or bus speed / host speed can't support. +# Lower than 50 is not recommended under any circumstances. See the README for more info. +DEFAULT_CYCLE_COUNT = 100 + +# Not a hard floor -- the manual's is MIN_CYCLE_COUNT below -- but the point +# under which the dither margin is too thin to defend, so logger.py warns. +RECOMMENDED_MIN_CYCLE_COUNT = 50 + +# Table 3-1 tabulates gain and noise up to a cycle count of 400. Past that both +# _gain_lsb_per_tesla and expected_noise_nt are extrapolating their fits. +MAX_SPEC_CYCLE_COUNT = 400 # The fastest rate register. Used by default so the cycle count, not TMRC, sets # the rate -- TMRC offers only factor-of-two steps and has no effect at all once diff --git a/setup.sh b/setup.sh index 889f3d5..60e904f 100755 --- a/setup.sh +++ b/setup.sh @@ -3,15 +3,14 @@ # One-time system setup for the CH347 USB-I2C adapter and the RM3100 logger. # Idempotent: safe to re-run. # -# Needs sudo for exactly one thing: installing the udev rule that grants the -# plugdev group access to the adapter's USB node. +# Needs sudo for exactly one thing: installing the udev rule that grants this +# user access to the adapter's USB node. set -euo pipefail VID=1a86 PID=55db RULE_FILE=/etc/udev/rules.d/99-ch347.rules -RULE='SUBSYSTEM=="usb", ATTRS{idVendor}=="'"$VID"'", ATTRS{idProduct}=="'"$PID"'", GROUP="plugdev", MODE="0660"' cd "$(dirname "$0")" @@ -26,25 +25,48 @@ lsusb -d "$VID:$PID" echo echo "== 2. Installing udev rule ==" +# Access is granted two ways, because no single mechanism covers every distro: +# +# TAG+="uaccess" systemd-logind puts an ACL on the node for whoever is +# logged in at the local seat. Needs no group and no logout, +# and is the only thing that works unaided on atomic Fedora +# (Bazzite, Silverblue, Kinoite) -- see section 4. +# GROUP=/MODE= the traditional fallback, for ssh sessions and seatless +# systems where there is no local seat for uaccess to grant. +# +# The group is picked from those that exist. plugdev is a Debian convention and +# is absent on Fedora; naming a group that does not exist makes udev log an +# error and leave the node owned by root, which is worse than omitting it. +ACCESS_GROUP="" +for g in plugdev dialout; do + if getent group "$g" >/dev/null; then ACCESS_GROUP=$g; break; fi +done + +RULE="SUBSYSTEM==\"usb\", ATTRS{idVendor}==\"$VID\", ATTRS{idProduct}==\"$PID\", TAG+=\"uaccess\"" +if [[ -n "$ACCESS_GROUP" ]]; then + RULE="$RULE, GROUP=\"$ACCESS_GROUP\", MODE=\"0660\"" + echo "Access via uaccess, falling back to group '$ACCESS_GROUP'." +else + echo "No plugdev or dialout group here; relying on uaccess alone." +fi + if [[ -f "$RULE_FILE" ]] && [[ "$(cat "$RULE_FILE")" == "$RULE" ]]; then echo "$RULE_FILE already up to date, skipping." else echo "Writing $RULE_FILE (needs sudo)..." printf '%s\n' "$RULE" | sudo tee "$RULE_FILE" >/dev/null sudo udevadm control --reload-rules - sudo udevadm trigger --subsystem-match=usb - # udevadm trigger returns before the rule has necessarily been applied. + # --action=add, not the default 'change': the uaccess builtin that sets the + # ACL only runs on add, so a change event would install the rule without + # granting anything until the next replug. Matched to this device alone so + # re-running setup does not re-add every USB device on the system. + sudo udevadm trigger --action=add --subsystem-match=usb \ + --attr-match=idVendor="$VID" --attr-match=idProduct="$PID" + # trigger returns before the rule has necessarily been applied. sudo udevadm settle echo "Installed." fi -if ! id -nG | tr ' ' '\n' | grep -qx plugdev; then - echo "WARNING: $(id -un) is not in the 'plugdev' group, so the rule above" >&2 - echo " will not grant access. Fix with:" >&2 - echo " sudo usermod -aG plugdev $(id -un)" >&2 - echo " then log out and back in." >&2 -fi - echo echo "== 3. Creating virtualenv and installing pyusb ==" if [[ ! -d .venv ]]; then @@ -59,16 +81,45 @@ echo "pyusb $(./.venv/bin/python -c 'import usb; print(usb.__version__)') instal echo echo "== 4. Verifying device node permissions ==" -# Resolve the bus/device path for this specific adapter. +# The node itself is the ground truth, so nothing above is warned about +# speculatively -- group membership only matters if uaccess did not already +# cover it, and that is visible here. NODE=$(lsusb -d "$VID:$PID" | head -1 | sed -E 's|Bus ([0-9]+) Device ([0-9]+).*|/dev/bus/usb/\1/\2|') ls -l "$NODE" +getfacl -p "$NODE" 2>/dev/null | grep -E "^user:[^:]+:" || true + if [[ -w "$NODE" ]]; then echo "OK: $NODE is writable by $(id -un)." else - echo "WARNING: $NODE is not writable by $(id -un)." >&2 - echo " If the group above is already 'plugdev', unplug and replug" >&2 - echo " the adapter so the new rule is applied to a fresh node." >&2 + USER_NAME=$(id -un) + echo "WARNING: $NODE is not writable by $USER_NAME." >&2 + echo >&2 + echo " uaccess grants nothing over ssh or without a local seat, so fall" >&2 + echo " back to the group. Try, in order:" >&2 + echo >&2 + echo " 1. Unplug and replug the adapter, so the rule applies to a fresh" >&2 + echo " node. This is enough on a local desktop session." >&2 + if [[ -n "$ACCESS_GROUP" ]] && + ! id -nG | tr ' ' '\n' | grep -qx "$ACCESS_GROUP"; then + echo >&2 + if [[ -f /run/ostree-booted ]]; then + # usermod edits /etc/group directly, while getent also sees + # /usr/lib/group through nss_altfiles. On rpm-ostree systems the + # group is often only in the latter, so it looks present to every + # query and still fails to add: "group '$ACCESS_GROUP' does not + # exist". Copying the line across is what makes usermod agree. + echo " 2. This is an rpm-ostree system (Bazzite/Silverblue), where" >&2 + echo " '$ACCESS_GROUP' may exist only in /usr/lib/group. usermod" >&2 + echo " reads /etc/group alone and will refuse. Copy it over:" >&2 + echo " grep -E '^$ACCESS_GROUP:' /usr/lib/group | sudo tee -a /etc/group" >&2 + echo " then:" >&2 + else + echo " 2. Add yourself to the group:" >&2 + fi + echo " sudo usermod -aG $ACCESS_GROUP $USER_NAME" >&2 + echo " and log out and back in for it to take effect." >&2 + fi fi echo