Skip to content

rtl8196e-uart-bridge

In-kernel UART ↔ TCP byte shoveler for the Realtek RTL8196E / Lexra RLX4181 gateway. Replaces the former userspace serialgateway daemon.

What it does

Bridges /dev/ttyS1 (Silabs EFR32 Zigbee/Thread radio) to a TCP listen socket (default :8888). A single client connects, and the driver shovels bytes in both directions from inside the kernel:

                 +-------------------+
EFR32 radio <--> | UART1 (ttyS1)     |
                 |   |               |
                 |   | tty_port      |     rtl8196e-uart-bridge
                 |   | client_ops    |     (in-kernel hot path,
                 |   v               |      no ldisc, no userspace)
                 | TCP listen :8888  | <---> Z2M / cpcd / zigbeed / etc.
                 +-------------------+

No line discipline, no context switches on the hot path, no userspace copy. The result is a clean up to 892857 baud (200 MHz ÷ 16 ÷ 14 — the N+1 divisor max for this SoC) with zero overrun under load.

Why a kernel module at all — and why the tty_port client_ops path rather than a line discipline — is documented in DESIGN.md.

Sysfs interface

Exposed under /sys/module/rtl8196e_uart_bridge/parameters/. All knobs are live: writing flips the running bridge without reload.

File R/W Meaning
tty rw, root tty device path. Default /dev/ttyS1
baud rw UART baud. Applied live; take care matching EFR32 firmware
port rw, root TCP listen port. Default 8888
bind_addr rw, root TCP bind address. Default 0.0.0.0; set 127.0.0.1 for loopback-only
flow_control rw 0/none = off (needed during EFR32 flash), 1/hw = CRTSCTS (default), 2/sw = XON/XOFF handled by the bridge. Readback is always numeric
enable rw 1 = arm the bridge, 0 = disarm. Boot default 0
armed ro 1 when both UART and listen socket are live
stats ro rx=... tx=... drops_nocli=... drops_err=... drops_tx=...
nrst_pulse wo, root write 1 to pulse the EFR32 nRST line low for 100 ms (radio recovery)
nrst_gpio rw gpio-rtl819x line wired to EFR32 nRST. Default 12 (pad B4, Lidl gateway)
status_led_brightness rw 0-255 value fired on the uart-bridge-client LED trigger when a client connects (default 255; cleared on disconnect)

Example — arm the bridge manually at 115200 on loopback:

SYSFS=/sys/module/rtl8196e_uart_bridge/parameters
echo 115200 > $SYSFS/baud
echo 127.0.0.1 > $SYSFS/bind_addr
echo 1 > $SYSFS/enable
cat $SYSFS/armed      # 1

The module loads at boot with enable=0 and does nothing until something (typically the init script below) arms it. This avoids racing the 8250 probe that creates /dev/ttyS1.

Device tree configuration

Board-specific defaults can be described in an optional root node of the board DTS, looked up by compatible at driver init (the bridge is not a platform driver — no device binds to the node, and the unbound platform device it produces under /sys/devices/platform/ is harmless):

radio-bridge {
        compatible = "realtek,rtl8196e-uart-bridge";
        nrst-gpios = <&gpio0 12 (GPIO_ACTIVE_LOW | GPIO_OPEN_DRAIN)>;
        flow-control = "hw";
};
  • nrst-gpios — gpio-rtl819x line wired to the EFR32 nRST pad. Only the line number is consumed; the pulse path always claims the line active-low + open-drain, because EFR32 RESETn is inherently both. The DT cell flags should spell the same for the reader.
  • flow-control"hw" (RTS/CTS), "sw" (XON/XOFF — for boards without RTS/CTS wiring, paired with a software-flow-control radio firmware such as NCP-UART-SW) or "none", matching how the board wires the EFR32 UART.

Precedence is DT < kernel command line < runtime sysfs writes: the node only seeds the boot-time defaults of nrst_gpio and flow_control; an explicit rtl8196e_uart_bridge.nrst_gpio=... on the command line or a later sysfs write always wins. With no node (or no property) the compiled-in Lidl defaults apply — third-party DTS files may simply omit it. This is the mechanism that lets RTL8196E-twin ports (e.g. the Sengled G4, discussion #119) describe their radio wiring without patching the driver.

Boot / runtime integration

The userdata init script S50uart_bridge (3-Main-SoC-Realtek-RTL8196E/34-Userdata/skeleton/etc/init.d/S50uart_bridge) is the normal entry point. It reads two keys from /userdata/etc/radio.conf:

FIRMWARE_BAUD=115200 # or 460800, 691200, 892857 — match the EFR32 firmware
BRIDGE_BIND=0.0.0.0  # or 127.0.0.1 to force SSH-tunnel-only access

then writes the corresponding sysfs knobs and flips enable=1. When MODE=otbr is set in the same file, the script exits early and leaves the UART free for otbr-agent.

Both keys are optional — missing FIRMWARE_BAUD defaults to 460800, missing BRIDGE_BIND defaults to 0.0.0.0 (unchanged from v3.0 behaviour).

FIRMWARE_BAUD is the chip-side baud written by flash_efr32.sh on every successful flash; both S50uart_bridge (Zigbee) and S70otbr (OTBR) read this same key, since a working UART link forces both ends to the same baud. radio.conf may also carry the related chip-identity keys (FIRMWARE, FIRMWARE_VERSION). The kernel driver itself reads none of those — they are operator-facing metadata, see 34-Userdata/README.md for the full reference.

Stats and observability

/sys/module/rtl8196e_uart_bridge/parameters/stats tracks eight counters:

  • rx — bytes forwarded UART → TCP (radio → host)
  • tx — bytes forwarded TCP → UART (host → radio)
  • drops_nocli — UART bytes dropped because no TCP client is connected
  • drops_err — UART bytes dropped because kernel_sendmsg() failed
  • drops_tx — TCP bytes dropped because the tty->write was short
  • xoff / xon — bare XOFF/XON bytes received from the radio (flow_control=sw only; both stay 0 in hw/none modes)
  • tx_pause_timeouts — sw mode: a radio XOFF stayed unanswered for more than 1 s and the bridge failed open

Non-zero drops_err or drops_tx in steady state points to TCP backpressure or tty congestion, respectively. drops_nocli is normal any time Z2M (or whichever client) is not connected. In sw mode, xoff/xon incrementing with drops_tx staying 0 is the healthy signature; non-zero tx_pause_timeouts means the radio stopped draining its UART for over a second (stuck firmware, baud mismatch).

Combined with the 8250 framing/overrun counters in /proc/tty/driver/serial you get a complete picture of both the wire and the bridge.

STATUS LED — client connected indicator

When a TCP client is connected the bridge fires the uart-bridge-client LED trigger at the configured brightness; it clears the trigger on disconnect. This reproduces the pre-v3.0 serialgateway behaviour where the STATUS LED tracked "Zigbee host connected".

Bind the trigger to the physical LED once (done by S50uart_bridge at boot based on eth0/led_mode):

echo uart-bridge-client > /sys/class/leds/status/trigger
echo 255 > /sys/module/rtl8196e_uart_bridge/parameters/status_led_brightness

Set status_led_brightness to 0 to disable the LED behaviour without touching the trigger wiring.

EFR32 nRST recovery

nrst_pulse recovers a stuck radio (crashed app, livelock, corrupted state) without rebooting the SoC: it claims the GPIO line named by nrst_gpio as an open-drain output, drives it low for 100 ms, then floats it back to input — the EFR32's internal RESETn pull-up releases the chip into a clean boot. Stop the radio daemon first and restart it after; the recover_efr32 helper script wraps exactly that sequence.

On the Lidl gateway nRST is wired to pad B4 = line 12 on the gpio-rtl819x chip (the default). Ports to other RTL8196E boards set nrst_gpio to their own line at boot — note that the GPIO driver only auto-muxes pads B2–B6 (lines 10–14, PIN_MUX_SEL_2); a line outside that range needs its pad mux established separately.

Flashing the EFR32 with the bridge armed

flash_efr32.sh (at the repo root) talks to the bridge directly and handles the EFR32 firmware flash without disarming it. It flips flow_control to 0 for the Gecko Bootloader Xmodem transfer and restores it to 1 afterwards. No manual teardown required.

flow_control=0 matters in all modes during a flash: Xmodem payloads contain raw 0x11/0x13 bytes, so leaving sw mode on would make the bridge eat them as XON/XOFF and corrupt the transfer.

Security

The bridge is a plaintext single-client TCP listener. Any host that can reach gateway:8888 can talk EZSP/CPC/Spinel directly to the Zigbee radio with no authentication. For anything beyond a fully-trusted LAN, set BRIDGE_BIND=127.0.0.1 and reach the bridge through an SSH tunnel from the host running Z2M/cpcd/otbr.

Full threat model, gateway-side hardening, autossh + systemd recipe, and verification steps: SECURITY.md.

Files in this directory

File Role
rtl8196e_uart_bridge_main.c driver source (~1400 lines, single file)
Kconfig / Makefile in-tree build glue
README.md this file
DESIGN.md rationale + design notes (what was built and why)
SECURITY.md SSH-tunnel deployment and threat model

The driver header comment at the top of rtl8196e_uart_bridge_main.c is the authoritative reference for the sysfs knobs and their runtime semantics — anything in this README that drifts from it should be treated as a documentation bug.