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.
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
# 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.
{
"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
# 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.
# 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.
# 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.
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.
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
- 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 whileGPIO.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
spi0costs five header positions to carry three signals.
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.