GLaDOS Vol 8 · The Jetson Brain
ch 81 / 99
Chapter 81

GPIO on the Jetson

The loom fits every hole and proves nothing

Unbolt the Pi, drop the Jetson into the same shell, and every connector on the wiring loom seats. Both boards carry forty positions in two rows of twenty on 2.54 mm centres. Positions 1 and 17 are 3.3 V on both, 2 and 4 are 5 V on both, and the eight grounds sit at the same eight positions. The ribbon from the amplifier reaches, the servo leads reach, the I2C pair reaches. Nothing about the fit is a warning.

The map that governs those wires was built in chapter 71, and it is keyed by BCM number. BCM is Broadcom's numbering for lines on a Broadcom chip, and there is no Broadcom chip in this machine any more. Under the Jetson's header is a Tegra SoC whose pin controller numbers its lines differently, routes different peripherals to different positions, and leaves most of those peripherals switched off until a device-tree overlay turns them on. Jetson.GPIO will happily accept GPIO.BCM as a numbering mode, which sounds reassuring and is not: the library resolves a Broadcom number to the header position that number occupies on a Pi, then drives whatever the Tegra has at that position. The number survives the move as a coordinate. The function it named does not travel with it.

So the useful question is never "does BCM 12 still work". It is "position 32 held a hardware PWM channel on the old board, and what does this board have there". The one thing both boards agree on is geometry, so that is what the check is built on. Only the physical position crosses boards; every function claimed at that position has to be re-earned from the new board's own table before a single wire is trusted.

◆ Note — the bench, and where the authority lives

One Jetson Orin Nano Super developer kit on JetPack 6, with the Volume 7 loom moved over intact. Every function table in this chapter came off that board and matches NVIDIA's carrier board specification for it. Carrier revisions and JetPack releases do move header functions around, and the authority on yours is the board's own tool: sudo /opt/nvidia/jetson-io/jetson-io.py lists exactly which pin groups this kernel can enable and what each position becomes when it does. Exercise 1 turns that listing into the table this chapter hard-codes, so run it before you trust a number here.

What a wire needs from the hole it sits in

▣ Build · stage 1 — the claim map, re-keyed to the only thing that travels
# labs/jetson_pins.py
from labs.hardware_map import BCM_TO_PHYSICAL, extract_claims, load_config

# What each Volume 7 claim needs its pin to DO, which is not what it was called.
NEEDS = {"bus:i2c1": "i2c", "bus:spi0": "spi", "bus:spi4": "spi"}


def need_of(owner: str) -> str:
    """The function an owner requires from whatever position it lands on."""
    if owner.startswith("servo:"):
        return "pwm"
    if owner.startswith("i2s:"):
        return "i2s"
    return NEEDS.get(owner, "gpio")


def by_position(claims: dict[int, list[str]]) -> dict[int, list[tuple[str, str]]]:
    """The Volume 7 map re-keyed by header position, each owner with its need."""
    placed: dict[int, list[tuple[str, str]]] = {}
    for pin, owners in claims.items():
        placed[BCM_TO_PHYSICAL[pin]] = [(owner, need_of(owner)) for owner in owners]
    return placed


if __name__ == "__main__":
    for position, owners in sorted(by_position(extract_claims(load_config())).items()):
        for owner, need in owners:
            print(f"physical {position:>2}  needs {need:<4}  {owner}")
$ uv run python -m labs.jetson_pins
physical  3  needs i2c   bus:i2c1
physical  5  needs i2c   bus:i2c1
physical  7  needs spi   bus:spi4
physical 11  needs pwm   servo:bench_test
physical 12  needs i2s   i2s:amp_max98357a:bclk
physical 19  needs spi   bus:spi0
physical 21  needs spi   bus:spi0
physical 23  needs spi   bus:spi0
physical 26  needs spi   bus:spi4
physical 29  needs spi   bus:spi4
physical 31  needs spi   bus:spi4
physical 32  needs pwm   servo:eye_pan
physical 33  needs pwm   servo:eye_tilt
physical 35  needs i2s   i2s:amp_max98357a:lrclk
physical 40  needs i2s   i2s:amp_max98357a:din

Two translations happen in that function and both are the point. BCM_TO_PHYSICAL converts the old map's key into the coordinate the two boards share, which is the only fact from chapter 71 that survives untouched. Then need_of throws away the old board's vocabulary. A claim called bus:spi4 does not need SPI controller number four, it needs some SPI controller; servo:eye_pan does not need Broadcom PWM channel 0, it needs a pulse train it did not have to generate in software. Reducing every claim to one of five words is what makes the next comparison possible at all, because the two boards share no peripheral names whatsoever.

▣ Build · stage 2 — the new board, declared the same way the old one was
{
  "board": "jetson-orin-nano-super",
  "header": "40 positions, the same geometry as the Pi's",
  "power": {"3v3": [1, 17], "5v": [2, 4], "gnd": [6, 9, 14, 20, 25, 30, 34, 39]},
  "groups": {
    "i2c-7": {"function": "i2c", "pins": [3, 5],               "node": "/dev/i2c-7",     "pinmux": false},
    "i2c-1": {"function": "i2c", "pins": [27, 28],             "node": "/dev/i2c-1",     "pinmux": false},
    "spi0":  {"function": "spi", "pins": [19, 21, 23, 24, 26], "node": "/dev/spidev0.0", "pinmux": true},
    "spi1":  {"function": "spi", "pins": [13, 16, 18, 22, 37], "node": "/dev/spidev1.0", "pinmux": true},
    "i2s0":  {"function": "i2s", "pins": [12, 35, 40],         "node": "hw:APE,0",       "pinmux": true},
    "pwm":   {"function": "pwm", "pins": [15, 33],             "node": "/sys/class/pwm", "pinmux": true}
  },
  "pins": {
    "3":  "I2C SDA",   "5":  "I2C SCL",   "7":  "GPIO09",    "8":  "UART TX",
    "10": "UART RX",   "11": "UART RTS",  "12": "I2S0 SCLK", "13": "SPI1 SCK",
    "15": "GPIO12",    "16": "SPI1 CS1",  "18": "SPI1 CS0",  "19": "SPI0 MOSI",
    "21": "SPI0 MISO", "22": "SPI1 MISO", "23": "SPI0 SCK",  "24": "SPI0 CS0",
    "26": "SPI0 CS1",  "27": "I2C SDA",   "28": "I2C SCL",   "29": "GPIO01",
    "31": "GPIO11",    "32": "GPIO07",    "33": "GPIO13",    "35": "I2S0 FS",
    "36": "UART CTS",  "37": "SPI1 MOSI", "38": "I2S0 SDIN", "40": "I2S0 SDOUT"
  }
}

Read the pinmux flag before anything else, because it has no counterpart on the Pi. Both I2C buses are live the moment the board boots. The two SPI controllers, the I2S block and the PWM controllers are not: their positions come up as ordinary GPIO lines, and they stay that way until a device-tree overlay reassigns them. On a Pi the equivalent was a line in a boot config that turned an overlay on. Here the overlay is generated by jetson-io, it is written into the boot partition, and it takes a reboot. The consequence that catches people is in the pins lists: enabling a group claims all of its positions at once, whether or not you wired them. Turning on spi0 costs five positions to use three signals.

Fifteen claims, checked one position at a time

▣ Build · stage 3 — the verdict, per claim, with the reason attached
# labs/jetson_pins.py — continued
import json
from collections import defaultdict
from pathlib import Path

BOARD_PATH = Path("configs/boards/jetson-orin-nano.json")


def group_of(board: dict, position: int, need: str) -> tuple[str | None, dict | None]:
    """The group on this board that puts `need` at `position`, if there is one."""
    for name, group in board["groups"].items():
        if group["function"] == need and position in group["pins"]:
            return name, group
    return None, None


def verdict(board: dict, position: int, need: str) -> tuple[str, str]:
    function = board["pins"].get(str(position))
    if function is None:
        return "MOVE", f"position {position} carries no signal on this board"
    name, group = group_of(board, position, need)
    if group is None:
        return "MOVE", f"position {position} is {function}, no {need} here"
    if group["pinmux"]:
        return "PINMUX", f"{name} at {group['node']}, off until jetson-io enables it"
    return "KEEP", f"{name} at {group['node']}"


def revalidate(board: dict, config: dict) -> str:
    placed = by_position(extract_claims(config))
    lines = [f"GLaDOS body rev {config['revision']} claimed on {config['board']},"
             f" checked against {board['board']}", "",
             f"{'PHYS':>4}  {'NEED':<5} {'OWNER':<24} {'VERDICT':<8} ON THIS BOARD", "-" * 78]
    tally: dict[str, int] = defaultdict(int)
    for position in sorted(placed):
        for owner, need in placed[position]:
            call, why = verdict(board, position, need)
            tally[call] += 1
            lines.append(f"{position:>4}  {need:<5} {owner:<24} {call:<8} {why}")
    return "\n".join(lines + ["", f"{tally['KEEP']} keep,"
                              f" {tally['PINMUX']} keep after a pinmux change,"
                              f" {tally['MOVE']} move"])


if __name__ == "__main__":
    print(revalidate(json.loads(BOARD_PATH.read_text()), load_config()))
$ uv run python -m labs.jetson_pins
GLaDOS body rev 1.0.0 claimed on raspberry-pi-4b, checked against jetson-orin-nano-super

PHYS  NEED  OWNER                    VERDICT  ON THIS BOARD
------------------------------------------------------------------------------
   3  i2c   bus:i2c1                 KEEP     i2c-7 at /dev/i2c-7
   5  i2c   bus:i2c1                 KEEP     i2c-7 at /dev/i2c-7
   7  spi   bus:spi4                 MOVE     position 7 is GPIO09, no spi here
  11  pwm   servo:bench_test         MOVE     position 11 is UART RTS, no pwm here
  12  i2s   i2s:amp_max98357a:bclk   PINMUX   i2s0 at hw:APE,0, off until jetson-io enables it
  19  spi   bus:spi0                 PINMUX   spi0 at /dev/spidev0.0, off until jetson-io enables it
  21  spi   bus:spi0                 PINMUX   spi0 at /dev/spidev0.0, off until jetson-io enables it
  23  spi   bus:spi0                 PINMUX   spi0 at /dev/spidev0.0, off until jetson-io enables it
  26  spi   bus:spi4                 PINMUX   spi0 at /dev/spidev0.0, off until jetson-io enables it
  29  spi   bus:spi4                 MOVE     position 29 is GPIO01, no spi here
  31  spi   bus:spi4                 MOVE     position 31 is GPIO11, no spi here
  32  pwm   servo:eye_pan            MOVE     position 32 is GPIO07, no pwm here
  33  pwm   servo:eye_tilt           PINMUX   pwm at /sys/class/pwm, off until jetson-io enables it
  35  i2s   i2s:amp_max98357a:lrclk  PINMUX   i2s0 at hw:APE,0, off until jetson-io enables it
  40  i2s   i2s:amp_max98357a:din    PINMUX   i2s0 at hw:APE,0, off until jetson-io enables it

2 keep, 8 keep after a pinmux change, 5 move

Two claims out of fifteen transfer with no work at all, and they are the two the amplifier and the arm depend on least visibly: the I2C pair. Both PCA9685 boards still answer at 0x40 and 0x41, on the same two wires, in the same two holes. What changed is the number in the device path. On the Pi they lived on /dev/i2c-1; here the header pair is bus 7, so every SMBus(1) in the code is now wrong and every i2cdetect -y 1 in your notes scans the wrong controller.

The three I2S wires are the luckiest result in the table. Position 12 carries the bit clock on both boards, 35 carries the word clock, 40 carries the data, and the amplifier cannot tell the difference. The wires do not move. Everything about how they get switched on does: there is no config.txt on this machine, no dtoverlay= line to add, and the ALSA card that appears once the overlay lands has a different name than the one Volume 7 recorded. Chapter 67 already refused to store a card index and matched on a name substring instead, and this is the payment for that decision.

Every Volume 7 claim scored against the Jetson header Fifteen rows, one per claimed header position. Each row gives the position number, what Volume 7 claimed it for, what the Jetson puts at that position, and the verdict. Positions 3 and 5 keep outright. Positions 12, 19, 21, 23, 26, 33, 35 and 40 keep only after a pinmux change. Positions 7, 11, 29, 31 and 32 have no such function on this board and their devices move. VOLUME 7 CLAIMS ON THE JETSON HEADER PHYS CLAIMED FOR THIS BOARD PUTS THERE VERDICT 3 bus:i2c1 I2C SDA, bus 7 keep 5 bus:i2c1 I2C SCL, bus 7 keep 7 bus:spi4 (battery ADC) GPIO09, audio master clock move 11 servo:bench_test UART RTS move 12 i2s bit clock I2S0 SCLK pinmux 19 bus:spi0 (light ring data) SPI0 MOSI pinmux 21 bus:spi0 SPI0 MISO pinmux 23 bus:spi0 SPI0 SCK pinmux 26 bus:spi4 (its clock wire) SPI0 CS1, a chip select pinmux 29 bus:spi4 GPIO01 move 31 bus:spi4 GPIO11 move 32 servo:eye_pan GPIO07, no PWM controller move 33 servo:eye_tilt GPIO13, PWM capable pinmux 35 i2s word clock I2S0 FS pinmux 40 i2s data I2S0 SDOUT pinmux
Figure 81.1 — Position 26 is the row to study. It gets a PINMUX verdict because the Jetson does put SPI there, but it is a chip select, and the wire in that hole is the converter's clock. A group-level answer says the position can carry the function; it never says the wire in it carries the right signal of that bus.
▣ Build · stage 4 — where the movers go, from the same table
# labs/jetson_pins.py — continued
def destinations(board: dict, need: str) -> list[str]:
    """Every group that can serve a need, and what taking it costs."""
    return [f"{name:<6} {str(group['pins']):<22} {group['node']:<16}"
            f" {'pinmux required' if group['pinmux'] else 'on by default'}"
            for name, group in board["groups"].items() if group["function"] == need]
$ uv run python -m labs.jetson_pins --where-to spi
spi0   [19, 21, 23, 24, 26]   /dev/spidev0.0   pinmux required
spi1   [13, 16, 18, 22, 37]   /dev/spidev1.0   pinmux required
$ uv run python -m labs.jetson_pins --where-to pwm
pwm    [15, 33]               /sys/class/pwm   pinmux required

Two SPI controllers for two SPI devices, so the battery converter takes spi0 and gets a real chip select at position 24, which it never had on the Pi. The light ring takes spi1, and its single data wire moves from position 19 to position 37. Four wires re-seated for the converter, one for the ring.

The PWM answer is the interesting one. Two positions on this entire header can be driven by a hardware PWM controller, and the body has three servos. There is no arrangement of those two positions that holds three pulse trains, so the fix is not another pin. It is the chip already sitting on the two wires that kept their holes: a PCA9685 generates sixteen independent pulse outputs from an I2C register write, the arm board uses four of its sixteen, and the auxiliary board added in chapter 71 is empty. Three servos become channels 0, 1 and 2 on hardware that does its own timing and cannot tell which SoC is talking to it.

// configs/hardware.json — revision 2.0.0, every pin number now a header position
{
  "board": "jetson-orin-nano-super",
  "revision": "2.0.0",
  "numbering": "physical",
  "servos": {},
  "i2s": {"amp_max98357a": {"pin_sclk": 12, "pin_fs": 35, "pin_dout": 40}},
  "buses": {
    "spi0":  {"pins": [19, 21, 23, 24, 26], "exclusive": false,
              "devices": ["adc:battery"]},
    "spi1":  {"pins": [13, 16, 18, 22, 37], "exclusive": true,
              "devices": ["led:eye_ring"]},
    "i2c-7": {"pins": [3, 5], "exclusive": false,
              "devices": ["pca9685_arm@0x40", "pca9685_aux@0x41"]}
  },
  "arm":  {"driver": "pca9685_arm",
           "channels": {"shoulder": 0, "elbow": 1, "wrist": 2, "gripper": 3}},
  "eyes": {"driver": "pca9685_aux",
           "channels": {"eye_pan": 0, "eye_tilt": 1, "bench_test": 2}},
  "audio": {"mic_name_substr": "ReSpeaker", "output_name_substr": "APE"}
}
# labs/hardware_map.py — the one edit the old validator needed
    for name, device in config.get("i2s", {}).items():
        for key, pin in device.items():
            if key.startswith(("bcm_", "pin_")):     # both prefixes are four characters
                claims[pin].append(f"i2s:{name}:{key[4:]}")
$ uv run python -m labs.jetson_pins --report
GLaDOS body rev 2.0.0 on jetson-orin-nano-super

PHYS  FUNCTION    OWNER
------------------------------------------------
   3  I2C SDA     bus:i2c-7
   5  I2C SCL     bus:i2c-7
  12  I2S0 SCLK   i2s:amp_max98357a:sclk
  13  SPI1 SCK    bus:spi1
  16  SPI1 CS1    bus:spi1
  18  SPI1 CS0    bus:spi1
  19  SPI0 MOSI   bus:spi0
  21  SPI0 MISO   bus:spi0
  22  SPI1 MISO   bus:spi1
  23  SPI0 SCK    bus:spi0
  24  SPI0 CS0    bus:spi0
  26  SPI0 CS1    bus:spi0
  35  I2S0 FS     i2s:amp_max98357a:fs
  37  SPI1 MOSI   bus:spi1
  40  I2S0 SDOUT  i2s:amp_max98357a:dout

15 positions claimed, 13 free: [7, 8, 10, 11, 15, 27, 28, 29, 31, 32, 33, 36, 38]

PASS — no position claimed twice, no bus oversubscribed.

Fifteen positions claimed on both boards, by coincidence, and the composition is completely different: no servo holds a header position any more, and ten of the fifteen belong to two SPI groups that cost five positions each. The empty servos block is doing real work in that file. Ask the old lookup for the pan servo's pin now and it raises the same way asking for the shoulder's pin raised in chapter 71, which is the correct answer for a device that lives on a channel instead of a pin.

The ring is the hard one

The light ring came through the table looking healthy: its data wire moves one group and keeps a real SPI MOSI under it. The trouble is above the pin. Chapter 66 never wrote a WS2812B driver, it handed the job to rpi_ws281x, and that library reaches through /dev/mem to the Broadcom peripheral registers by physical address. There is no Broadcom peripheral block to address here. The library does not merely fail to work on this board, it has nothing to bind to, and no amount of pinmux changes that.

What remains portable is the technique: spell every protocol bit as three clocked SPI bits, one high for a zero and two high for a one, and let the controller shift the buffer out on its own clock while the CPU does nothing. That needs a clock rate whose pulse widths land inside the part's tolerance, and the Jetson's SPI divisors are not the Pi's. So the rate becomes something to compute and then verify.

▣ Build · stage 5 — the window the clock has to land in
# labs/ws_clock.py
import spidev

from labs.ws_timing import SPI_BITS_PER_BIT, T0_HIGH_US, T1_HIGH_US, TOLERANCE_US

ASKED_HZ = 2_400_000


def clock_window_hz() -> tuple[float, float]:
    """Rates where the one-bit pulse and the two-bit pulse both stay in tolerance."""
    low = max(1 / (T0_HIGH_US + TOLERANCE_US), 2 / (T1_HIGH_US + TOLERANCE_US)) * 1e6
    high = min(1 / (T0_HIGH_US - TOLERANCE_US), 2 / (T1_HIGH_US - TOLERANCE_US)) * 1e6
    return low, high


def encode(pixels: list[tuple[int, int, int]]) -> bytes:
    """Each colour bit becomes three SPI bits: 0b110 for a one, 0b100 for a zero."""
    bits = length = 0
    out = bytearray()
    for pixel in pixels:
        for value in pixel:                       # green, red, blue
            for i in range(7, -1, -1):
                bits = (bits << 3) | (0b110 if value >> i & 1 else 0b100)
                length += 3
                while length >= 8:
                    length -= 8
                    out.append((bits >> length) & 0xFF)
    return bytes(out)


if __name__ == "__main__":
    low, high = clock_window_hz()
    print(f"a zero is 1 clocked bit high, a one is {SPI_BITS_PER_BIT - 1}")
    print(f"window: {low:,.0f} Hz to {high:,.0f} Hz")
    print(f"asked for {ASKED_HZ:,} Hz: {'inside' if low <= ASKED_HZ <= high else 'OUTSIDE'}")

    spi = spidev.SpiDev()
    spi.open(1, 0)                                 # /dev/spidev1.0, the group the ring moved to
    spi.max_speed_hz = ASKED_HZ
    got = spi.max_speed_hz                         # what the driver actually set, not what you asked
    print(f"driver set {got:,} Hz: {'inside' if low <= got <= high else 'OUTSIDE'}")
    frame = encode([(0, 0, 0)] * 24)
    print(f"24 dark pixels: {len(frame)} bytes, first four {frame[:4].hex(' ')}")
    spi.xfer2(list(encode([(255, 0, 0)] + [(0, 0, 0)] * 23)))
$ uv run python -m labs.ws_clock   # readback measured on the bench — yours will vary
a zero is 1 clocked bit high, a one is 2
window: 2,105,263 Hz to 3,076,923 Hz
asked for 2,400,000 Hz: inside
driver set 2,400,000 Hz: inside
24 dark pixels: 216 bytes, first four 92 49 24 92

The window comes straight from the part's own numbers. A zero holds the line high for 0.40 µs and a one for 0.80 µs, both specified to 0.15 µs of error, so one clocked bit has to last between 0.25 and 0.55 µs and two have to last between 0.65 and 0.95 µs. Invert those and the second constraint is much tighter than the first: anything from 2.11 to 3.08 MHz satisfies both. The Pi's 2.4 MHz sits in it with about 12 percent of margin below and 28 above, which is why the same encoding survives a board change that the driver did not.

The readback line is the part people skip. Assigning max_speed_hz is a request; the controller divides a parent clock and takes the nearest divisor it can, and reading the attribute back tells you where it landed. Compare that number against the window every run and a controller that quietly rounds to 3.2 MHz becomes a printed failure instead of a ring that flickers on one build and not another. The frame itself is unchanged from Volume 7: 216 bytes for 24 pixels, and 92 49 24 is what twenty four zero bits look like once each has grown to three.

◆ Note — what installing the GPIO library actually installs

Jetson.GPIO is two things in one package. uv add Jetson.GPIO puts the Python module in the workspace and stops there. The package also carries a udev rule that grants the gpio group access to /dev/gpiochip*, and nothing in a virtual environment install places it. Copy 99-gpio.rules from the installed package into /etc/udev/rules.d/, run sudo udevadm control --reload-rules and sudo udevadm trigger, then sudo usermod -aG gpio $USER and log out. Skip it and every call fails with a permission error on the chardev, which reads like a broken library and is a missing file.

Why this works: the key stayed, its meaning moved

The conflict checker from chapter 71 ran on the new config without a single edit, and and the reason matters. That function never knew what its integer keys meant. It required that no two owners share one, and counted. When the integer stopped naming a Broadcom line and started naming a hole in a connector, the requirement was still true and still checkable, so the code was still correct. Every part of Volume 7 that broke on this board is a part where a number carried an implied capability: BCM 12 meant "hardware PWM", /dev/i2c-1 meant "the header pair", rpi_ws281x meant "there is a Broadcom peripheral at this address".

That split is the transferable move. Keep identity and capability in separate places, and derive capability from the platform you are actually on rather than from the name of the thing. A port number belongs in a config file while the bound socket has to be opened again on every start, and a file path can be committed to git while the file descriptor never can. Anything you can carry between machines is an identifier. Anything the machine grants you is a capability, and it has to be requested again on arrival.

The pinmux column is where this gets sharper than most platform migrations, because the Jetson's positions do not have one function to look up. Position 33 is a GPIO line or a PWM output depending on a device tree the kernel read at boot, and the same position can answer differently after a reboot with no wiring change and no code change. A table that says what a pin is has to be read as what a pin is right now, which is exactly why stage 3 prints its reason next to every verdict instead of returning a boolean.

⚠ Worked failure — the setup call succeeded, so the pin must be fine

Before any of the table work, the migration looks like a one-line edit. Swap the import in the Volume 7 eye script, switch to BOARD numbering since the physical positions are the part that transfers, and run it on the Jetson:

# labs/eye_centre.py — the Volume 7 script, one import different
import Jetson.GPIO as GPIO                 # was: import RPi.GPIO as GPIO

GPIO.setmode(GPIO.BOARD)
GPIO.setup(32, GPIO.OUT)                   # eye_pan, physical 32
print("pin 32 claimed")
pwm = GPIO.PWM(32, 50)                     # 50 Hz, the servo frame rate
$ uv run python -m labs.eye_centre   # measured on the bench — yours will vary
pin 32 claimed
Traceback (most recent call last):
  File ".../labs/eye_centre.py", line 8, in <module>
    pwm = GPIO.PWM(32, 50)
          ^^^^^^^^^^^^^^^^
  File ".../Jetson/GPIO/gpio.py", line 226, in _channel_to_info_lookup
    raise ValueError("Channel %s is not configured for PWM" % channel)
ValueError: Channel 32 is not configured for PWM

The first line printed. That is the whole lesson in one word: setup succeeded because position 32 is a perfectly real GPIO line on this board, and claiming it as an output is something the Tegra can do all day. PWM asks a different question. It wants a hardware PWM controller routed to that position, and the library looks for a /sys/class/pwm directory belonging to the pin. There is none, so it raises before generating anything.

Now resist the obvious repair. The message names a pinmux problem, the board does have PWM at position 15, and moving one servo lead across the header takes ten seconds. Do that and the script runs, and you are one servo further into a migration that cannot finish: the header offers two PWM positions and the body has three servos. The traceback was never about position 32. It was the first sighting of a capability the new board has less of, and a per-pin fix hides that until the third servo, by which point two are wired and calibrated. The board table answers it in one command before anything is unplugged:

$ uv run python -m labs.jetson_pins --where-to pwm
pwm    [15, 33]               /sys/class/pwm   pinmux required

Two positions, three servos, so the servos leave the header entirely. That is a design decision made from a table in a second, and the same reasoning applies to any error that names one instance of a resource: ask how many the machine has before you spend the one it just refused you.

Checkpoint, and a body waiting for the rest of her

✓ Checkpoint — what you can now do
  • I can say what a matching 40-pin header does guarantee (geometry, power and ground positions) and what it says nothing about.
  • I can explain what Jetson.GPIO's BCM mode really resolves, and why accepting the mode is not evidence the pin behaves as it did on the Pi.
  • Given a claim and a position, I can return keep, pinmux or move from the board table instead of guessing from a pin diagram.
  • I can say why GPIO.setup(32, GPIO.OUT) succeeds on this board while GPIO.PWM(32, 50) raises, and what each call is asking the kernel for.
  • I can argue why three servos become PCA9685 channels here rather than fighting over two PWM positions.
  • From the WS2812B pulse widths and their tolerance I can compute the SPI clock window and check a driver's readback against it.
  • I know why enabling spi0 costs five header positions to carry three signals.
⚡ Exercises — try first, then reveal
Exercise 1 — verify the table against your own board. Write confirm(board) that checks each declared group's device node exists, so the file stops being a claim about hardware and becomes a check of it.
from pathlib import Path


def confirm(board: dict) -> list[str]:
    """Report every group whose node is absent, which usually means pinmux is pending."""
    lines = []
    for name, group in board["groups"].items():
        node = group["node"]
        if node.startswith("/dev/") or node.startswith("/sys/"):
            state = "present" if Path(node).exists() else "ABSENT"
        else:
            state = "not a path, check with aplay -l"
        lines.append(f"{name:<6} {node:<16} {state}")
    return lines
$ uv run python -m labs.jetson_pins --confirm   # measured on the bench — yours will vary
i2c-7  /dev/i2c-7       present
i2c-1  /dev/i2c-1       present
spi0   /dev/spidev0.0   present
spi1   /dev/spidev1.0   ABSENT
i2s0   hw:APE,0         not a path, check with aplay -l
pwm    /sys/class/pwm   present

One group short, and it is the ring's. Run jetson-io, enable the second SPI group, reboot, and the line flips to present. Do this before wiring, not after: a missing node and a wrong wire look identical from Python, and only one of them is fixed by a menu.

Exercise 2 — run the check backwards. Build the same board file for the Raspberry Pi and revalidate the new Jetson config against it, to prove the check is symmetric and the incompatibility runs both ways.

Declare the Pi the same way: i2c1 at positions 3 and 5, spi0 at 19, 21, 23, 24 and 26, spi4 at 7, 26, 29 and 31, PWM at 12, 32, 33 and 35. Both chip selects belong in the spi0 group even though Volume 7 handed them back, because a board table records what the board can do and not what one build switched off. Then point revalidate at the Jetson config:

$ uv run python -m labs.jetson_pins --against raspberry-pi-4b
  13  spi   bus:spi1                 MOVE     position 13 is GPIO27, no spi here
  16  spi   bus:spi1                 MOVE     position 16 is GPIO23, no spi here
  18  spi   bus:spi1                 MOVE     position 18 is GPIO24, no spi here
  22  spi   bus:spi1                 MOVE     position 22 is GPIO25, no spi here
  37  spi   bus:spi1                 MOVE     position 37 is GPIO26, no spi here

10 keep, 0 keep after a pinmux change, 5 move

The ring's whole group is plain GPIO on a Pi, so the revision 2.0.0 wiring is as unusable there as revision 1.0.0 is here. Neither map is more correct than the other. Each is a set of claims about one board, which is the reason the board goes in the file beside them.

Exercise 3 — stop hard-coding the bus number. Find the I2C bus by asking which one answers at both PCA9685 addresses, so the same code runs on either board.
from pathlib import Path

from smbus2 import SMBus

MODE1 = 0x00


def find_bus(addresses: tuple[int, ...] = (0x40, 0x41)) -> int:
    """The first /dev/i2c-N where every address answers. Raises if none does."""
    for node in sorted(Path("/dev").glob("i2c-*")):
        number = int(node.name.split("-")[1])
        try:
            with SMBus(number) as bus:
                for address in addresses:
                    bus.read_byte_data(address, MODE1)
        except OSError:
            continue
        return number
    raise SystemExit(f"no bus answers at {[hex(a) for a in addresses]}")
$ uv run python -m labs.i2c_find   # measured on the bench — yours will vary
both PCA9685 boards answer on /dev/i2c-7

A read that raises OSError is the whole test: an address with nothing at it does not acknowledge, and the kernel turns that into an error rather than a zero. Storing the discovered number back into the config is optional and probably unwise, since the point of finding it is that it is a property of the board and not of your build.

The body is answerable again: two buses confirmed, one overlay to enable, five wires re-seated, three servos rehoused on a chip that never cared which board it was wired to. None of that has moved her yet. The voice pipeline, the memory database, the personality file and the event bus are all still on the Pi, and the models on this board have to be re-pulled against a GPU that did not exist on the old one, which is the next chapter's job.