LoRaWAN Gateway on Raspberry Pi 4 with RAK5146 and Trixie — operational runbook

This is the long-form runbook. The narrative-style blog post that highlights the most non-obvious findings lives at the gateway-on-Trixie writeup.

Note: This installation "protocol" is mainly written by "Schlaubischumpf" (Brainy Smurf)

Phase A — Hardware assembly

Two HATs stack on the Raspberry Pi 4: * the RAK9003 PoE Pi HAT for power * and the RAK2287/RAK5146 Pi HAT as the mini-PCIe carrier for the RAK5146 concentrator.

RAK's documentation covers each HAT individually, but does not document the stacked configuration — this section consolidates what you need.

Primary sources consulted:

The Stack

We target

  • model is RAK5146 (not RAK2287),
  • interface is SPI (not USB),
  • band is EU868 (not US915 / AS923 / …),
  • and the GPS option.

The silkscreen on the PCB will not tell you, because the RAK2287 and RAK5146 share the same PCB design. You will see both SX1302 and SX1303 printed next to the main IC footprint, and both 868MHz and 915MHz printed next to the RF matching networks. Only one of each is actually populated — not sure if it's possible to pin down the variant by looking at the (wired) SMD parts, aline.

In Phase C, util_chip_id -d /dev/spidev0.0 will only return a chip ID if the SPI variant is populated — the USB variant has no SPI bus to talk to. The EU868 band is functionally confirmed in Phase E when TTN forwards traffic on the EU_863_870 channels.

Bill of fasteners

From the RAK DIY Kit Installation Guide:

  • 2 × roundhead bolts, 2 mm OD — secure the RAK5146 mPCIe card to the RAK2287/RAK5146 Pi HAT.
  • 4 × roundhead bolts, 2.5 mm OD — secure the RAK2287/RAK5146 Pi HAT standoffs.

Step 1 — Mount the RAK5146 into the carrier HAT

Insert the RAK5146 mPCIe card into the mPCIe slot of the RAK2287/RAK5146 Pi HAT at a 45° angle, then press down flat. Fasten with the two 2 mm roundhead bolts through the matching screw holes.

RAK5146 Quickstart: "Make sure the card fits securely into the connector and sticks out at a 45-degree angle."

Step 2 — Attach uFL pigtails to the RAK5146

The RAK5146 exposes two uFL/IPEX RF ports, labelled on the module for LoRa and GPS. Clip the LoRa pigtail onto the LoRa port; clip the GPS pigtail onto the GPS port.

RAK RPi DIY Kit Installation Guide: "The concentrator connectors are labelled, make sure to not mix them."

Step 3 — Stack both HATs onto the Pi

This is the step RAK does not document for this exact combination. The stack order is forced by the hardware: the RAK2287/RAK5146 Pi HAT has no upward-facing pass-through header, so it must sit at the top of the stack. That leaves only one possible order:

  1. Raspberry Pi 4 (40-pin GPIO header, male, facing up)
  2. RAK9003 PoE Pi HAT directly on the Pi's GPIO header, exposing its own pass-through header upward
  3. RAK2287/RAK5146 Pi HAT (with the RAK5146 already mounted in its mPCIe slot) on top — terminal HAT

Pin usage on the carrier HAT (from datasheet) — no overlap PoE-relevant pins:

  • SPI: GPIO 8, 9, 10, 11
  • GPS UART: GPIO 14, 15
  • Control: GPIO 7 (concentrator IRQ), GPIO 17 (SX1302 reset), GPIO 25 (GPS reset), GPIO 12 (GPS standby)

Secure the stack with the four 2.5 mm roundhead bolts through the standoffs.

Step 4 — Attach SMA antennas to the pigtails

Screw the EU868 LoRa SMA antenna onto the LoRa pigtail's bulkhead end. Screw the active GPS SMA antenna onto the GPS pigtail's bulkhead end.

Step 5 — Pre-power checklist

RAK is explicit, and the quickstart repeats the warning twice:

"It is not recommended to power the device with the antennas detached, as this might damage the circuitry."

"Before powering the Raspberry Pi 3B+ or 4, install the LoRa and GPS antennas. Not doing so might damage the boards."

Confirm before connecting Ethernet:

  • [ ] LoRa SMA antenna screwed onto pigtail
  • [ ] GPS antenna screwed onto pigtail
  • [ ] Both pigtails clipped to the RAK5146 uFL ports
  • [ ] RAK5146 mPCIe card screwed flat into the carrier HAT
  • [ ] Both HATs seated; four standoff bolts tight
  • [ ] microSD card inserted (Phase B output)
  • [ ] Ethernet cable from the PoE+ source ready, but not yet connected

Step 6 — Staged first power-on

Do not connect everything and hope. Bring the stack up in two stages so a fault in the PoE path cannot be mistaken for a fault in the concentrator (and vice versa).

Stage 1 — Pi + RAK9003 only. Assemble only the Pi and the RAK9003 PoE HAT. Do not yet attach the carrier HAT, the RAK5146, or any antennas. Connect Ethernet from the PoE source; confirm the Pi boots; SSH in (Phase B prep on the SD card already in place). If the Pi browns out, throttles, or fails to come up here, the problem is the PoE source or the RAK9003 itself — narrowed before the concentrator is even in the picture.

Stage 2 — Add the carrier HAT, with antennas already attached. Power off. Stack the RAK2287/RAK5146 Pi HAT (with the RAK5146 mounted) on top of the RAK9003. Connect both antennas to their pigtails (per Step 2 and Step 4) before reapplying power. This respects RAK's "never power without antennas" rule and keeps the SX1303 PA terminated the entire time it is energised. Reapply PoE.

This staged approach also clears two items from the verify list without risk: RAK9003 LED behaviour is observable in Stage 1, and PoE class adequacy can be checked with vcgencmd get_throttled over SSH in Stage 1 before the concentrator is added to the load.

Findings not covered by RAK's documentation

Three details the official RAK documentation does not address, confirmed during this build:

  • Standoff kit is sufficient. The kit-included bolts and standoffs were enough for the RAK9003 + RAK2287/RAK5146 Pi HAT double stack — no extras needed.
  • PoE budget has an order of magnitude of headroom. vcgencmd get_throttled returned 0x0 immediately after the full stack came up (RAK9003 + carrier HAT + RAK5146 + both antennas). The PoE source for this build is a Ubiquiti UniFi USW-Lite-8-PoE (4 × 802.3af/at, ~52 W switch budget). Idle draw at the wall with Pi booted but no gateway software running is ≈ 3 W — an order of magnitude under even an 802.3af port's 15.4 W ceiling, so there is significant headroom for the SX1303 RF front-end and the active GPS once Phase D brings the concentrator up.
  • No status LEDs visible on our RAK9003. The board on this build appeared to ship without visible indicators — at least we could not find any. The first sign that PoE was delivering power was the Pi itself booting; we could not find an LED-level diagnostic on the RAK9003 to help if the upstream PoE source fails. Fault isolation on a dark stack seems to mean swapping the cable or the PSE rather than reading an indicator on the HAT.

Phase B — SD card preparation and first SSH

The hardware boots, but it has no OS yet — or none that you trust. This phase lays down a clean Raspberry Pi OS image with the right interfaces enabled, before any gateway software touches the system.

Primary sources consulted:

Decision: which OS image?

RAK publishes a pre-compiled SD image, but the official download is RAK5146_USB_Latest_Firmware.zip — explicitly the USB variant. For RAK5146 SPI, RAK's own User Manual prescribes vanilla Raspberry Pi OS plus the rak_common_for_gateway installer (Phase D). The installer's menu lists RAK5146 SPI as an explicit option, so this is the supported path, not a workaround.

Use Raspberry Pi OS Lite 64-bit, Trixie (Debian 13). The conservative choice would be Bookworm — Trixie images have unresolved Wi-Fi driver issues during first boot as of mid-2026, which makes Trixie risky for headless Wi-Fi setups. This build uses Ethernet (via PoE) for both bring-up and operation, so the Wi-Fi-on-first-boot bug is not a blocker — only the regulatory Wi-Fi country setting is required, not a working Wi-Fi association. Trixie also brings a newer kernel and toolchain, which helps with longer-term support of newer Pi hardware.

Trade-off to verify in Phase D: rak_common_for_gateway is well-tested on Bookworm, but its Trixie compatibility is not yet confirmed in RAK's docs. If the installer fails on Trixie, fall back to the Docker path (xoseperez/basicstation), which is OS-agnostic.

Trixie / Bookworm changes worth knowing now

These changes were introduced in Bookworm and carry forward into Trixie:

  • No default pi:raspberry account — the Imager forces you to set a username and password, and pi is rejected as a username.
  • Boot partition is mounted at /boot/firmware/, not /boot/. Older guides that edit /boot/config.txt should be read as /boot/firmware/config.txt.
  • NetworkManager replaces dhcpcd for network configuration.

Step 1 — Flash with Raspberry Pi Imager

Open Raspberry Pi Imager (1.8+). Choose OS → "Raspberry Pi OS (other)" → Raspberry Pi OS Lite (64-bit) based on Debian Trixie. If the menu offers a Legacy / Bookworm variant as well, pick the current (Trixie) one — Bookworm is fine as a fallback but offers no advantage for this build. Choose storage → the gateway's microSD. Open OS customisation (gear icon, or Ctrl+Shift+X) and set:

  • Hostname: lorawan-gw
  • Username: devop (Bookworm rejects pi as the username)
  • Password: strong, unique to this device
  • SSH: enabled, public-key authentication only — paste the maintainer's ~/.ssh/id_ed25519.pub
  • Wi-Fi: leave SSID/password empty (we use Ethernet), but set Wi-Fi country code — Pi OS uses it for regulatory radio config even when Wi-Fi is unused
  • Locale: timezone and keyboard layout

Write the image, eject the card, and insert it into the Pi (already mounted in the assembled stack from Phase A).

Step 2 — First boot and SSH

Connect the PoE cable and wait 30–60 seconds for the first boot to complete. From the host machine:

ssh devop@lorawan-gw.local

If mDNS resolution fails, find the DHCP-assigned IP from your router or arp -a and connect by IP.

Step 3 — Apply updates

sudo apt update
sudo apt full-upgrade -y
sudo reboot

If your first SSH session greets you with setlocale: LC_ALL: cannot change locale (en_US.UTF-8) warnings, that is your host forwarding its LC_* environment variables to the Pi over SSH, while the Pi only generated the locale you picked in the Imager. The path of least resistance is to install every Debian locale on the Pi:

sudo apt install -y locales-all

Log out and back in; the warnings disappear.

Step 4 — Enable interfaces required by the RAK5146 SPI variant

After reboot, run sudo raspi-configInterface Options:

  • SPI → enable
  • I2C → enable
  • Serial Port — split decision:
    • Serial console (login shell on UART) → disabled
    • Serial hardware (the UART itself) → enabled

The split serial decision is required because the RAK5146 emits GPS NMEA over the Pi's hardware UART; a login shell on the same UART would conflict with the NMEA stream.

RAK5146 User Manual: "use sudo raspi-config to enable SPI and I2C interfaces, disable login shell over serial and enable serial."

Reboot once more to apply.

Step 5 — Install supporting packages

sudo apt install -y chrony git build-essential

chrony is more accurate than the default systemd-timesyncd on fan-less ARM hosts, and an accurate clock is non-negotiable for Phase D — Basics Station's TLS handshake to TTN fails on clock skew of more than a few minutes.

When apt installs chrony, it will also remove systemd-timesyncd during the same transaction. This is expected: both daemons claim the system's NTP role, and Debian's dependency resolver swaps one for the other. There is no manual intervention required and no service-disabling step needed afterwards.

git and build-essential are required in Phase C to clone and compile Semtech's sx1302_hal for the util_chip_id sanity check on the RAK5146. On Pi OS Lite, git is sometimes pre-installed, but build-essential rarely is — apt will quietly skip whichever is already present.

Verify time sync:

chronyc tracking

Expect Leap status : Normal within a minute.

Phase B exit checklist

Confirm before moving to Phase C:

  • [ ] SSH works as devop@lorawan-gw.local (key-based, password disabled)
  • [ ] hostname returns lorawan-gw
  • [ ] vcgencmd get_throttled returns 0x0 — no under-voltage from the PoE source
  • [ ] ls /dev/spidev0.* lists /dev/spidev0.0
  • [ ] chronyc tracking shows Leap status : Normal

Phase C — RAK5146 enable and sanity check

Before installing any gateway software, prove the concentrator is wired and talking. This phase clones Semtech's HAL, builds the diagnostic tools, and runs chip_id against /dev/spidev0.0. A successful chip-ID read confirms wiring, power, and SPI in one shot — and prints the SX1303's hardware EUI, which becomes one of two candidates for the Gateway EUI registered in TTN later.

On modern Pi OS (Trixie) with a vendor module like the RAK5146, the path is unexpectedly bumpy. Two upstream assumptions break on this combination, each requiring a small but non-obvious workaround documented step-by-step below.

Primary sources consulted:

Decision: which sx1302_hal source?

Use Semtech upstream — github.com/Lora-net/sx1302_hal.

Earlier guides (including older drafts of this post) point at RAKWireless/sx1302_hal, but that repo does not seem to exist publicly anymoregit clone against it returns a GitHub 404 which surfaces as an unhelpful username prompt. It looks like RAK does not maintain a public fork (anymore?); what we found is a pinned sx1302_hal version embedded inside rak_common_for_gateway and udp-packet-forwarder, which Phase D installs.

Three patches are required against Semtech upstream before chip_id prints the EUI on this hardware combination:

  1. GPIO reset pins must match the carrier HAT's wiring (one-line sed per pin).
  2. reset_lgw.sh needs a complete rewrite because Trixie has removed the sysfs GPIO interface that the upstream script still uses.
  3. lgw_start() aborts when no STTS751 temperature sensor is present, and the RAK5146 omits the STTS751 entirely (one-line patch).

Step 1 — Clone, repoint reset GPIOs, build

cd ~
git clone https://github.com/Lora-net/sx1302_hal.git
cd sx1302_hal
sed -i 's/^SX1302_RESET_PIN=.*/SX1302_RESET_PIN=17/' tools/reset_lgw.sh
sed -i 's/^SX1261_RESET_PIN=.*/SX1261_RESET_PIN=5/' tools/reset_lgw.sh
git diff tools/reset_lgw.sh

The two sed lines retarget the SX1302 reset from Semtech's CoreCell default (GPIO 23) to the RAK Pi HAT's wiring (GPIO 17), and the SX1261 reset from GPIO 22 to GPIO 5. sed silently succeeds even when its pattern matches nothing, so git diff is not optional — it is how you confirm the substitutions actually took effect.

Expect a unified diff with exactly two changed lines:

-SX1302_RESET_PIN=23
+SX1302_RESET_PIN=17
...
-SX1261_RESET_PIN=22
+SX1261_RESET_PIN=5

Once the diff looks right, build:

make clean all

build-essential from Phase B provides everything the HAL needs — no extra apt install is required. The build produces utilities under util_chip_id/, util_net_downlink/, util_tx_continuous/, and util_spectral_scan/.

Step 2 — Replace reset_lgw.sh with a pinctrl version (Trixie has no sysfs GPIO)

Copy the patched reset script into the util_chip_id/ directory (the utility expects it next to the binary), then run it once to surface the failure mode:

cd ~/sx1302_hal/util_chip_id
cp ../tools/reset_lgw.sh .
./reset_lgw.sh start

Expected error:

./reset_lgw.sh: 32: cannot create /sys/class/gpio/gpio17/direction: Directory nonexistent
...

Semtech upstream's reset_lgw.sh still uses the legacy sysfs GPIO interface (/sys/class/gpio/export), which was removed from the Raspberry Pi kernel as of Bookworm — Trixie inherits the removal. The fix is to rewrite the script. pinctrl (from raspi-utils, pre-installed on Pi OS) is the cleanest choice because it persists pin state across command invocations — the SX1302's POWER_EN line must stay high while chip_id opens SPI and talks to the chip. libgpiod's gpioset is also available on Trixie, but its chardev model releases the line when the process exits, requiring --hold-time tricks.

Replace util_chip_id/reset_lgw.sh with the pinctrl version below. The default user must be in the gpio group (Pi OS adds it automatically) or the script needs sudo.

#!/bin/sh

SX1302_RESET_PIN=17
SX1302_POWER_EN_PIN=18
SX1261_RESET_PIN=5
AD5338R_RESET_PIN=13

WAIT_GPIO() {
    sleep 0.01
}

iot_sk_reset() {
    pinctrl set $SX1302_RESET_PIN op dl
    pinctrl set $SX1302_POWER_EN_PIN op dl
    pinctrl set $SX1261_RESET_PIN op dl
    pinctrl set $AD5338R_RESET_PIN op dl
    WAIT_GPIO

    pinctrl set $SX1302_POWER_EN_PIN dh
    WAIT_GPIO

    pinctrl set $SX1302_RESET_PIN dh
    WAIT_GPIO
    pinctrl set $SX1302_RESET_PIN dl
    WAIT_GPIO

    pinctrl set $SX1261_RESET_PIN dl
    WAIT_GPIO

    pinctrl set $AD5338R_RESET_PIN dl
    WAIT_GPIO
    pinctrl set $AD5338R_RESET_PIN dh
    WAIT_GPIO
}

iot_sk_term() {
    :
}

case "$1" in
    stop)
        iot_sk_term
        ;;
    *)
        iot_sk_reset
        ;;
esac

exit 0

Only stop is special-cased. Every other invocation — start from chip_id, empty from some Basics Station builds, a numeric GPIO pin (17) from other Basics Station builds — triggers the reset.

This permissive shape matters because the two callers we use the script with disagree on convention, and we cannot predict which Basics Station build will pass which form:

  • chip_id (Phase C) invokes the script as reset_lgw.sh start.
  • Basics Station (Phase D) invokes it via --radio-init=<path> and may pass the SX1302 reset GPIO pin number (17) as $1, or call with no arguments at all, depending on the build.

A strict start|stop script works for chip_id but fails Basics Station's ral_config with Usage: ... {start|stop} and exit code 1 — non-obvious because Basics Station otherwise completes the INFOS and MUXS handshakes successfully before this step.

Make it executable and syntax-check it:

chmod +x reset_lgw.sh
sh -n reset_lgw.sh && echo "syntax ok"

Step 3 — Patch the HAL for the missing STTS751 (RAK5146 omits it)

Try chip_id now — with the reset script fixed, it gets further but still fails:

./chip_id -d /dev/spidev0.0

Expected (partial) output:

Note: chip version is 0x12 (v1.2)
INFO: no temperature sensor found on port 0x39
INFO: no temperature sensor found on port 0x3B
INFO: no temperature sensor found on port 0x38
ERROR: no temperature sensor found.
ERROR: failed to start the gateway

The chip version is read successfully (SPI works, SX1303 v1.2 is alive), but lgw_start() aborts at the next stage. Semtech's SX1302 CoreCell reference design wires an STTS751 temperature sensor on the SX1302's internal I2C bus at one of 0x38 / 0x39 / 0x3B and uses it for RSSI compensation. The RAK5146 datasheet does not list any temperature sensor in its block diagram or BoM — looks like the part is omitted from the module entirely, possibly as a cost saving.

This seems to be acknowledged upstream — Issue #58 has been open since the issue was first reported, and PR #63 proposes a temp_dev_path="" config knob to disable the probe cleanly. As far as we can tell, the PR has not yet merged.

Until PR #63 lands, the local fix is to remove the fatal return in lgw_start(). Find the offending block:

grep -n "no temperature sensor found" ~/sx1302_hal/libloragw/src/loragw_hal.c

Two matches: an INFO: per failed I2C address, and an ERROR: after the loop, followed by return LGW_HAL_ERROR; on the next line. Delete that one fatal return:

sed -i '/temperature sensor found.\\n/{n;d;}' ~/sx1302_hal/libloragw/src/loragw_hal.c
git -C ~/sx1302_hal diff libloragw/src/loragw_hal.c

Expect exactly one line removed:

-            return LGW_HAL_ERROR;

Execution now falls through to the AD5338R block (a no-op for the half-duplex RAK5146, gated by CONTEXT_BOARD.full_duplex == true) and onward to the EUI read. Rebuild the HAL and the chip_id binary:

cd ~/sx1302_hal
make clean all

Step 4 — Run chip_id and capture the hardware EUI

cd ~/sx1302_hal/util_chip_id
./chip_id -d /dev/spidev0.0

Note the naming asymmetry: the directory is util_chip_id/ but the binary is just chip_id. Many guides — including older drafts of this post — call the executable util_chip_id and produce a No such file or directory error on first run.

Expected output:

Opening SPI communication interface
Note: chip version is 0x12 (v1.2)
INFO: using legacy timestamp
ARB: dual demodulation disabled for all SF
INFO: no temperature sensor found on port 0x39
INFO: no temperature sensor found on port 0x3B
INFO: no temperature sensor found on port 0x38
ERROR: no temperature sensor found.

INFO: concentrator EUI: 0x<16 hex chars>

Closing SPI communication interface
ERROR: failed to close I2C temperature sensor device (err=-1)
ERROR: failed to stop the gateway

The ERROR: lines around the temperature sensor and lgw_stop() are cosmetic — kept printf calls without fatal returns, and a close() on a never-opened I2C fd. Safe to ignore for the chip-ID sanity check.

Capture the EUI line. The OUI prefix 00:16:C0 is registered to Semtech Corporation in IEEE's database, confirming this is a real factory-burned hardware EUI from the SX1303 silicon, not synthesised.

Step 5 — Choose the Gateway EUI for TTN

TTN's Gateway EUI is any unique 64-bit identifier of your choosing. There are two reasonable sources for it:

  • Hardware EUI from the SX1303 (Step 4 output) — tied to the physical concentrator; survives a Pi swap; breaks if the RAK5146 module is replaced.
  • eth0 MAC with FFFE middle-inserted — the TTN-canonical convention; survives an SD card reflash; breaks if the Pi's network hardware changes.

Both are acceptable. For a homelab one-off, the hardware EUI is marginally more durable because the SX1303 is the most expensive and least-likely-to-be-swapped component in the stack.

Derive the MAC-based EUI for comparison:

ip link show eth0 | awk '/ether/ {print $2}' | tr -d ':' | sed 's/^\(......\)\(......\)$/\1fffe\2/'

That prints a 16-character hex string in TTN's expected format.

Record both EUIs in your notes; we register one of them in Phase E.

Step 6 — GPS sanity check (the answer turns out to be: skip it)

sudo cat /dev/serial0

RAK's documentation confirms the ZOE-M8Q's PPS signal is wired through the mPCIe golden-finger pin 19 into the SX1303 for fine timestamping, but does not state whether the ZOE-M8Q's NMEA UART is exposed to the Pi's /dev/serial0 on the carrier HAT. In practice on this build, cat /dev/serial0 produced no NMEA — the UART looks like it isn't wired through to the Pi.

That does not mean GPS is dead. The ZOE-M8Q is fully functional, and Basics Station reaches it through the SX1303's SPI multiplexing layer in Phase D — within seconds of lgw_start() succeeding, the station log produces:

[SYN:INFO] First PPS pulse acquired
[SYN:INFO] Obtained initial PPS offset (899917) - starting timesync with LNS
[SYN:INFO] Time sync: NOW ... gpsOffset=0x... ppsOffset=899917 syncQual=134

So the PPS-based fine timestamp works and Class B beaconing is possible — they just bypass the Pi entirely. The NMEA UART path is unused; you cannot read $GPGGA lines on the Pi without rerouting the ZOE-M8Q's TX line on the RAK5146 itself (well outside the scope of this build).

The Phase D launch in Step 6 of that phase is the right place to confirm GPS is alive. At this Phase C step, skipping is correct.

Phase C exit checklist

  • [ ] Reset pins repointed to GPIO 17 (SX1302) and GPIO 5 (SX1261) in tools/reset_lgw.sh
  • [ ] util_chip_id/reset_lgw.sh replaced with the pinctrl version (sysfs is gone on Trixie)
  • [ ] loragw_hal.c patched to make the temp-sensor probe non-fatal (RAK5146 has no STTS751)
  • [ ] chip_id returns a valid chip version and a 16-character hardware EUI
  • [ ] Hardware EUI recorded for inventory
  • [ ] eth0+FFFE EUI recorded as the second candidate
  • [ ] Decision made on which EUI to register with TTN in Phase E
  • [ ] GPS UART status documented (NMEA / silent / gibberish) — any of the three is fine, just observed

Findings not covered by RAK or Semtech documentation

Three substantive findings recorded by this build, each citation-backed:

  • RAKWireless/sx1302_hal does not seem to exist publicly anymore. Old guides recommending it produce a confusing GitHub username prompt because GitHub returns 404 to the underlying clone. From what we could find, RAK now embeds the HAL inside rak_common_for_gateway and udp-packet-forwarder instead. Phase C uses Semtech upstream with three patches.
  • reset_lgw.sh upstream still seems to use sysfs GPIO. Trixie (and Bookworm) removed the legacy /sys/class/gpio/ interface. The script needs to be rewritten using pinctrl (or libgpiod 2.x gpioset with hold-time tricks). The pinctrl version is included above.
  • The RAK5146 looks to omit the STTS751 temperature sensor that Semtech's CoreCell reference design assumes. We inferred this from the RAK5146 datasheet (no temp sensor in BoM), upstream Issue #58, and PR #63 — open since the issue was first reported and, as far as we can tell, not yet merged. The one-line loragw_hal.c patch above is a stopgap until (and if) that PR lands.

Forward warning to Phase D

Basics Station ships its own vendored copy of the HAL at deps/lgw1302/platform-corecell/libloragw/src/loragw_hal.c. The temp-sensor problem recurs there — Phase D needs its own patch, against a different file and for a different reason than Phase C. The vendored loragw_hal.c no longer has the fatal return LGW_HAL_ERROR; (so the Phase C patch is not needed against the vendored HAL), yet Basics Station still fails because (a) the RAL wrapper treats lgw_start's missing-sensor state as fatal and (b) the runtime receive path repeatedly calls stts751_get_temperature() and aborts on each read. The Beyondlogic-style patch — targeting loragw_stts751.c to short-circuit both stts751_configure() and stts751_get_temperature() — is the correct, sufficient fix.

Phase D — Basics Station install and configuration

With the concentrator proven alive in Phase C, this phase brings up the actual LoRaWAN gateway software. Basics Station is the WebSocket-based protocol Semtech recommends for new gateways and what TTN expects from any gateway registered after the v3 cutover. It replaces the legacy UDP packet forwarder model.

Primary sources consulted:

Decision: build Basics Station from source, not rak_common_for_gateway

Phase B is committed to rak_common_for_gateway based on RAK's manual. The Phase C findings invalidate that choice for this build:

  1. The installer has not been updated for Trixie / libgpiod 2.x — community reports of failure on Pi 5 / newer kernels, and Trixie inherits the same sysfs removal that broke Phase C's reset script.
  2. The installer's README does not document which packet forwarder it installs (legacy UDP vs Basics Station) — reproducibility is poor.
  3. The Phase C forward warning is confirmed: Basics Station's vendored HAL has the same STTS751 abort we patched in Phase C, and rak_common_for_gateway does not document any pre-applied patch.

Pivoting to direct Basics Station build from upstream, applying the same one-line patch we used in Phase C against the vendored copy of the HAL, and writing our own systemd unit. This is the path documented in Beyondlogic's RAK5146 writeup and is where the post would have landed anyway after a Path-A failure.

Step 1 — Clone, retarget the toolchain to aarch64, build

cd ~
git clone --recursive https://github.com/lorabasics/basicstation.git
cd basicstation

--recursive is required — Basics Station vendors the lgw1302 HAL as a git submodule. The corecell platform variant targets SX1302/SX1303 concentrators; std is the standard variant (no LBT/Spectral Scan).

A naive make platform=corecell variant=std will fail at this point with:

setup.gmk:60: No toolchain for platform 'corecell' and local arch is not 'arm-linux-gnueabihf'
... make[4]: NO-TOOLCHAIN-FOUND-gcc: No such file or directory

Basics Station's setup.gmk looks to hardcode ARCH.corecell = arm-linux-gnueabihf (32-bit ARM), and only treats the build as native when gcc -dumpmachine returns that exact string. On 64-bit Pi OS, gcc -dumpmachine returns aarch64-linux-gnu instead, so the build falls back to placeholder NO-TOOLCHAIN-FOUND-* compiler names that obviously do not exist. This seems to be documented in upstream Issue #163 and Issue #171; neither one has been merged in upstream that we can see. The community workaround in #163 forks a new corecell64 platform; the simpler in-place fix is to retarget the existing platform's ARCH:

sed -i 's|ARCH.corecell *= *arm-linux-gnueabihf|ARCH.corecell = aarch64-linux-gnu|' setup.gmk
grep '^ARCH.corecell' setup.gmk

Last line should print ARCH.corecell = aarch64-linux-gnu.

Now build:

make platform=corecell variant=std

Build takes 3–5 minutes on a Pi 4 and produces ~/basicstation/build-corecell-std/bin/station. Expect a single gps.c "misleading indentation" warning from upstream; it is pre-existing and unrelated to the aarch64 retarget.

Step 2 — Patch the temp-sensor probe (the Beyondlogic patch is required, in a different file from Phase C)

Phase C patched loragw_hal.c by removing the fatal return after the post-loop "no temperature sensor found" error. The vendored HAL inside Basics Station already lacks that fatal return — verify by inspecting the post-loop check:

sed -n '1095,1125p' deps/lgw1302/platform-corecell/libloragw/src/loragw_hal.c

You will see:

if (i == sizeof I2C_PORT_TEMP_SENSOR) {
    ERROR_PRINTF("no temperature sensor found.\n");
}

No return — compare to Lora-net/sx1302_hal master, which still has the fatal return. Conclusion at this point: Basics Station ships a pre-patched HAL, no patch needed here.

That conclusion is half right. Removing the fatal return is necessary but not sufficient under Basics Station — the RAL wrapper that calls lgw_start() is stricter than the standalone chip_id of Phase C and still reports Concentrator start failed: lgw_start with ral_config failed with status 0x08 when no sensor is found. Some downstream code in lgw_start's later init steps still expects a valid ts_fd, and an ts_fd = -1 leaks through into a state that Basics Station treats as fatal.

The Beyondlogic patch (V2.1.0-corecell-disable-stts751.patch) survives precisely because it solves the problem one layer earlier: it patches loragw_stts751.c so that stts751_configure() reports success even with no sensor present. The loop in lgw_start() then sees the first probe at 0x39 "succeed", breaks early with a phantom ts_fd, and all subsequent code that touches the (non-existent) sensor returns success too.

Apply the same effective patch — one-line insertion at the top of the function:

sed -i '/^int stts751_configure(int i2c_fd, uint8_t i2c_addr) {/a\    return LGW_I2C_SUCCESS;' \
  deps/lgw1302/platform-corecell/libloragw/src/loragw_stts751.c
sed -n '78,86p' deps/lgw1302/platform-corecell/libloragw/src/loragw_stts751.c

Expect the function to read:

int stts751_configure(int i2c_fd, uint8_t i2c_addr) {
    return LGW_I2C_SUCCESS;
    int err;
    uint8_t val;
    ...

Everything below the inserted return becomes unreachable — gcc will emit a -Wunreachable-code warning during the build, which is exactly what we want.

Patching only stts751_configure() makes lgw_start() happy, but not the receive path. Basics Station's lgw_receive() polls the temperature ~50 times per second for RSSI compensation, via stts751_get_temperature(), and treats each failed I2C read as a fatal lgw_receive error: -1. Symptom is [HAL:ERRO] [lgw_receive:1296] failed to get current temperature spam in the journal — the gateway looks online but does not actually deliver uplinks.

Patch the second function the same way — return a fake 50 °C, then short-circuit:

sed -i '/^int stts751_get_temperature/a\    return LGW_I2C_SUCCESS;' \
  deps/lgw1302/platform-corecell/libloragw/src/loragw_stts751.c
sed -i '/^int stts751_get_temperature/a\    *temperature = 50.0f;' \
  deps/lgw1302/platform-corecell/libloragw/src/loragw_stts751.c
sed -n '148,156p' deps/lgw1302/platform-corecell/libloragw/src/loragw_stts751.c

Note the order: each sed -i ... /a\ TEXT inserts TEXT after the matching line. Running the return sed first, then the *temperature = 50.0f; sed second, produces them in the correct source order — the *temperature assignment ends up above the return.

Expect the function to read:

int stts751_get_temperature(int i2c_fd, uint8_t i2c_addr, float * temperature) {
    *temperature = 25.0f;
    return LGW_I2C_SUCCESS;
    int err;
    ...

Then rebuild:

make platform=corecell variant=std

When Basics Station next runs, the station log will report found temperature sensor on port 0x39 — the phantom sensor — lgw_start() will complete with Concentrator started, and the receive loop will run silently without temperature errors.

Step 3 — Set up the TTN configuration directory

Basics Station looks for its configuration in a single directory passed via --home. This build uses ~/TTN/ to keep everything in the operator's home for easy backup.

mkdir -p ~/TTN
echo 'wss://eu1.cloud.thethings.network:8887' > ~/TTN/tc.uri
cp /etc/ssl/certs/ca-certificates.crt ~/TTN/tc.trust
cp ~/sx1302_hal/util_chip_id/reset_lgw.sh ~/TTN/reset_gw.sh
chmod +x ~/TTN/reset_gw.sh

The Phase C pinctrl reset script is directly reusable. Basics Station invokes it via --radio-init=<script> before opening the SPI device.

Step 4 — station.conf (corecell EU868)

A minimal station.conf derived from Beyondlogic's template — only hardware parameters. TTN pushes the EU868 channel plan dynamically at connect time via router_config, so no local channel enumeration is needed.

cat > ~/TTN/station.conf << 'EOF'
{
    "radio_conf": {
        "lorawan_public": true,
        "clksrc": 0,
        "device": "/dev/spidev0.0",
        "pps": true,
        "antenna_gain": 0,
        "full_duplex": false,
        "radio_0": {
            "type": "SX1250",
            "rssi_offset": -215.4,
            "rssi_tcomp": {"coeff_a": 0, "coeff_b": 0, "coeff_c": 20.41, "coeff_d": 2162.56, "coeff_e": 0},
            "tx_enable": true
        },
        "radio_1": {
            "type": "SX1250",
            "rssi_offset": -215.4,
            "rssi_tcomp": {"coeff_a": 0, "coeff_b": 0, "coeff_c": 20.41, "coeff_d": 2162.56, "coeff_e": 0},
            "tx_enable": false
        }
    },
    "station_conf": {
        "log_file": "stderr",
        "log_level": "INFO",
        "log_size": 10e6,
        "log_rotate": 3
    }
}
EOF

pps: true tells the station to honour the PPS signal, the RAK5146 routes from the ZOE-M8Q GPS into the SX1303 — fine timestamps are available even if the GPS UART is not exposed to the Pi.

Step 5 — tc.key (placeholder until Phase E)

The LNS authentication key is generated via the TTN Console in Phase E. Create an empty placeholder for now:

touch ~/TTN/tc.key

When the real key arrives in Phase E, it goes in with the mandatory CRLF line endings:

API_KEY='<the-key-from-ttn-console>'
echo "Authorization: Bearer $API_KEY" | perl -p -e 's/\r\n|\n|\r/\r\n/g' > ~/TTN/tc.key

LF-only line endings cause tc contains malformed auth token errors with no useful diagnostics.

Step 6 — First manual launch (smoke test before systemd)

Run the Basics Station in the foreground to verify the build and configuration before wrapping it in a service:

~/basicstation/build-corecell-std/bin/station \
  --radio-init=$HOME/TTN/reset_gw.sh \
  --home=$HOME/TTN \
  --log-level=DEBUG \
  --log-file=stderr

Without a registered gateway in TTN, the connection will not yet succeed — but the station should:

  • Print the Station EUI, it is auto-derived from eth0 (the eth0 MAC with FFFE middle-inserted)
  • Load tc.trust and complete the TLS handshake to eu1.cloud.thethings.network:8887
  • Receive an explicit gateway_eui_not_registered error from TTN — not an HTTP 401

The relevant lines look like:

[SYS:INFO] Station Ver : 2.0.6(corecell/std) ...
[SYS:INFO] proto EUI   : 0:d83a:dda5:f831    (/sys/class/net/eth0/address)
[SYS:INFO] Station EUI : d83a:ddff:fea5:f831
[TCE:INFO] Connecting to INFOS: wss://eu1.cloud.thethings.network:8887
[TCE:ERRO] Infos error: ::0 Failed to fetch gateway:
    error:pkg/gatewayserver:gateway_eui_not_registered
    (gateway EUI `D83ADDFFFEA5F831` is not registered)
[TCE:INFO] INFOS reconnect backoff 10s (retry 1)

Take the Station EUI (d83a:ddff:fea5:f831 in this example, your value will differ) and collapse it to a single 16-character uppercase hex string for TTN registration: D83ADDFFFEA5F831.

Notable: Basics Station's default EUI derivation uses eth0+FFFE; overriding it to use the SX1303 hardware EUI from Phase C would require an explicit --eui flag or station_conf.routerid field. The least friction is to register the eth0-derived EUI in Phase E.

Notable: the INFOS (gateway-discovery) handshake does not require tc.key — TTN identifies us by EUI in the URL, and a missing key only blocks the subsequent LNS session. The exponential reconnect backoff (0s → 10s → 20s → …) keeps the station retrying so we can register in TTN Console without restarting the service.

If you see those four lines, the local stack is healthy. Ctrl-C the station and proceed to Phase E to register the EUI and generate the LNS API key.

Other failure modes worth noting:

  • pinctrl: permission denied → user not in gpio group (covered in Phase C).
  • Failed to open SPI device/dev/spidev0.0 not present, raspi-config didn't enable SPI (covered in Phase B).
  • TLS handshake failure → clock skew (chronyc tracking) or wrong tc.trust (re-copy the system CA bundle).

Step 7 — systemd unit

Wrap the manual launch in a systemd service so the gateway survives reboots:

sudo tee /etc/systemd/system/basicstation.service > /dev/null << 'EOF'
[Unit]
Description=LoRaWAN Basics Station
After=network-online.target chrony.service
Wants=network-online.target

[Service]
User=devop
WorkingDirectory=/home/devop/TTN
ExecStart=/home/devop/basicstation/build-corecell-std/bin/station \
    --radio-init=/home/devop/TTN/reset_gw.sh \
    --home=/home/devop/TTN \
    --log-level=INFO \
    --log-file=stderr
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

The ExecStart line uses explicit \ continuation — systemd respects it the same way the shell does. A single-line ExecStart works too, but it is fragile to terminal paste behaviour, which routinely inserts unwanted line breaks at the first space after a column threshold; the resulting ExecStart= line becomes truncated, and the leftover arguments are misparsed as their own directives. Verify the unit parses cleanly before enabling:

sudo systemd-analyze verify /etc/systemd/system/basicstation.service

No output means the unit is valid.

Enable but do not start yet — wait for the real tc.key from Phase E:

sudo systemctl daemon-reload
sudo systemctl enable basicstation.service

After Phase E writes the real tc.key:

sudo systemctl start basicstation.service
sudo journalctl -u basicstation -f

The After=chrony.service ordering matters — without an accurate clock, the TLS handshake to TTN will fail with confusing certificate-validity errors during early boot.

Phase D exit checklist

  • [ ] basicstation binary built at ~/basicstation/build-corecell-std/bin/station with corecell + STTS751 patch
  • [ ] ~/TTN/ contains tc.uri, tc.trust, station.conf, reset_gw.sh
  • [ ] tc.key placeholder created (empty; populated in Phase E)
  • [ ] Foreground smoke test succeeds: Station EUI printed (eth0+FFFE derivation), TLS handshake completes, TTN replies with gateway_eui_not_registered — expected before the gateway is registered in Phase E, not an HTTP 401
  • [ ] systemd unit installed and enabled, not started

Findings not covered by RAK or Semtech documentation

  • Basics Station's vendored HAL looks half-patched, and the half that remains seems fatal at runtime. The vendored loragw_hal.c has the fatal return LGW_HAL_ERROR; removed from the temp-sensor probe — so the Phase C loragw_hal.c patch does not seem needed against the vendored copy. But on our build, two independent failure modes remained: (a) the RAL wrapper around lgw_start() still treats the missing sensor as fatal, and (b) the runtime receive path calls stts751_get_temperature() ~50 times per second and aborts on each read with lgw_receive error: -1. The Beyondlogic-style patch targets a different file (loragw_stts751.c) and the two functions that turn out to matter; in our experience, it is the correct, sufficient fix on the current vendored revision, not an obsolete historical workaround.
  • Reset script timing seems to matter. Basics Station looks like it SIGTERMs the --radio-init script after about 200 ms. A naive pinctrl reset_lgw.sh with 7 × sleep 0.1 exceeds that budget, and the radio never came up for us. Reducing the per-step delay to sleep 0.01 kept the script under the ceiling without obviously violating SX1302 reset timing requirements.
  • The --radio-init argument convention looks build-dependent. Basics Station may call the script with no arguments or with the SX1302 reset GPIO pin number as $1, depending on the build. A script written to handle only start/stop (the convention chip_id used in Phase C) fails the *) default case with exit 1. Making the script permissive — special-casing only stop and treating anything else as a reset request — worked for us.
  • systemd ExecStart and terminal-paste accidents. A single long ExecStart= line is fragile because terminal paste can insert unwanted newlines at the first space past some column. Use explicit \ continuation per option for resilience, and run sudo systemd-analyze verify <unit> before enabling.
  • Basics Station's setup.gmk looks like it assumes 32-bit ARM. ARCH.corecell is hardcoded to arm-linux-gnueabihf, so a default make platform=corecell variant=std fails on 64-bit Pi OS with NO-TOOLCHAIN-FOUND-* placeholder compiler names. A one-line sed to retarget ARCH.corecell to aarch64-linux-gnu was enough to make the build work natively. This seems to be documented in upstream Issue #163 and Issue #171; we could not find evidence that either has merged.
  • tc.key seems to require CRLF line endings. Editor-saved files with LF-only endings produced silent malformed auth token failures in our build. The perl -p -e 's/\r\n|\n|\r/\r\n/g' filter looks to be mandatory; an echo alone is not enough.
  • Basics Station looks like it ignores local channel plans for TTN. station.conf only carries hardware config; the LNS pushes EU868 channel and data rate plans dynamically via router_config at handshake time. Older guides that hand-roll EU868 channels into station.conf come across as obsolete on TTN v3.

Phase E — TTN registration and first connection

The gateway hardware is alive (Phase A), the OS is sane (Phase B), the concentrator answers SPI (Phase C), and Basics Station builds and reaches TTN's discovery endpoint (Phase D). The final step is to register the gateway in TTN Console, generate an authentication key, write it into ~/TTN/tc.key, and watch the first successful handshake complete.

Primary sources consulted:

Step 1 — Register the gateway and generate the LNS key

In a browser, log in to https://eu1.cloud.thethings.network/console/. Go to Gateways → + Add gateway and fill in:

Field Value
Gateway EUI the 16-character hex string from Phase D Step 6's Station EUI, uppercase, no separators
Gateway ID <your-handle>-gw — this build used datenkollektiv-gw
Gateway name usually matches the ID
Frequency plan Europe 863-870 MHz (SF9 for RX2 — recommended)
Require authenticated connection Yes (required for Basics Station with TLS)
Generate API key for LNS Yes (the inline checkbox auto-creates the key with "Link as Gateway" scope — no separate API-keys step needed)
Generate API key for CUPS No (we do not use CUPS)
Share status within network Yes
Share location within network Yes

On the Gateway ID: TTN Gateway IDs live in a global namespace, not a per-account one. Generic names like lorawan-gw are almost certainly taken; submission will fail with error:pkg/util/store:id_taken. The post's recommended pattern is <your-handle-or-blog>-gw — distinctive, discoverable by other TTN community members, and teachable to readers who would otherwise hit the same collision.

Submit. The next screen shows the generated LNS API key — visible once and only once. Copy it immediately. A leaked key grants only traffic forwarding — it cannot view or modify the gateway, so the blast radius is minimal — but it is still a real credential. If you lose it before pasting it into tc.key, regenerate from the gateway's API keys tab.

Step 2 — Get tc.key onto the gateway

The simplest path: when TTN generates the LNS key inline (Step 1's Generate API key for LNS checkbox), the next screen also offers a pre-formatted tc.key file download — already containing Authorization: Bearer <key> with the mandatory CRLF line endings. Download it and scp it to the Pi:

scp ~/Downloads/tc.key devop@lorawan-gw.local:~/TTN/tc.key

Verify on the Pi:

xxd ~/TTN/tc.key | tail -1

Last bytes should be ... 0d 0a (\r\n).

If you only have the bare API key (e.g. regenerated from the API keys tab, no fresh download offered), build the file by hand with the mandatory perl CRLF filter:

API_KEY='<paste-the-key-here>'
echo "Authorization: Bearer $API_KEY" | perl -p -e 's/\r\n|\n|\r/\r\n/g' > ~/TTN/tc.key

A naive echo "Authorization: Bearer $API_KEY" > ~/TTN/tc.key produces an LF-only file and Basics Station rejects it with the non-obvious tc contains malformed auth token error.

Step 3 — Relaunch the station, watch the first session

~/basicstation/build-corecell-std/bin/station \
  --radio-init=$HOME/TTN/reset_gw.sh \
  --home=$HOME/TTN \
  --log-level=INFO \
  --log-file=stderr

What we want to see now, in order:

  1. tc.trust cert loaded and TLS handshake completes — same as Phase D Step 6.
  2. INFOS handshake succeeds this time (no more gateway_eui_not_registered).
  3. The station is upgraded to the LNS session — connecting to a per-gateway WebSocket URL that TTN hands out.
  4. TTN pushes router_config containing the EU868 channel plan.
  5. The concentrator reset script runs (our pinctrl reset_gw.sh) — this is the first time Basics Station actually touches the radio.
  6. SX1303 chip ID is read; the EU868 radio chain is brought up; the station enters its receive loop.

The log line that proves we are live looks roughly like:

[TCE:INFO] Connecting to MUXS: wss://eu1.cloud.thethings.network:8887/...
[S2E:INFO] Configuring radio - SX1302 chipid: 0x12
[RAL:INFO] Lora Gateway sx1302_hal v2.x.x started

Step 4 — Confirm in TTN Console

Refresh the gateway page in TTN Console; switch to the Live data tab. Within 30 seconds, you should see gateway status updates streaming in (heartbeats every ~30 s), and the gateway listing on the parent page should show Connected / Last seen seconds ago.

If the page still shows Disconnected after a minute:

  • Cross-check the EUI in the foreground log against the EUI in TTN Console (uppercase, no separators, 16 hex chars).
  • Re-verify tc.key line endings with xxd ~/TTN/tc.key | tail -1.
  • Check clock sync (chronyc trackingLeap status : Normal).
  • Inspect TTN Console → General settings for the Gateway Server address matches eu1.cloud.thethings.network.

Phase E exit checklist

  • [ ] Gateway registered in TTN Console as <your-handle>-gw (this build used datenkollektiv-gw) with the eth0-derived EUI
  • [ ] LNS API key generated with "Link as Gateway" scope only (no view, no update)
  • [ ] tc.key written with CRLF line endings (xxd confirms 0d 0a at end)
  • [ ] Station reconnects past the gateway_eui_not_registered wall
  • [ ] Concentrator reset succeeds; SX1303 brought up; EU868 radio chain online
  • [ ] TTN Console shows Connected / Last seen seconds ago
  • [ ] Status events visible in Live data tab

Wrap-up — convert to systemd

With the manual launch confirmed working, install the systemd unit drafted in Phase D Step 7 (if not already installed), then enable and start:

sudo systemctl daemon-reload
sudo systemctl enable --now basicstation.service
sudo journalctl -u basicstation -f

The --now flag both enables boot persistence and starts the service immediately. Confirm in the journal that the station comes back up cleanly under systemd and that TTN Console still shows Connected.

Findings not covered by RAK or TTN documentation

  • TTN's INFOS handshake does not seem to require tc.key. Basics Station can complete the TLS exchange and present its EUI for discovery using only server-side auth (tc.trust), at least in our build. The LNS key only seems to be needed for the subsequent MUXS session. This is likely why Phase D Step 6's smoke test produces a precise gateway_eui_not_registered error rather than a generic TLS or auth failure — the path between local stack and TTN looks healthy even before any key is present.
  • The "Link as Gateway" right looks like the only permission needed. No _view, no _update was necessary for our gateway to function. This limits the blast radius of a leaked LNS key to traffic forwarding for this one gateway.