# RM3100 logging over a CH347 USB-I2C adapter

Logs a PNI RM3100 geomagnetic sensor connected to the I2C pins of a Waveshare
USB to UART/I2C/SPI/JTAG adapter (CH347, USB `1a86:55db`).

Reference: *RM3100 & RM2100 Sensor Suite User Manual*, Doc 1017252 R07
(`PNI Sensor - RM3100-Sensor-Suite-User-Manual-R07-1.pdf` in this directory).
Section numbers below refer to it.

## Hardware

Waveshare adapter in **Mode 1** (UART1 + I2C + SPI), voltage selector at **3V3**.
The RM3100 is on a breakout board.

> 3V3 is required, though 3.0v would be ideal for the analog side.

### Wiring

All connections below are **confirmed working** — this exact wiring produces
valid data.

| Adapter | RM3100 | Notes |
|---|---|---|
| I2C VCC (3V3) | DVDD | digital supply |
| I2C VCC (3V3) | VDD (= AVDD) | analog supply — **required for measurements** |
| I2C VCC (3V3) | I2CEN | **required to select I2C over SPI** |
| I2C GND | DVSS | digital ground |
| I2C SCL | SCK/SCL | pin 27, shared with SPI SCLK |
| I2C SDA | SI/SDA | pin 1, shared with SPI MOSI |

All three 3V3 connections share the adapter's single I2C VCC pin. Current draw is
negligible — ~260 µA per axis at 24 Hz (Table 3-1).

The two supply pins and I2CEN are the non-obvious ones; both are explained below,
because omitting either produces a failure that is easy to misdiagnose.

#### I2CEN (pin 22) must be tied HIGH

`HIGH = I2C, LOW = SPI` (§4.3.1). The RM3100's SDA/SCL pins are shared with the
SPI MOSI/SCLK pins, and I2CEN is what selects between the two interfaces. Left
floating or low, the chip stays in SPI mode and **never ACKs its I2C address** —
the bus scan finds nothing at all, even though the adapter is perfectly healthy.

At DVDD = 3.3 V it needs at least `VIH = 0.7 × DVDD` = 2.31 V, so tie it
directly to 3V3.

#### AVDD/AVSS (pins 4, 5) must be powered to take measurements

§4.3.1: *"AVDD can be turned off when not making a measurement to conserve
power, since all other operations are supported with DVDD."*

So with only DVDD connected, the part is **half alive** in a way that is easy to
misread as working: the I2C address responds, REVID reads back `0x22`, and
registers write and read back correctly — but the analog section that drives the
sensor coils is dead, so DRDY never asserts and the measurement registers stay
at zero. `BIST` reports `XOK=YOK=ZOK=0` (see Diagnostics below), which is the
unambiguous tell.

**Both rails are needed — AVDD is not a substitute for DVDD.** Two constraints
(§4.3.1, Table 3-5):
- DVDD must come up **before or at the same time as** AVDD, never after. Moving
  the supply from DVDD to AVDD instead of adding it violates this.
- AVDD must stay within **0.1 V** of DVDD while on — sharing one 3V3 rail
  satisfies this for free.

#### Power quality

The manual specifies very little here, but what it does specify is firm.

| Parameter | Limit | Source |
|---|---|---|
| Ripple on AVDD or DVDD | **50 mV peak-to-peak** | Table 3-5, `VDD_ripple` |
| DVDD − AVDD while running | **±0.1 V** | Table 3-5, `ΔVDD_OP` |
| Supply range | 2.0–3.6 V (typ 3.0) | Table 3-5 |
| Absolute maximum | 3.7 V | Table 3-4 |

The ripple limit carries **no frequency qualifier** — it is stated flat, so
switching noise and mains hum are not distinguished.

Reference decoupling (Figures 4-1 and 4-2) is a **10 µF bulk capacitor in
parallel with 0.1 µF ceramic**, rail to ground. One rail feeds AVDD, both DVDD
pins, and I2CEN. That is the entire filtering specification — no ferrite, no
split analog/digital rails, no LDO requirement. §4.2.3 adds a placement rule:
*"Keep capacitors, especially tantalum capacitors, far away from the sensor
coils"* — a magnetic concern (ferromagnetic packaging), not an electrical one.

**The manual gives no transfer function from ripple to field error** — no
µT-per-mV, no PSRR, no ripple-vs-noise curve. Only the 50 mV limit.

Slow drift should matter less than fast ripple. §4.1 describes the output as
*"the difference in the time to complete the measurement for each bias"* — a
differential measurement across forward and reverse coil bias — and §2 claims
measurements are *"stable over temperature and inherently free from offset
drift."* Anything common to both half-measurements largely cancels, so supply
variation slower than one measurement cycle is rejected far better than noise
near the ~180 kHz LR oscillation. (That last step is inference from the
described architecture, not an explicit claim in the manual.)

**This rig runs at 3.3 V, but every number in Table 3-1 is quoted at 3.0 V.**
Footnote 1: *"Other bias resistors, external timing resistors and operating
voltages may be used, but performance will differ from the values listed."*
3.3 V is comfortably in range, but the 75 LSB/µT gain this driver uses is
strictly a 3.0 V figure — a candidate scale-factor error the manual does not
quantify. Unverified here: whether the breakout carries local decoupling, and
what the CH347's 3V3 rail ripple actually measures against the 50 mV limit.

#### SA0 / SA1 set the I2C address

The top 5 address bits are fixed at `0b01000`; SA0 (pin 3) and SA1 (pin 28) set
the low two, giving 0x20–0x23 (§4.5). On this breakout both are strapped high,
so the sensor answers at **0x23**. These pins are shared with SPI SSN and MISO,
so a breakout may label them `SSN/SA0` and `MISO/SA1`.

`logger.py` scans all four addresses, so no configuration is needed.

#### DRDY (pin 23) is not connected

Not required. The driver polls the STATUS register instead (§5.4.1), which the
manual explicitly offers as an alternative.

## Setup

```bash
./setup.sh
```

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:

```
SUBSYSTEM=="usb", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="55db", GROUP="plugdev", MODE="0660"
```

Python 3.14 here is PEP 668 externally-managed, hence the venv rather than a
system-wide `pip install`.

## Usage

```bash
./.venv/bin/python logger.py --duration 10      # log for 10 s
./.venv/bin/python logger.py                    # log until Ctrl-C
./.venv/bin/python logger.py --scan-only        # bus scan only, for wiring checks
```

| Flag | Default | Meaning |
|---|---|---|
| `--cycle-count` | 200 | gain/resolution vs. speed; 200 → 75 LSB/µT |
| `--tmrc` | 0x96 | continuous-mode rate; 0x92 (~600 Hz) to 0x9D (~0.3 Hz) |
| `--duration` | 0 | seconds, 0 = until Ctrl-C |
| `--output` | timestamped | CSV path |
| `--address` | autodetect | skip the scan |
| `--scan-only` | — | scan and exit |

CSV columns: `timestamp_iso, elapsed_s, x_raw, y_raw, z_raw, x_uT, y_uT, z_uT,
magnitude_uT`.

```bash
./.venv/bin/python plot.py capture_60s.csv          # -> capture_60s.png
./.venv/bin/python plot.py capture.csv --smooth 0   # raw only, no moving average
```

### Sample rate

**Cycle count, not TMRC, sets the ceiling.** Measured with `--bus-speed 400`:

| Cycle count | Measured (polled) | Table 3-1 predicts | Noise (Table 3-1) |
|---|---|---|---|
| 200 | 152 Hz | 147 Hz | 15 nT |
| 100 | 297 Hz | 283 Hz | 20 nT |
| 50 | 538 Hz | 533 Hz | 30 nT |

**The manual contradicts itself here and Table 3-1 is the correct one.** The
§5.2.1 note claims cycle count 200 allows a "~430 Hz" 3-axis update rate;
Table 3-1 gives 440 Hz *single-axis* and says to divide by 3, i.e. ~147 Hz.
Measurement backs Table 3-1 at all three cycle counts.

Bus speed matters less than expected: 400 kHz measured best (538 Hz), 100 kHz
gave 444 Hz, and 750 kHz was slightly *worse* than 400 kHz at 515 Hz. Use 400.

Skipping the DRDY poll and free-running reads reaches 1100–1460 Hz, but 62–87%
of those samples are duplicates, so it buys nothing real.

### Threading

The sampling loop does I2C and nothing else — it pushes raw counts onto a queue,
and a writer thread does unit conversion, CSV formatting, flushing and the
console display. Wall-clock timestamps are reconstructed in the writer from one
start time plus each sample's monotonic offset, rather than calling
`datetime.now()` per sample; that is both cheaper and immune to an NTP step
mid-capture.

This is worth roughly **28%**: the same 60 s capture yielded 22,558 samples
(376 Hz) with inline writing and 28,864 (481 Hz) with the writer split off.
The queue is bounded, and any drop is counted and reported rather than
silently losing data.

## Files

| File | Role |
|---|---|
| `setup.sh` | udev rule, venv, pyusb |
| `ch347.py` | CH347 I2C transport — USB only, knows nothing about the sensor |
| `rm3100.py` | RM3100 driver — registers and decoding, knows nothing about USB |
| `logger.py` | CLI: scan → identify → configure → log |
| `plot.py` | four-panel plot of a capture: X, Y, Z and the norm |
| `diagnose.py` | walks USB → bus → identity → registers → BIST → live read, stopping at the first failure |

The adapter/sensor split is deliberate: `ch347.py` is a general I2C master usable
with any device, and `rm3100.py` needs only a bus object exposing
`write(addr, data)` and `read(addr, count)`.

## Implementation notes

Things that cost time to work out, recorded so they don't have to be again.

### CH347 access

The adapter presents three USB interfaces. Interfaces 0 and 1 are CDC-ACM and
the kernel binds them as `/dev/ttyACM0` (the UART). **Interface 2** is
vendor-class and carries I2C/SPI/JTAG with no kernel driver bound, so libusb can
claim it directly with nothing to detach.

`ch347.py` deliberately never calls `set_configuration()` — the device is already
configured, and re-setting it would disturb the CDC-ACM interfaces driving the
UART.

There is no usable off-the-shelf option: no CH347 kernel driver exists (the
in-tree `spi-ch341` is for `1a86:5512`, a different chip); the PyPI `ch347`
package wraps a Windows DLL; and `ch347api` supports only HID mode (`55dc`), not
this vendor-bulk mode 1. The framing in `ch347.py` follows the
`aystarik/ch347-i2c-spi-gpio` Linux driver, whose id-table entry
`USB_DEVICE_INTERFACE_NUMBER(0x1a86, 0x55db, 0x02)` matches this device exactly.

Wire format — bulk OUT `0x06`, bulk IN `0x86`, max 63 bytes per transfer:

| Purpose | Bytes out | Bytes back |
|---|---|---|
| Set speed | `AA 6<speed> 00` | 0 |
| Write n | `AA 74 (80\|n+1) (addr<<1) <data> 75 00` | n+1 |
| Read n | `AA 74 81 ((addr<<1)\|1) [C0\|(n-1)] C0 75 00` | n+1 |
| Probe | `AA 74 81 (addr<<1) 75 00` | 1 |

**Every returned byte must be `1`** — that is the per-byte ACK. On a read, byte 0
is the address ACK and the rest is payload.

### RM3100 quirks

- **Register reads use the plain address, not `|0x80`.** §5 describes the SPI
  convention of adding 0x80, but the I2C diagram in §5.8.4 writes `0x24`
  literally. Only 7 bits are decoded, so both work; the plain form is used here.
- **A read is STOP-then-START, not a repeated START** (§4.5.2, §5.8.4), which is
  exactly what two separate CH347 transactions produce. No special handling.
- **CMM = 0x79** for all three axes. Table 5-1 describes bit 3 as reserved-zero,
  but the manual's own examples (§5.7.2, §5.8.3) set it. The examples win.
- **HSHAKE is set to 0x0A** (`DRC0=0`, `DRC1=1`) during init. The 0x1B default has
  `DRC0=1`, meaning *any* register write clears DRDY — including the pointer
  write that reading STATUS itself requires, so polling could never observe DRDY
  set. With `DRC0=0`, DRDY clears only on a results read.
- **Gain** is `0.3671 × cycle_count + 1.5` LSB/µT, a linear fit to Table 3-1
  (50→20, 100→38, 200→75; reproduces all three within a count).
- Measurements are three **24-bit big-endian two's-complement** values, read as
  9 bytes from 0x24 using the sensor's register auto-increment.

## Diagnostics

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` |
| `No CH347 adapter found` | not plugged in, or not in Mode 1 |
| Adapter opens, `--scan-only` finds nothing | **I2CEN not tied high** (most likely), or SDA/SCL swapped, or no bus pull-ups |
| Found at 0x23, REVID `0x22`, registers fine, but DRDY never sets and results are all zero | **AVDD/VDD not powered** — confirm with BIST; both of these were hit during bring-up |
| Cycle-count read-back mismatch | bus integrity — try a lower speed |

**BIST is the definitive test for the analog side** (§5.6.1). Write `0x8F` to
BIST (STE=1, max timeout and periods), write `0x70` to POLL, wait, then read
BIST back: bits 4/5/6 are XOK/YOK/ZOK, and `1` means that axis's LR oscillator
ran. All zeros means the coils are not oscillating, which points at AVDD or the
REXT timing resistor rather than anything on the I2C side.

A healthy total field magnitude is roughly **25–65 µT** (Earth's field). Near
zero, railed, or wildly out of range means the decode or gain is wrong rather
than merely "data arrived".

## Observed performance

First working capture, 10 s at TMRC 0x96, cycle count 200, sensor stationary on
a desk next to a PC:

```
349 samples over 10.02 s -> 34.8 Hz
  X: mean  +6.473 uT   sd 177 nT
  Y: mean +50.914 uT   sd 106 nT
  Z: mean +18.944 uT   sd 396 nT
  |B|: mean 54.709 uT  sd 245 nT
  sample interval: 28.78 ms, sd 1.52 ms
```

- **Rate.** 34.8 Hz against a nominal ~37 Hz is in spec: §5.2.1 quotes roughly
  7% one-standard-deviation tolerance on the update rate.
- **Magnitude.** 54.7 µT sits in the expected 25–65 µT band for Earth's field,
  which is the real confirmation that gain and the int24 decode are right.
- **Noise** is well above the 15 nT that Table 3-1 quotes for cycle count 200,
  and is not a driver problem. Most likely proximity to a PC and the USB
  adapter; a secondary candidate is ripple on the CH347's 3V3 rail against the
  50 mV limit (see Power quality above), which has not been measured. Expect far
  better readings away from mains wiring and switching supplies.
- **Drift.** All three axes drifted monotonically down over the 10 s (Z most, by
  about 1.3 µT), so |B| fell from 55.5 to 54.3. Consistent with thermal settling
  after AVDD is first powered — coil DC resistance moves 0.4 %/°C (Table 3-3).
  Worth allowing a warm-up period before trusting absolute values.

None of this is calibrated: the figures are raw sensor output with no hard- or
soft-iron correction, so the individual axis values reflect local distortion as
much as Earth's field.

### 60 s capture at maximum rate

`capture_60s.csv` / `capture_60s.png` — 28,864 samples, 481 Hz, cycle count 50,
400 kHz bus. The sensor was nudged by hand at t ≈ 41 s, so statistics are split
around that to keep the noise figures honest:

| Axis | sd, 0–40 s | sd, 47–60 s | step across the event |
|---|---|---|---|
| X | 84 nT | 85 nT | +280 nT |
| Y | 388 nT | 376 nT | +217 nT |
| Z | 187 nT | 198 nT | +232 nT |
| \|B\| | 369 nT | 358 nT | +313 nT |

Three things worth noting:

- **The t ≈ 41 s step was the sensor being physically moved**, confirmed at the
  time — not an electrical artefact. Note that a norm shift does *not* by itself
  imply an external source changed: |B| is preserved under **rotation** in a
  uniform field, but a **translation** through a field gradient samples a
  different local field and changes the magnitude. Near a PC the gradients are
  steep (§4.2.2: field falls off as 1/distance³), so a few centimetres is ample
  to produce the +313 nT seen here. Treat |B| changes as "not a pure rotation",
  nothing more.
- **Noise is strongly axis-dependent** — Y is 4.6× X (388 vs 84 nT), and stable
  across the event, so it is not a consequence of the disturbance. The coils are
  nominally identical, so this asymmetry points at orientation relative to a
  local noise source rather than a sensor fault. X at 84 nT is within ~3× of the
  30 nT Table 3-1 quotes for cycle count 50; Y is ~13×.
- **Sample timing is jittery**: intervals average 2.08 ms (481 Hz) with sd
  1.09 ms and a 19.6 ms worst case. Fine for logging, but the irregular spacing
  makes this data unsuitable for spectral analysis without resampling.

## Status

- [x] udev rule and venv setup
- [x] CH347 vendor protocol — verified byte-for-byte against the kernel driver
- [x] I2C bus scan — sensor found at 0x23
- [x] Sensor identified — REVID `0x22`
- [x] Register write/read verified — cycle counts written and read back
- [x] Analog section verified — BIST reports `XOK=YOK=ZOK=1`
- [x] **Live measurements logging to CSV at ~35 Hz**

Initial communication is complete. A proper Python API is planned; these modules
are the initial-communication milestone, deliberately kept simple.
