ZephCore Native Linux Port

July 4, 2026 · View on GitHub

Run ZephCore as a native Linux process on SBCs like the Femtofox (Luckfox Pico Mini + E22-900M30S) or a Raspberry Pi + RAK6421 HAT. The full mesh stack runs on top of Zephyr's native_sim board, talking to real SPI/GPIO via /dev/spidev* and the kernel's GPIO V2 character-device uAPI (direct ioctls — no libgpiod dependency).

The companion app connects via a TCP socket on port 5000, using MeshCore's SerialWifiInterface framing (['<'][len_LSB][len_MSB][payload] app→node, ['>']… node→app) — see Companion app connection.

Real-time clock is automatic. The binary forces the native_sim simulated clock into real-time mode at boot (equivalent to always passing --rt), because it drives a real radio whose BUSY/DIO1 timing happens in wall-clock time. You do not need to pass --rt. See adapters/transport/linux_native_setup.c.


Prerequisites

On the build host (where you run west build):

# Zephyr SDK and west workspace as per the main CLAUDE.md.

# Cross-compile toolchains (for SBC targets) — install BOTH gcc and g++:
# the mesh layer is C++, so the g++ cross-compiler is required (a build with
# only gcc fails at CMake configure with "Tell CMake where to find the compiler").
sudo apt install gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf   # Femtofox (RV1103 ARMv7-A)
sudo apt install gcc-aarch64-linux-gnu   g++-aarch64-linux-gnu     # Raspberry Pi (aarch64)

On the target SBC (where the binary runs): grant your user access to /dev/spidev* and /dev/gpiochip* so you don't need sudo/root — see Running without root below. (You can just run as root with sudo, but it's not required.)

Femtofox only: the Femtofox image ships with meshtasticd pre-installed and it holds the SPI bus / GPIO lines. Uninstall it before running ZephCore or the radio will be unavailable. Use foxbuntu-config → uninstall meshtasticd, or:

sudo systemctl stop meshtasticd
sudo apt remove meshtasticd

No userspace library dependencies — the GPIO driver talks to /dev/gpiochipN directly via the kernel's GPIO V2 chardev uAPI (ioctl). Requires Linux ≥ 5.10 (released Dec 2020 — every modern SBC distro has it). The SPI driver uses spidev the same way (no library either).

Enabling spidev on the SBC

Femtofox / Luckfox Pico Mini: the official Femtofox image ships with SPI and GPIO already enabled — /dev/spidev0.0 and /dev/gpiochip03 are present out of the box. No extra configuration needed; skip straight to adding your user to the spi/gpio groups.

Raspberry Pi: add dtparam=spi=on to /boot/firmware/config.txt (RPi 5) or /boot/config.txt (RPi 4) and reboot. Verify /dev/spidev0.0 exists.


Running without root

By default /dev/spidev* and /dev/gpiochip* are owned root:root, so the binary only runs under sudo. The binary needs no elevated privileges of its own — it only needs read/write on those two device classes (the TCP port is 5000, non-privileged; the flash file is written in your home dir). Grant access once with groups + a udev rule:

# 1. Create the groups (the Femtofox/foxbuntu image ships without them).
sudo groupadd -f spi
sudo groupadd -f gpio

# 2. Add your login user (e.g. "femto") to both.
sudo usermod -aG spi,gpio "$USER"

# 3. udev rule: give those groups access to the SPI + GPIO char devices.
sudo tee /etc/udev/rules.d/90-zephcore.rules >/dev/null <<'EOF'
KERNEL=="spidev*",                 GROUP="spi",  MODE="0660"
SUBSYSTEM=="gpio", KERNEL=="gpiochip*", GROUP="gpio", MODE="0660"
EOF

# 4. Reload udev and re-trigger so the rule applies to the existing nodes.
sudo udevadm control --reload-rules
sudo udevadm trigger --subsystem-match=spidev --subsystem-match=gpio

# 5. Log out and back in (group membership is only picked up at login).
#    Verify:  id        # should list "spi" and "gpio"
#             ls -l /dev/spidev0.0 /dev/gpiochip1    # group spi / gpio, mode 0660

Then run the binary without sudo:

./zephcore_native_linux.exe

If the udev rule doesn't stick (some minimal images use mdev/busybox instead of systemd-udev, which ignores /etc/udev/rules.d), set the permissions at boot instead — e.g. a tiny systemd unit or an rc.local line:

chgrp gpio /dev/gpiochip* && chmod 660 /dev/gpiochip*
chgrp spi  /dev/spidev*   && chmod 660 /dev/spidev*

Femtofox note: you must still stop/uninstall meshtasticd first (it holds the SPI bus) — see the Prerequisites section. Running as your user vs root doesn't change that.


Quick Start

1. Host smoke build (x86-64 Linux, no real radio)

A device preset is required (it provides the SX1262 node); the Femtofox preset is fine for a host smoke build — the SPI/GPIO opens just fail gracefully with no hardware:

rm -rf build && west build -b native_sim/native/64 zephcore -- \
  -DEXTRA_CONF_FILE="boards/linux_native/femtofox.conf"
./build/zephyr/zephcore_native_linux.exe

The binary prints something like:

UART_0 connected to pseudotty: /dev/pts/3
TCP companion transport listening on :5000

Ctrl-C to stop.

2. Femtofox (Luckfox Pico Mini, ARMv7-A)

Note: use native_sim (32-bit), not native_sim/native/64 — the Luckfox Pico Mini is ARMv7-A (32-bit) and the 64-bit variant will error at CMake configure time.

west build -b native_sim zephcore -- \
  -DZEPHYR_TOOLCHAIN_VARIANT=cross-compile \
  -DNATIVE_TARGET_HOST=arm \
  -DCROSS_COMPILE=/usr/bin/arm-linux-gnueabihf- \
  -DEXTRA_CONF_FILE="boards/linux_native/femtofox.conf"

scp build/zephyr/zephcore_native_linux.exe femto@femtofox.local:~/
ssh femto@femtofox.local ./zephcore_native_linux.exe   # no sudo once groups are set up

(No --rt flag needed — real-time mode is forced at boot. See the note at the top.)

3. Raspberry Pi + RAK6421 HAT (aarch64)

The rak6421 preset is named after the HAT, not a Pi model — the wiring is identical on every Pi. The only per-model difference is the Linux gpiochip:

  • Pi 2 / 3 / 4 / Zero 2gpiochip0rak6421.conf
  • Pi 5gpiochip4 (RP1) → rak6421_pi5.conf

The build command is unchanged by the native-Linux work this cycle — the GPIO-driver fixes, automatic --rt, and per-node persistence apply to every native build. For a 64-bit Pi OS (the usual case on Pi 3/4/5):

rm -rf build && west build -b native_sim/native/64 zephcore -- \
  -DZEPHYR_TOOLCHAIN_VARIANT=cross-compile \
  -DNATIVE_TARGET_HOST=aarch64 \
  -DCROSS_COMPILE=/usr/bin/aarch64-linux-gnu- \
  -DEXTRA_CONF_FILE="boards/linux_native/rak6421.conf"      # Pi 5: rak6421_pi5.conf
scp build/zephyr/zephcore_native_linux.exe pi@raspberrypi.local:~/

(On a 32-bit Pi OS — e.g. Pi 2, or Pi 3/4 on 32-bit Raspberry Pi OS — build the armhf way instead: -b native_sim -DNATIVE_TARGET_HOST=arm -DCROSS_COMPILE=/usr/bin/arm-linux-gnueabihf-, same -DEXTRA_CONF_FILE.)

rak6421_pi5 is just rak6421 with gpiochip4 — equivalently you can keep using rak6421.conf and override at runtime with --lora-gpio-chip=/dev/gpiochip4.

--pristine in this WSL workspace: prefer rm -rf build && over west build --pristine. --pristine can feed Windows-style D:/… paths to CMake when .west/config was initialised on Windows. rm -rf build && is equivalent and works everywhere. (On a native-Linux build host, plain --pristine is fine.)

Status: the RAK6421 preset (rak6421.conf, Pi Zero 2 W) is hardware-verified end-to-end (2026-06-03) — SX1262 init, SPI/CS, TCXO, radio-start, and over-the-air RX/TX all confirmed on real hardware, alongside the Femtofox (2026-06-02). The Pi 5 preset (rak6421_pi5.conf) shares the same wiring (only the gpiochip differs) and is build-verified but not separately hardware-tested. If the radio is dead, follow the "Radio does nothing" troubleshooting below.

The RAK13300/RAK13302 does have a DIO3-powered TCXO and uses DIO2 as the RF switch — both overlays set dio3-tcxo-voltage/dio2-tx-enable accordingly, and hold the module's two Enable_Pins (BCM 12 + 13) high via GPIO hogs (CONFIG_GPIO_HOGS=y). Pins and clock follow the RAK6421 IO Slot 1 reference wiring; see the wiring table below.

4. Repeater role (no companion)

Add boards/common/repeater.conf to the EXTRA_CONF_FILE list. The repeater CLI is accessible via the native PTY printed at boot (/dev/pts/N):

screen /dev/pts/3        # local
# or remotely:
socat /dev/pts/3 TCP-LISTEN:6000,reuseaddr,fork &
# then on a remote machine:
nc <sbc-ip> 6000

Hardware Wiring

Femtofox / Luckfox Pico Mini + E22-900M30S (TCXO)

Pin offsets are computed as (Rockchip GPIO# − 32) because the kernel splits banks into separate gpiochip devices.

SignalgpiochipOffsetRockchip GPIO
SPI bus/dev/spidev0.0 @ 2 MHz
CSgpiochip116GPIO48 (1C0)
DIO1/IRQgpiochip123GPIO55 (1C7)
BUSYgpiochip122GPIO54 (1C6)
RESETgpiochip125GPIO57 (1D1)
RXENgpiochip124GPIO56 (1D0)

SX1262 extras: DIO2 drives the RF switch (dio2-tx-enable), DIO3 powers the TCXO at 1.8V.

Source: github.com/femtofox/femtofoxfoxbuntu/.../femtofox_SX1262_TCXO.yaml.

Raspberry Pi + RAK6421 HAT + RAK13300/RAK13302 (IO Slot 1)

BCM GPIO numbers (Pi 2/3/4/Zero 2: gpiochip0rak6421.conf; Pi 5: gpiochip4rak6421_pi5.conf):

SignalBCMWisBlock IO slot 1
SPI bus/dev/spidev0.0 (CE0 = GPIO 8)
DIO1/IRQ22IO6
RESET16IO4
BUSY24IO5
RX/TX enable12, 13 (Enable_Pins; 13 = IO3 antenna switch)IO3

SX1262 extras: DIO3 powers a 1.8 V TCXO (dio3-tcxo-voltage = <SX126X_DIO3_TCXO_1V8>) and DIO2 drives the RF switch (dio2-tx-enable / DIO2_AS_RF_SWITCH: true) — same as the Femtofox. The two Enable_Pins (12, 13) are held high at boot via GPIO hogs in the overlay (CONFIG_GPIO_HOGS=y in the .conf). The RAK13302 is the 1W SKY66122-boosted variant and additionally wants a per-step TX_GAIN_LORA PA gain table; ZephCore's SX126x driver does not expose that, so this overlay drives the 13300 fully and the 13302 at default (non-boosted) PA.


Running the Binary

The binary prints its PTY path and TCP listen port at startup. Logs go to stderr; pipe them where you want them.

Companion app connection

Use the MeshCore companion app's "Connect via WiFi" (TCP/Network) mode:

  • Host: the SBC's IP address
  • Port: 5000 (default; configurable via CONFIG_ZEPHCORE_LINUX_TCP_PORT)

Wire format: MeshCore SerialWifiInterface framing (matches ESP32 Arduino reference in src/helpers/esp32/SerialWifiInterface.cpp):

  • App → Node: ['<' (0x3C)][length_LSB][length_MSB][NUS payload...]
  • Node → App: ['>' (0x3E)][length_LSB][length_MSB][NUS payload...]

Only one client connects at a time.

Repeater CLI

The repeater role exposes the same configuration CLI as an MCU repeater's USB-CDC serial console — see Repeater_CLI_commands.md for the full command list (get/set, password, reboot, clock, …). On native Linux that console maps onto Zephyr's native-PTY UART (CONFIG_SERIAL + CONFIG_UART_NATIVE_PTY). At boot the binary prints the pseudo-terminal it created:

uart connected to pseudotty: /dev/pts/3

That /dev/pts/N path is the serial console — point any serial terminal at it.

The PTY number changes on every run. Grab it from the boot output each time (or from the journal when running as a service — see below). It is not stable across restarts.

Attach locally (same machine as the binary)

screen /dev/pts/3        # detach: Ctrl-a then d ; quit: Ctrl-a then k
# or
picocom /dev/pts/3
# or
minicom -D /dev/pts/3

You get the repeater prompt directly. Logs go to the binary's stderr, not the PTY, so the CLI stays clean (no log spam interleaved with your typing).

Attach remotely (configure an SBC over the network)

Bridge the PTY to a TCP port with socat, then connect from your workstation:

# on the SBC:
socat /dev/pts/3 TCP-LISTEN:6000,reuseaddr,fork &
# from anywhere on the network:
nc <sbc-ip> 6000

When running as a systemd service

The binary runs in the background, so there's no terminal to attach to and the PTY path is only in the journal:

journalctl -u zephcore | grep pseudotty     # find the current /dev/pts/N

Because the path changes every boot and you can't screen a backgrounded process, the practical options are:

  • Expose the CLI on a fixed TCP port by adding the socat bridge as an ExecStartPost= in the unit (see Run at boot), so the console is always reachable at <sbc-ip>:6000 regardless of the PTY number. Then nc <sbc-ip> 6000 to configure.
  • Or run the repeater in the foreground (sudo systemctl stop zephcore then launch the binary by hand) just while you need to change settings, and restart the service afterward.

After changing settings, persist + apply them the same way as on an MCU repeater (most set commands take effect immediately; some require reboot). Settings survive restarts via the per-node flash file — see Persistent storage.

Run at boot (systemd service)

To start ZephCore automatically at boot and restart it if it ever exits, install a systemd service. This assumes you've already done the Running without root setup (the spi/gpio groups + udev rule), so the service can run as your normal user — no root.

Create /etc/systemd/system/zephcore.service (adjust User= and the paths to your setup):

[Unit]
Description=ZephCore native LoRa mesh
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=femto
SupplementaryGroups=spi gpio
# WorkingDirectory pins where the persistent flash file lands
# (meshcore_settings_<hostname>.bin) and must be writable by User=.
WorkingDirectory=/home/femto
ExecStart=/home/femto/zephcore_native_linux.exe
Restart=always
RestartSec=2

[Install]
WantedBy=multi-user.target

Enable + start it (and have it come back on every reboot):

sudo systemctl daemon-reload
sudo systemctl enable --now zephcore      # start now + at boot
sudo systemctl status zephcore            # check it's running
journalctl -u zephcore -f                 # follow logs (replaces stderr)

Manage it:

sudo systemctl restart zephcore
sudo systemctl stop zephcore
sudo systemctl disable zephcore           # stop auto-start at boot

Notes:

  • No --rt flag is needed — real-time mode is baked into the binary.
  • Stop meshtasticd first (Femtofox): if it's installed it grabs the SPI bus. Disable it so it doesn't race your service: sudo systemctl disable --now meshtasticd.
  • Companion (TCP) role works perfectly as a service — connect the app to <sbc-ip>:5000 as usual.
  • Repeater role: the CLI is a pseudo-terminal whose path (/dev/pts/N) is printed to the journal at boot (journalctl -u zephcore | grep pseudotty). To reach it remotely, add a bridge as ExecStartPost, e.g. ExecStartPost=/bin/sh -c 'socat ... TCP-LISTEN:6000,fork &' — or just run the repeater in the foreground when you need the CLI.
  • If you relocate the binary (e.g. to /usr/local/bin/zephcore), keep WorkingDirectory= pointed at a user-writable dir so the settings file persists.

Persistent storage

Identity, prefs, contacts, channels and BLE-equivalent settings all live in LittleFS on /lfs, which is backed by Zephyr's flash simulator. On native Linux that simulator is file-backed (not RAM), so everything survives restarts.

The backing file is named meshcore_settings_<hostname>.bin and created in the binary's working directory on first run (<hostname> comes from the Linux gethostname(), so multiple nodes on one host each get their own file). For the Femtofox that's meshcore_settings_femtofox.bin.

ls -l meshcore_settings_*.bin     # appears after the first run
  • To move a node's identity to another machine, copy this file.
  • To reset a node to a fresh identity, delete it (a new one is created next run).
  • An explicit --flash=/path/to/file.bin overrides the default name/location.
  • Migrating from an older build that used the generic flash.bin: cp flash.bin meshcore_settings_$(hostname).bin before first run to keep your existing identity.

Implementation: patches/zephyr/0008-flash-sim-per-node-file.patch makes the flash simulator default to the per-node filename instead of flash.bin.


Runtime Pin Override

For one-off hardware setups or custom wiring, override DT defaults at runtime:

./zephcore_native_linux.exe \
  --lora-spidev=/dev/spidev0.0 \
  --lora-gpio-chip=/dev/gpiochip1

Currently only the paths are runtime-overridable; pin offsets come from the DTS overlay you build with. To change pin numbers without rebuilding, create your own boards/linux_native/<device>.conf + .overlay (copy femtofox.overlay as a starting point) and pass it via -DEXTRA_CONF_FILE.

Run ./zephcore_native_linux.exe --help to see all available command-line arguments registered by the native_sim infrastructure (Zephyr drivers, our SPI/GPIO drivers, etc.).


Troubleshooting

Permission denied opening /dev/spidev0.0 or /dev/gpiochip*

Set up group access — see Running without root. On the Femtofox image the spi/gpio groups don't exist yet, so you must groupadd them and add a udev rule (just usermod is not enough). Or run with sudo.

/dev/spidev0.0: No such file or directory

SPI isn't enabled in the device tree. See "Enabling spidev on the SBC" above.

Linux kernel headers too old; GPIO V2 chardev uAPI required

The host adapter requires the GPIO V2 uAPI (kernel ≥ 5.10, late 2020). All current SBC distros (Debian 12+, Ubuntu 22.04+, Raspberry Pi OS bookworm+) ship recent enough kernels. If you hit this, your distro is ancient — upgrade.

TCP port 5000 already in use

Another service (Flask, Docker registry, AirPlay…) has port 5000. Override:

west build -- -DCONFIG_ZEPHCORE_LINUX_TCP_PORT=15000

"TCP companion client disconnected" loops

The transport expects raw NUS bytes with no length prefix. If the connection drops immediately, capture traffic with tcpdump -i any -X port 5000 and verify the first bytes the app sends look like a MeshCore opcode (e.g. 0x01 = CMD_APP_START), not an HTTP request or other framed protocol.

Failed to request line 8 / CS GPIO configure failed: -5 (Raspberry Pi)

<err> gpio_native_linux: Failed to request line 8 on host chip
<err> spi_native_linux: CS GPIO configure failed: -5
<err> sx126x_hal: SPI bus not ready
<err> sx126x: HAL init failed: -19

On the Pi, dtparam=spi=on binds the spi0 controller to GPIO 7–11 in ALT0, so CE0 = BCM GPIO 8 is owned by the SPI controller and cannot also be requested as a plain GPIO for chip-select — the request fails with -EIO. The fix is to let spidev drive CE0 in hardware (it asserts CS on every transfer) and not declare cs-gpios in the overlay. The rak6421 / rak6421_pi5 overlays do this (fixed 2026-06-03). If you copy the Femtofox overlay for a Pi setup, remove its cs-gpios line — that path only works when CS is a free GPIO (as it is on the Luckfox, GPIO48 on gpiochip1).

Radio does nothing (no RX, CAD always times out, TX never completes)

The interrupt path (DIO1) and BUSY readback are the spine of all radio ops. If they're broken at the GPIO layer, the chip stays wedged in standby and every operation fails (CAD -ETIMEDOUT, no RX, no TX-done).

Don't try to verify DIO1/BUSY with gpioget /dev/gpiochip1 23 while the binary is running — the kernel grants GPIO lines exclusively, so the binary holds them and gpioget just returns -EBUSY. Reading it with the binary stopped tells you nothing (nothing is driving the radio). Instead, build with LoRa debug logging and read the chip's own registers:

west build -- -DCONFIG_LORA_LOG_LEVEL_DBG=y

A healthy boot shows sx126x_irq_work_handler: IRQ status: … lines on RX/CAD/TX.

Things that previously broke this exact path (all fixed; listed so you recognize the symptoms if a regression appears):

  • Native GPIO driver requesting no lines (the GPIO_DISCONNECTED == 0 trap) → BUSY stuck-low, commands dropped, chip frozen.
  • Double inversion on active-low pins → RESET driven backwards, chip held in reset.
  • Missing real-time clock mode → CAD/TX/RX time out before the real radio responds (now forced automatically; do not remove linux_native_setup.c).

If a regression appears, check port_get_raw/pin_configure in gpio_native_linux.c and that linux_native_setup.c is still compiled.

bind(5000) failed: 98 (EADDRINUSE)

Another instance of the binary is already running, or you killed the last one with kill -9 and the socket is still in TIME_WAIT. SO_REUSEADDR is set so this should clear in <1 minute; or pkill zephcore_native_linux.exe.


Architecture

┌─────────────────────────────────────────────┐
│ ZephCore Mesh (C++)                         │
│   CompanionMesh / RepeaterMesh              │
└──────────────┬──────────────────────────────┘
               │ Zephyr APIs (k_event, k_msgq, k_thread)

┌──────────────▼──────────────────────────────┐
│ Zephyr RTOS @ native_sim (running as Linux  │
│ process via pthreads)                       │
│                                             │
│   • SX1262 driver (unchanged)               │
│   • New spi_native_linux driver       ──┐   │
│   • New gpio_native_linux driver      ──┤   │
│   • LinuxTCPTransport (replaces BLE)  ──┤   │
└──────────────────────────────────────────┼──┘

                              host syscalls + zsock_*

┌──────────────────────────────────────────▼──┐
│ Linux kernel                                │
│   /dev/spidevX.Y    /dev/gpiochipN          │
│   AF_INET TCP socket    /dev/pts/N          │
└─────────────────────────────────────────────┘

The mesh C++ layer and the patched SX1262 Zephyr driver are bit-identical to the MCU builds. Only the SPI/GPIO host bridges and the companion transport differ.


Limitations

  • Not BLE. The companion app must support TCP/Network mode. No BlueZ integration.
  • Not for production use per Zephyr's native_sim documentation. Works fine for hobby/lab deployments.
  • Single companion client. One TCP connection at a time, mirroring BT_MAX_CONN=1.
  • Persistent storage is file-backed and survives restarts — see Persistent storage.

Files

All native-Linux board files live under boards/linux_native/:

  • boards/linux_native/linux_common.conf + .overlay — platform base, auto-applied when BOARD=native_sim (no device wiring)
  • boards/linux_native/femtofox.conf + .overlay — Femtofox preset (SX1262 TCXO wiring)
  • boards/linux_native/rak6421.conf + .overlay — RAK6421 HAT, Pi 2/3/4/Zero 2 (gpiochip0)
  • boards/linux_native/rak6421_pi5.conf + .overlay — RAK6421 HAT, Pi 5 (gpiochip4)

These are EXTRA_CONF_FILE presets, not Zephyr boards — you still build with -b native_sim … -DEXTRA_CONF_FILE="boards/linux_native/<device>.conf", not -b <device>.

  • adapters/transport/LinuxTCPTransport.c — TCP companion transport
  • adapters/transport/linux_native_setup.c — forces real-time clock mode at boot (bakes in --rt)
  • patches/zephyr-new/drivers/spi/spi_native_linux* — spidev SPI driver
  • patches/zephyr-new/drivers/gpio/gpio_native_linux* — GPIO driver (kernel GPIO V2 chardev ioctls, no libgpiod)
  • patches/zephyr/0007-spi-gpio-native-linux.patch — wires the new drivers into Zephyr's drivers/spi/ and drivers/gpio/ CMakeLists + Kconfig
  • patches/zephyr/0008-flash-sim-per-node-file.patch — flash simulator defaults to meshcore_settings_<hostname>.bin for persistent per-node storage

Debugging Log

(Updated as issues are found during bringup.)

Verification status

End-to-end verified under WSL Ubuntu 24.04 (gcc 13.3):

  • ✅ All patches/zephyr/*.patch apply cleanly (including the new 0007-spi-gpio-native-linux.patch).
  • ✅ All files in patches/zephyr-new/ copy correctly into the Zephyr tree.
  • ✅ Platform detection routes BOARD=native_sim to boards/linux_native/linux_common.conf.
  • native_sim/native/64 builds clean → build/zephyr/zephcore_native_linux.exe (~4.3 MB ELF).
  • ✅ Binary runs. Zephyr OS boots, mesh event loop starts.
  • spi_native_linux driver loads and attempts to open /dev/spidev0.0 (fails in WSL: no SPI hardware).
  • LinuxTCPTransport listens on port 5000.
  • ✅ TCP client connect/disconnect works; SerialWifiInterface (</> + 2-byte LE length) framing parses correctly.
  • End-to-end on real Femtofox hardware (2026-06-02): radio RX, CAD (LBT), and TX all working; companion app connects and meshes; settings persist across restarts. (Required fixing two bugs in the native GPIO driver — see "Known caveats" below.)
  • End-to-end on real Raspberry Pi Zero 2 W + RAK6421/RAK13300 hardware (2026-06-03): SX1262 init, SPI with spidev hardware CS (CE0), DIO3 TCXO, DIO2 RF switch, and over-the-air RX/TX all confirmed. (Required dropping the GPIO chip-select from the Pi overlays — CE0 is owned by the kernel SPI controller; see the Failed to request line 8 troubleshooting entry.)

Build command verified (run from inside WSL with workspace at /mnt/d/zephcore):

export PATH="$HOME/.local/bin:$PATH"
export ZEPHYR_BASE=/mnt/d/zephcore/zephyr
cd /mnt/d/zephcore && west build -b native_sim zephcore

Use native_sim (32-bit) for x86-64 host smoke builds and for ARM cross-compile targets (Femtofox). Use native_sim/native/64 only if targeting a 64-bit host natively (no cross-compile). The 32-bit variant requires libc6-dev-i386 on the build host.

Known caveats found during implementation

  • Native GPIO driver had two bugs that killed the radio (fixed 2026-06-02, patches/zephyr-new/drivers/gpio/):

    1. rebuild_line() tested (flags & GPIO_DISCONNECTED) == GPIO_DISCONNECTED, but GPIO_DISCONNECTED == 0, so the test was always true and no GPIO line was ever actually requested — every input read returned 0 and every output write was a no-op (SPI still worked only because spidev drove its own hardware CS). BUSY read stuck-low → driver never waited for the chip → commands issued while BUSY → silently dropped → chip frozen in STBY_RC. Fix: test (flags & (GPIO_INPUT | GPIO_OUTPUT)) == GPIO_DISCONNECTED.
    2. The driver passed GPIO_V2_LINE_FLAG_ACTIVE_LOW to the kernel while Zephyr's generic GPIO layer already inverts active-low pins (data->invert) → double inversion drove the active-low RESET line backwards, holding the chip in reset. Fix: driver port_*_raw are physical/raw; let Zephyr own inversion (don't set the kernel ACTIVE_LOW flag). Symptom of both: TCXO/XOSC/CAD all look like the culprit (XOSC_START_ERR, CAD -ETIMEDOUT) but are downstream — the chip is simply wedged.
  • --rt is mandatory and now automatic. Without real-time clock mode the native_sim clock free-runs decoupled from wall time, so real-radio timeouts (200 ms CAD, TX/RX-done) fire before the hardware responds. Forced at boot via linux_native_setup.c (hwtimer_set_real_time_mode(true)).

  • Double-overlay listing in EXTRA_DTC_OVERLAY_FILE: linux_common.overlay gets auto-paired twice — once when the platform conf is selected, once again when the EXTRA_CONF_FILE list is re-walked. Pre-existing CMakeLists.txt quirk affecting all platform confs; harmless (DTC merges idempotently) but cosmetic.

  • Pin paths are runtime-overridable, pin numbers are not. Only --lora-spidev=<path> and --lora-gpio-chip=<path> are wired as command-line args. To change actual pin numbers without rebuilding, create a custom preset overlay and pass it via -DEXTRA_CONF_FILE. (A future iteration could expose pin offsets as cmdline args too.)