Changed some defaults and warn on bad settings. Added bazzite support for setup.sh USB perms.

This commit is contained in:
Jeremy Karst 2026-08-23 18:59:51 -04:00
parent 47bc23cd16
commit b1a405e25d
4 changed files with 245 additions and 66 deletions

130
README.md
View file

@ -128,12 +128,42 @@ Idempotent. It installs a udev rule, creates `.venv`, and installs pyusb. It
needs `sudo` for the udev rule only. needs `sudo` for the udev rule only.
The rule is needed because the CH347's USB node defaults to `root:root 0664`. 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 Python 3.14 here is PEP 668 externally-managed, hence the venv rather than a
system-wide `pip install`. system-wide `pip install`.
@ -147,12 +177,13 @@ system-wide `pip install`.
| Flag | Default | Meaning | | 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* | | `--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 | | `--duration` | 0 | seconds, 0 = until Ctrl-C |
| `--output` | timestamped | CSV path | | `--output` | timestamped | CSV path |
| `--address` | autodetect | skip the scan | | `--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 | | `--scan-only` | — | scan and exit |
Exit status is non-zero if an interval was missed. The partial capture is kept 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 | | 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 | | `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 | | `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 | | `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 ### Recommended configuration
**Cycle count 50 at 750 kHz**, which is what the defaults now do. That runs the **Cycle count 100 at 750 kHz**, which is what the defaults do. That runs the
sensor at its own ceiling of ~534 Hz, with TMRC held fast so the cycle count sensor at its own ceiling of ~282 Hz, with TMRC held fast so the cycle count
governs and the duty cycle stays near 100%. governs and the duty cycle stays at 94%.
```bash ```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 It sits deliberately between the two things that pull in opposite directions:
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.
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: irreducible I2C traffic per sample is:
| bus | traffic | share of period | | bus | traffic | share of period |
|---|---|---| |---|---|---|
| 100 kHz | 1.500 ms | **80%** | | 100 kHz | 1.500 ms | **42%** |
| 400 kHz | 0.375 ms | 20% | | 400 kHz | 0.375 ms | 11% |
| 750 kHz | 0.200 ms | **11%** | | 750 kHz | 0.200 ms | **6%** |
750 kHz is also the fastest measured, not merely the least bus traffic. Host 750 kHz is also the fastest measured, not merely the least bus traffic. Host
cost per sample, three runs each at cycle count 228: 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. 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. 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 **Fall back to cycle count 200 if dither is still thin.** The assumption to check
worth checking is that cc=50 stays dithered: its intrinsic noise is 0.58 LSB, is that cc=100 stays dithered: its intrinsic noise is 0.79 LSB against a
and simulation puts the failure point below ~0.2 LSB, so there is roughly 3x of simulated failure point below ~0.2 LSB, so there is roughly 4× of margin at the
margin. `characterize.py` prints `sd/LSB` per axis, which settles it from the spec noise figure. That margin shrinks if the real sensor is quieter than spec,
first capture — below ~0.35 and averaging stops recovering sub-LSB resolution, which is the entire point of the quiet-site experiment.
at which point 100 (1.17 LSB) or 200 (1.10 LSB) buys the margin back at the cost `characterize.py` prints `sd/LSB` per axis, which settles it from the first
of bandwidth. 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 | | cycle count | rate | Nyquist | nT/LSB | dither | duty | |
|---|---|---|---|---|---| |---|---|---|---|---|---|---|
| **50** | **534 Hz** | **267 Hz** | 50.37 | **0.58** | 89.0% | | 50 | 534 Hz | 267 Hz | 50.37 | 0.58 | 89.0% | spectrum, thin dither |
| 100 | 281 Hz | 141 Hz | 26.17 | 0.80 | 93.7% | | **100** | **282 Hz** | **141 Hz** | **26.17** | **0.79** | **94.2%** | **default** |
| 200 | 145 Hz | 73 Hz | 13.35 | 1.10 | 97.0% | | 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 ### Rate model, and how TMRC is chosen

View file

@ -23,6 +23,7 @@ import csv
import os import os
import queue import queue
import sys import sys
import textwrap
import threading import threading
import time import time
from datetime import datetime from datetime import datetime
@ -33,6 +34,10 @@ import rm3100
CSV_FIELDS = ["sample_index", "system_time_unix", "x_raw", "y_raw", "z_raw", CSV_FIELDS = ["sample_index", "system_time_unix", "x_raw", "y_raw", "z_raw",
"warning"] "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 = { BUS_SPEEDS = {
20: ch347.SPEED_20KHZ, 20: ch347.SPEED_20KHZ,
100: ch347.SPEED_100KHZ, 100: ch347.SPEED_100KHZ,
@ -179,8 +184,8 @@ def parse_args():
help="I2C address, skipping the scan (default: autodetect)") help="I2C address, skipping the scan (default: autodetect)")
p.add_argument("--bus-speed", type=int, choices=[20, 100, 400, 750], default=750, 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 " help="I2C bus speed in kHz. The default cycle count runs near "
"534 Hz, where 100 kHz would spend 80%% of each period on " "282 Hz, where 100 kHz would spend 42%% of each period on "
"the bus; 750 spends 11%% (default: %(default)s)") "the bus; 750 spends 6%% (default: %(default)s)")
p.add_argument("--calibrate", type=float, default=1.0, p.add_argument("--calibrate", type=float, default=1.0,
help="seconds of loss-free samples used to measure the true " help="seconds of loss-free samples used to measure the true "
"measurement period before recording starts; the run " "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}") print(f" priority {detail}")
for note in cfg.notes: for note in cfg.notes:
print(f" note: {note}") 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: if share > 0.5:
faster = [s for s in sorted(BUS_SPEEDS) if i2c_bus_time(s) / period < 0.25] 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 "" fix = f"; {faster[0]} kHz would fit" if faster else ""
print(f" WARNING: the bus needs {share:.0%} of every period{fix}", warnings.append(f"the bus needs {share:.0%} of every period{fix}.")
file=sys.stderr)
for text in warnings:
print(textwrap.fill(text, width=79, initial_indent=" WARNING: ",
subsequent_indent=" "), file=sys.stderr)
def find_sensor(bus, address): def find_sensor(bus, address):

View file

@ -46,18 +46,18 @@ STATUS_DRDY = 0x80
EXPECTED_REVID = 0x22 EXPECTED_REVID = 0x22
# 50 buys bandwidth almost for free. Against cycle count 200 it costs 4.4% in # 100 cycle count gives a good balance of sensor duty cycle, spectrum, and post-filter noise floor.
# post-filter noise (89% duty against 97%) and nothing else, while giving 3.7x # This should be raised if data rates are higher than desired or bus speed / host speed can't support.
# the spectrum -- 267 Hz of Nyquist against 73 Hz. That matters because aliased # Lower than 50 is not recommended under any circumstances. See the README for more info.
# interference cannot be filtered out afterwards at any cycle count, so seeing DEFAULT_CYCLE_COUNT = 100
# it is worth more than a few percent of noise.
# # Not a hard floor -- the manual's is MIN_CYCLE_COUNT below -- but the point
# The one thing to verify rather than assume is dither: 50 sits at 0.58 LSB of # under which the dither margin is too thin to defend, so logger.py warns.
# intrinsic noise, which simulation puts safely in the region where averaging RECOMMENDED_MIN_CYCLE_COUNT = 50
# 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 # Table 3-1 tabulates gain and noise up to a cycle count of 400. Past that both
# 200 if that comes back low. # _gain_lsb_per_tesla and expected_noise_nt are extrapolating their fits.
DEFAULT_CYCLE_COUNT = 50 MAX_SPEC_CYCLE_COUNT = 400
# The fastest rate register. Used by default so the cycle count, not TMRC, sets # 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 # the rate -- TMRC offers only factor-of-two steps and has no effect at all once

View file

@ -3,15 +3,14 @@
# One-time system setup for the CH347 USB-I2C adapter and the RM3100 logger. # One-time system setup for the CH347 USB-I2C adapter and the RM3100 logger.
# Idempotent: safe to re-run. # Idempotent: safe to re-run.
# #
# Needs sudo for exactly one thing: installing the udev rule that grants the # Needs sudo for exactly one thing: installing the udev rule that grants this
# plugdev group access to the adapter's USB node. # user access to the adapter's USB node.
set -euo pipefail set -euo pipefail
VID=1a86 VID=1a86
PID=55db PID=55db
RULE_FILE=/etc/udev/rules.d/99-ch347.rules 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")" cd "$(dirname "$0")"
@ -26,25 +25,48 @@ lsusb -d "$VID:$PID"
echo echo
echo "== 2. Installing udev rule ==" 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 if [[ -f "$RULE_FILE" ]] && [[ "$(cat "$RULE_FILE")" == "$RULE" ]]; then
echo "$RULE_FILE already up to date, skipping." echo "$RULE_FILE already up to date, skipping."
else else
echo "Writing $RULE_FILE (needs sudo)..." echo "Writing $RULE_FILE (needs sudo)..."
printf '%s\n' "$RULE" | sudo tee "$RULE_FILE" >/dev/null printf '%s\n' "$RULE" | sudo tee "$RULE_FILE" >/dev/null
sudo udevadm control --reload-rules sudo udevadm control --reload-rules
sudo udevadm trigger --subsystem-match=usb # --action=add, not the default 'change': the uaccess builtin that sets the
# udevadm trigger returns before the rule has necessarily been applied. # 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 sudo udevadm settle
echo "Installed." echo "Installed."
fi 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
echo "== 3. Creating virtualenv and installing pyusb ==" echo "== 3. Creating virtualenv and installing pyusb =="
if [[ ! -d .venv ]]; then if [[ ! -d .venv ]]; then
@ -59,16 +81,45 @@ echo "pyusb $(./.venv/bin/python -c 'import usb; print(usb.__version__)') instal
echo echo
echo "== 4. Verifying device node permissions ==" 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 | NODE=$(lsusb -d "$VID:$PID" | head -1 |
sed -E 's|Bus ([0-9]+) Device ([0-9]+).*|/dev/bus/usb/\1/\2|') sed -E 's|Bus ([0-9]+) Device ([0-9]+).*|/dev/bus/usb/\1/\2|')
ls -l "$NODE" ls -l "$NODE"
getfacl -p "$NODE" 2>/dev/null | grep -E "^user:[^:]+:" || true
if [[ -w "$NODE" ]]; then if [[ -w "$NODE" ]]; then
echo "OK: $NODE is writable by $(id -un)." echo "OK: $NODE is writable by $(id -un)."
else else
echo "WARNING: $NODE is not writable by $(id -un)." >&2 USER_NAME=$(id -un)
echo " If the group above is already 'plugdev', unplug and replug" >&2 echo "WARNING: $NODE is not writable by $USER_NAME." >&2
echo " the adapter so the new rule is applied to a fresh node." >&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 fi
echo echo