diff --git a/config.example.toml b/config.example.toml index 0b4ca41..655445b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -66,6 +66,14 @@ description = "Basestation over WiFi" transport = "spi" # [profiles.aio.spi] # en_pins = [27] # the AIO v2 powers its radio from pin 27 +# +# Which /dev/gpiochip carries the pins is not a fact about the board: a uConsole's CM4 +# has its 40-pin header on chip 0, a CM5 had it on chip 4 under its early kernels, chip 0 +# once Raspberry Pi aliased it there, and another number again on some later kernels. So +# gpio_chip defaults to -1, which finds the chip by its label (pinctrl-rp1 on a CM5, +# pinctrl-bcm2711 on a CM4; `gpiodetect` lists them) and falls back to chip 0 where none +# carries either. Give it a number only to override that: +# gpio_chip = 4 # Any other board states its whole wiring — every pin and both switches, because a key # left out keeps the AIO's value. Read them off the board's meshtasticd config; the diff --git a/docs/configuration.md b/docs/configuration.md index 78aae8d..5d290ce 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -106,11 +106,16 @@ map one to one: | `TXen`, `RXen` | `txen_pin`, `rxen_pin` | | `DIO2_AS_RF_SWITCH: true` | `use_dio2_rf = true` | | `DIO3_TCXO_VOLTAGE: true` | `use_dio3_tcxo = true` (1.8 V) | -| `gpiochip: 4` | `gpio_chip = 4` | +| `gpiochip: 4` | usually nothing — see below; `gpio_chip = 4` to pin it | -`Module` must be `sx1262`; that is the chip MeshTerm's node drives. `gpio_chip` is `0` on -a Compute Module 4 but differs on a Raspberry Pi 5 and a CM5 — `gpiodetect`, or -`ls /dev/gpiochip*`, shows which chip carries the 40-pin header. +`Module` must be `sx1262`; that is the chip MeshTerm's node drives. **Leave `gpio_chip` out** +unless you need to override it: a `meshtasticd` file pins a number because Meshtastic can't +look the chip up, but the number that carries a Raspberry Pi's 40-pin header differs +between a Pi 4/CM4 and a Pi 5/CM5 and has moved between kernel releases on the latter. +Left out, it defaults to `-1`, and MeshTerm finds the header's chip by its label +(`pinctrl-rp1`, `pinctrl-bcm2711`, `pinctrl-bcm2835`) — falling back to chip `0` on a board +whose chips carry none of them. `gpiodetect` lists every chip with its label. The +[uConsole guide](uconsole.md#which-gpio-chip) has the whole story. **State every pin and both switches.** Where a `meshtasticd` file leaves a key out, it means "none" or "off". Where the `spi` table leaves a key out, it means *the AIO's value* — IRQ on diff --git a/docs/uconsole.md b/docs/uconsole.md index e625c91..e47425e 100644 --- a/docs/uconsole.md +++ b/docs/uconsole.md @@ -35,6 +35,7 @@ That's [further down](#always-on-the-bridge) — most people want the section ab - [Step 4 — Connect](#step-4--connect) - [Moving over from the bridge (0.9.0 and earlier)](#moving-over-from-the-bridge-090-and-earlier) - [Board wiring](#board-wiring) + - [Which GPIO chip](#which-gpio-chip) - [Troubleshooting](#troubleshooting) - [Always on: the bridge](#always-on-the-bridge) - [What this has been tested on](#what-this-has-been-tested-on) @@ -47,7 +48,7 @@ That's [further down](#always-on-the-bridge) — most people want the section ab | Part | Notes | | --- | --- | -| ClockworkPi **uConsole** (CM4 module) | Any uConsole with a Compute Module 4. | +| ClockworkPi **uConsole** with a Raspberry Pi **CM4 or CM5** | Both cores work the same way; MeshTerm finds the GPIO chip behind the header on either (see [Which GPIO chip](#which-gpio-chip)). ClockworkPi's own A-04, A-06 and R-01 cores are not supported. | | hackergadgets **AIO** expansion board | The board this guide is written for and the one we recommend: an SX1262 on SPI bus 1, plus GPS, an RTL-SDR and a USB hub you don't need here. MeshTerm's SPI defaults are its wiring (AIO v1; the v2 needs one extra setting — see [Board wiring](#board-wiring)). | | An antenna for your region's LoRa band | Required. | @@ -61,8 +62,8 @@ for that part. Linux-only — driving a radio on the host's own SPI bus is not something Windows or macOS can do. - A `/dev/spidev*` node. This needs `dtoverlay=spi1-1cs` in `/boot/firmware/config.txt`. -- `/dev/gpiochip0`, and membership of the `spi` and `gpio` groups so you can read and write - both without root. +- The GPIO character devices (`/dev/gpiochip*`), and membership of the `spi` and `gpio` + groups so you can read and write both without root. - The radio library, **`openhop-core[hardware]` 1.1.3 or newer** — [Step 2](#step-2--install-the-radio-library) gets you there. It doesn't have to be in MeshTerm's own Python; MeshTerm looks in several places for it. @@ -99,12 +100,14 @@ sudo reboot After it comes back, check the device nodes exist and that you can use them: ```bash -ls -l /dev/spidev* /dev/gpiochip0 +ls -l /dev/spidev* /dev/gpiochip* groups ``` -You should see `/dev/spidev1.0` (or another `spidev*` node) and `/dev/gpiochip0` listed, -and `spi` and `gpio` in your `groups` output. +You should see `/dev/spidev1.0` (or another `spidev*` node) and at least one `gpiochip` +listed, and `spi` and `gpio` in your `groups` output. Which `gpiochip` carries the radio's +pins depends on your core and your kernel; MeshTerm works that out itself (see [Which GPIO +chip](#which-gpio-chip)). --- @@ -275,7 +278,7 @@ en_pins = [27] # the AIO v2 needs this | --- | --- | --- | | `bus_id`, `cs_id` | `1`, `0` | Which SPI bus and chip-select the radio is on (`/dev/spidev.`). | | `cs_pin` | `-1` | A GPIO driven as chip select by hand, instead of the bus's own. | -| `gpio_chip` | `0` | Which `/dev/gpiochip` the pins below are on. | +| `gpio_chip` | `-1` | Which `/dev/gpiochip` the pins below are on. `-1` finds the chip behind the 40-pin header by its label — see [Which GPIO chip](#which-gpio-chip). | | `use_gpiod_backend` | `false` | Drive the pins through `gpiod` instead of `python-periphery`. | | `reset_pin`, `busy_pin`, `irq_pin` | `25`, `24`, `26` | The chip's reset, busy and interrupt (DIO1) lines. | | `txen_pin`, `rxen_pin` | `-1`, `-1` | An external RF switch's transmit/receive-enable lines, if the board has one. | @@ -291,6 +294,38 @@ Radio settings — frequency, bandwidth, spreading factor, coding rate, TX power **not** in this table. They're the node's own, on Device config, as noted in [What you need](#what-you-need). +### Which GPIO chip + +The AIO's reset, busy and interrupt lines are GPIO 25, 24 and 26 on either core — the pin +numbers don't change. What changes is *which* `/dev/gpiochip` Linux puts them on: + +| Core | The header's GPIO controller | Its chip number | +| --- | --- | --- | +| CM4 | `pinctrl-bcm2711` | `0` | +| CM5, early kernels | `pinctrl-rp1` | `4` | +| CM5, kernels since Raspberry Pi aliased the RP1 to chip 0 ([raspberrypi/linux#6144](https://github.com/raspberrypi/linux/pull/6144)) | `pinctrl-rp1` | `0` | +| CM5, some later kernels | `pinctrl-rp1` | another number again, as the order the drivers load in moved it | + +So a chip number is not a fact about the board. **MeshTerm looks the chip up by its label +instead**: with `gpio_chip` left at its default of `-1`, it asks each `/dev/gpiochip` for +its label and uses the one called `pinctrl-rp1`, `pinctrl-bcm2711` or `pinctrl-bcm2835`, so +the same profile works on a CM4, a CM5, and across kernel updates. Where no chip carries one +of those labels, it falls back to chip `0`. + +To see what your system has, `gpiodetect` (from the `gpiod` package) lists every chip with +its label: + +``` +gpiochip0 [pinctrl-rp1] (54 lines) +gpiochip10 [gpio-brcmstb@107d508500] (32 lines) +… +``` + +Set `gpio_chip` to a number only to override the lookup — on a board whose header +controller has some other label, say. The [bridge](#always-on-the-bridge) does not look +chips up by label: on a CM5 it needs `MESHCORE_GPIO_CHIP` set to whatever `gpiodetect` shows +for `pinctrl-rp1`, if that isn't `0`. + --- ## Troubleshooting @@ -302,7 +337,7 @@ Radio settings — frequency, bandwidth, spreading factor, coding rate, TX power | Permission denied opening the SPI or GPIO device | `sudo usermod -aG spi,gpio $USER`, then log out and back in. | | "the meshterm-spi-bridge service has the radio" | Stop it: `systemctl --user stop meshterm-spi-bridge` (and `disable` it to keep it stopped). | | "meshcore-console has the radio open" | Close the GUI and connect again. | -| Some other program has the radio | `sudo lsof /dev/gpiochip0` shows which; only one program may hold the radio at a time. | +| Some other program has the radio | The message names the chip: `sudo lsof /dev/gpiochip` on it shows which program; only one may hold the radio at a time. | | The node starts but never answers | Check its log: `~/.meshterm/radio/spidev1.0/node.log`. | | An AIO v2 radio never comes up | It needs `en_pins = [27]` in the profile's `spi` table — see [Board wiring](#board-wiring). | @@ -400,7 +435,7 @@ what to do if it fails: | --- | --- | --- | | `node runtime` | Neither `openhop_core` nor `pymc_core` is importable by any interpreter the bridge tried. | Build the venv from [step 1](#bridge-step-1--install-the-radio-runtime), or set `MESHTERM_PYMC_PYTHON` to a python that has one. | | `SPI device` | No `/dev/spidev*` node exists, or it exists but you can't read/write it. | No node at all: add `dtoverlay=spi1-1cs` to `/boot/firmware/config.txt` and reboot. Node present but no permission: `sudo usermod -aG spi $USER`, then log out and back in. | -| `GPIO chip` | `/dev/gpiochip0` is missing or not read/write for you. | `sudo usermod -aG gpio $USER`, then log out and back in. | +| `GPIO chip` | `/dev/gpiochip0` (or the chip `MESHCORE_GPIO_CHIP` names) is missing or not read/write for you. | `sudo usermod -aG gpio $USER`, then log out and back in. On a CM5, check [which chip](#which-gpio-chip) the header is on. | | `bridge service` / `radio in use` / `port 5000 busy` / `radio is free` | Tells you whether something already holds the radio: the bridge's own boot service, a running `meshcore-console`, or something else already listening on the TCP port. | Only one program may use the radio at a time — see [step 6](#bridge-step-6--connect-meshterm-to-it). If `meshcore-console` is running, close it before running the bridge. | If the first three checks all pass, you're ready to run it. @@ -568,7 +603,7 @@ Then `systemctl --user restart meshterm-spi-bridge.service`. | `MESHCORE_SPREADING_FACTOR`, `MESHCORE_BANDWIDTH`, `MESHCORE_CODING_RATE` | the modem preset — all three must match the mesh you are joining. Whole numbers only: bandwidth in Hz (`62500`), coding rate as the denominator (`5` for 4/5). A decimal or `4/5` stops the bridge at startup. | | `MESHCORE_TXEN_PIN`, `MESHCORE_RXEN_PIN`, `MESHCORE_EN_PINS` | the RF-switch and power-enable lines a board may need (`-1` for none; `EN_PINS` is a comma list — the AIO v2 wants `27`) | | `MESHCORE_USE_DIO2_RF`, `MESHCORE_USE_DIO3_TCXO`, `MESHCORE_IS_WAVESHARE` | whether DIO2 drives the RF switch and DIO3 the TCXO (both on for the AIO), and a flag for a different vendor's wiring the runtime knows about | -| `MESHCORE_GPIO_CHIP`, `MESHCORE_USE_GPIOD_BACKEND`, `MESHCORE_PREAMBLE_LENGTH` | which gpiochip, whether to drive it through `gpiod`, and the LoRa preamble (leave it unset: the bridge follows MeshCore, 32 symbols up to SF8 and 16 above, and a shorter one leaves the radio deaf to most of the mesh) | +| `MESHCORE_GPIO_CHIP`, `MESHCORE_USE_GPIOD_BACKEND`, `MESHCORE_PREAMBLE_LENGTH` | which gpiochip (`0` unless set — the bridge doesn't look it up by label, so on a CM5 see [Which GPIO chip](#which-gpio-chip)), whether to drive it through `gpiod`, and the LoRa preamble (leave it unset: the bridge follows MeshCore, 32 symbols up to SF8 and 16 above, and a shorter one leaves the radio deaf to most of the mesh) | | `MESHTERM_PYMC_PYTHON` | force a specific interpreter instead of letting the bridge discover one | The bridge passes a knob only when the runtime's radio constructor accepts it, so the same @@ -597,9 +632,10 @@ install the table above is the whole of it. Either way, an AIO v2 without ## What this has been tested on -**The direct connection** has been tested against the real radio library -(`openhop-core`) with a simulated radio; it has **not yet been verified on uConsole -hardware**. +**The direct connection** has run on a bookworm uConsole with a **CM5** and an AIO v1 +since 2026-09-24, on MeshTerm's defaults. It has also been tested against the real radio +library (`openhop-core`) with a simulated radio. A CM4 has not been tried with it; its +header chip is found by the same label lookup (see [Which GPIO chip](#which-gpio-chip)). **The bridge** has been verified on a bookworm uConsole with `openhop-core` 1.1.3: the radio comes up, contacts are restored across a restart, and `info` and `contacts` answer @@ -609,8 +645,7 @@ live feed. MeshTerm's test suite does not cover the bridge. What has not been confirmed: -- **The direct connection on real hardware** — everything above has only run against a - simulated radio under the real library. +- **The direct connection on a CM4** — only the CM5 has run it. - **The bridge under `openhop_core` beyond `info` and `contacts`.** Startup, identity, contact restore and those two reads were exercised; a trace, a message and the live feed under the new runtime were not, though the library's own trace push is what the bridge diff --git a/meshterm/core/config.py b/meshterm/core/config.py index ada2d35..251dc65 100644 --- a/meshterm/core/config.py +++ b/meshterm/core/config.py @@ -81,7 +81,11 @@ class SpiWiring: bus_id: SPI bus (``/dev/spidev.``). cs_id: SPI chip-select device on that bus. cs_pin: A GPIO driven as chip select by hand, or ``-1`` for the bus's own. - gpio_chip: Which ``/dev/gpiochip`` the pins below are on. + gpio_chip: Which ``/dev/gpiochip`` the pins below are on, or ``-1`` (the default) + to find the Raspberry Pi header's controller by its label — ``pinctrl-bcm2711`` + on a CM4, ``pinctrl-rp1`` on a CM5 — rather than trust a number that differs + between the two and has moved between kernel releases on the CM5 (see + :func:`~meshterm.core.spiradio.gpio_chip`). A number pins it. use_gpiod_backend: Drive the pins through ``gpiod`` instead of ``python-periphery``. reset_pin: The chip's reset line. busy_pin: The chip's busy line. @@ -100,7 +104,7 @@ class SpiWiring: bus_id: int = 1 cs_id: int = 0 cs_pin: int = -1 - gpio_chip: int = 0 + gpio_chip: int = -1 use_gpiod_backend: bool = False reset_pin: int = 25 busy_pin: int = 24 diff --git a/meshterm/core/spiradio.py b/meshterm/core/spiradio.py index fd5b883..a896a18 100644 --- a/meshterm/core/spiradio.py +++ b/meshterm/core/spiradio.py @@ -40,7 +40,7 @@ import shutil import subprocess import sys from collections.abc import Iterable, Mapping -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path from .config import DeviceProfile, SpiWiring @@ -139,6 +139,71 @@ def spi_present(wiring: SpiWiring) -> bool: return _on_linux() and os.path.exists(wiring.spidev) +#: The labels the kernel gives the GPIO controller behind a Raspberry Pi's 40-pin header, in +#: the order they are looked for: the RP1 a Pi 5 or CM5 carries, then the BCM2711 of a Pi 4 +#: or CM4, then the BCM2835 family of the older boards. Its *number* is no fact about the +#: board: chip 0 on a CM4, chip 4 on a CM5 under the kernels it shipped with, chip 0 again +#: once Raspberry Pi aliased the RP1 there (raspberrypi/linux#6144), and on some later +#: kernels another number still, as probe order moved it. The label is what stays put. +HEADER_GPIO_LABELS = ("pinctrl-rp1", "pinctrl-bcm2711", "pinctrl-bcm2835") + +#: ``GPIO_GET_CHIPINFO_IOCTL``: ``_IOR(0xB4, 0x01, struct gpiochip_info)``, which fills a +#: 68-byte ``{char name[32]; char label[32]; u32 lines}`` for the chip the fd is open on. +_GPIO_GET_CHIPINFO_IOCTL = 0x8044B401 + + +def gpio_chip_labels(dev: Path = Path("/dev")) -> dict[int, str]: + """Each ``/dev/gpiochip``'s label as the kernel reports it, by ``n``. + + Asked of the GPIO character device itself, which needs nothing installed and only the + read access the radio's pins need anyway. Empty off Linux, and a chip that can't be + opened (no ``gpio`` group yet) is simply left out — the radio's own start reports that. + """ + try: + import fcntl + except ImportError: # not a POSIX system: no GPIO character devices to ask + return {} + labels: dict[int, str] = {} + for path in dev.glob("gpiochip[0-9]*"): + try: + number = int(path.name.removeprefix("gpiochip")) + fd = os.open(path, os.O_RDONLY) + except (ValueError, OSError): + continue + try: + info = bytearray(68) + fcntl.ioctl(fd, _GPIO_GET_CHIPINFO_IOCTL, info) + except OSError: + continue + finally: + os.close(fd) + labels[number] = bytes(info[32:64]).split(b"\0", 1)[0].decode("ascii", "replace") + return labels + + +def header_gpio_chip(labels: Mapping[int, str]) -> int | None: + """The chip carrying a Pi's 40-pin header, by its label (:data:`HEADER_GPIO_LABELS`).""" + for wanted in HEADER_GPIO_LABELS: + for number in sorted(labels): + if labels[number] == wanted: + return number + return None + + +def gpio_chip(wiring: SpiWiring) -> int: + """The ``/dev/gpiochip`` the wiring's pins are on. + + The profile's ``gpio_chip`` where it names one. Otherwise the Raspberry Pi header's + controller, found by its label — so a uConsole's CM4 and CM5 both work on the AIO's + defaults, whatever number the kernel gave the chip this boot — and chip 0 where no + controller carries a header label, which is what the default meant before it looked. + """ + if wiring.gpio_chip >= 0: + return wiring.gpio_chip + found = header_gpio_chip(gpio_chip_labels()) + return 0 if found is None else found + + def spi_radios( profiles: Mapping[str, DeviceProfile] | None, listed: Iterable[DiscoveredDevice] = () ) -> list[DiscoveredDevice]: @@ -308,7 +373,7 @@ def explain(kind: str, detail: str, wiring: SpiWiring, holder: str | None = None return "meshcore-console has the radio open — close it and connect again" return ( "another program has the radio's pins " - f"(`sudo lsof /dev/gpiochip{wiring.gpio_chip}` shows which)" + f"(`sudo lsof /dev/gpiochip{gpio_chip(wiring)}` shows which)" ) if kind == "runtime": return ( @@ -321,7 +386,7 @@ def explain(kind: str, detail: str, wiring: SpiWiring, holder: str | None = None "`dtoverlay=spi1-1cs` to /boot/firmware/config.txt) and reboot" ) if kind == "no-gpio": - return f"/dev/gpiochip{wiring.gpio_chip} does not exist — check `gpio_chip` in the profile" + return f"/dev/gpiochip{gpio_chip(wiring)} does not exist — check `gpio_chip` in the profile" if kind == "permission": return ( f"{detail} — add yourself to the spi and gpio groups " @@ -482,6 +547,9 @@ async def start_node(wiring: SpiWiring, state: Path, *, node_name: str) -> NodeP """ if not _on_linux(): raise SpiRadioError("unsupported", "a radio on the SPI bus needs Linux") + # The chip the pins are on, found by label where the profile names none: settled once + # here, so the node, every error message and the `lsof` hint all name the same chip. + wiring = replace(wiring, gpio_chip=await asyncio.to_thread(gpio_chip, wiring)) # Each of these runs a short subprocess or two; none of them belongs on the event loop # that is drawing the "connecting" card. holder = await asyncio.to_thread(radio_holder) diff --git a/tests/test_spiradio.py b/tests/test_spiradio.py index f5042be..3e35302 100644 --- a/tests/test_spiradio.py +++ b/tests/test_spiradio.py @@ -308,6 +308,42 @@ def test_no_bridge_means_nothing_carried(tmp_path: Path, monkeypatch: pytest.Mon # --- the error a person reads -------------------------------------------------------------- +@pytest.mark.parametrize( + ("labels", "chip"), + [ + ({0: "pinctrl-bcm2711", 1: "raspberrypi-exp-gpio"}, 0), # a CM4 + ({0: "gpio-brcmstb@107d508500", 4: "pinctrl-rp1"}, 4), # a CM5 on its first kernels + ({0: "pinctrl-rp1", 10: "gpio-brcmstb@107d508500"}, 0), # a CM5 after the alias + ({0: "gpio-brcmstb@107d508500", 15: "pinctrl-rp1"}, 15), # a CM5 whose chip moved + ({0: "gpio-mockup-A"}, None), # no Raspberry Pi header at all + ], +) +def test_the_header_s_gpio_chip_is_found_by_its_label(labels: dict, chip: int | None) -> None: + """The header's controller is known by its label, whatever number this kernel gave it. + + A CM4 and a CM5 put their 40-pin header on chips numbered differently, and on the CM5 + the number has moved between kernel releases, so the AIO's defaults find it by name. + """ + assert spiradio.header_gpio_chip(labels) == chip + + +def test_a_profile_s_gpio_chip_pins_it_and_the_default_looks(monkeypatch) -> None: + """A number in the profile wins; left out, the label decides, and chip 0 is the fallback.""" + monkeypatch.setattr(spiradio, "gpio_chip_labels", lambda: {15: "pinctrl-rp1"}) + assert SpiWiring().gpio_chip == -1 + assert spiradio.gpio_chip(SpiWiring()) == 15 + assert spiradio.gpio_chip(SpiWiring(gpio_chip=4)) == 4 + assert "sudo lsof /dev/gpiochip15" in spiradio.explain("busy", "", SpiWiring()) + monkeypatch.setattr(spiradio, "gpio_chip_labels", lambda: {}) + assert spiradio.gpio_chip(SpiWiring()) == 0 + + +def test_reading_the_chips_labels_never_fails(tmp_path: Path) -> None: + """Off Linux, or where a chip can't be asked, there is simply nothing to report.""" + (tmp_path / "gpiochip0").write_bytes(b"") # a plain file: the ioctl can't answer it + assert spiradio.gpio_chip_labels(tmp_path) == {} + + @pytest.mark.parametrize( ("kind", "holder", "says"), [