Skip to content
ben-kuhnPublic

About

A userspace daemon for accessing KISS TNCs

Resources

Stars

8 stars

Watchers

1 watching

Forks

Repository files navigation

tncd — AGWPE-to-KISS Bridge

Two release lines. The stable 1.3.x line (Python) documented here is the recommended version for production Linux stations, and is what the public apt.tncd.dev / rpm.tncd.dev repositories serve. A 2.0 line (v1.9x/v1.10x-Beta tags) is a from-scratch Go rewrite — a single static binary with no Python runtime, plus AX.25 v2.2 (SABME/modulo-128, XID, SREJ), native Bluetooth SPP, and cross-platform builds: Linux, Windows (with a self-installer GUI and a native Windows service), macOS, and FreeBSD. The 2.0 line is a beta and not yet as thoroughly tested as the 1.3.x Python line — try it if you want the standalone binary or need Windows/macOS, but keep 1.3.x on Linux if you need proven stability. Grab 2.0 betas (latest: v1.103-Beta) from the GitHub releases or the nix-ham-packages overlay. Windows and macOS are available only on the 2.0 beta line.

A userspace bridge that lets AGWPE-compatible applications (PAT/Winlink, Paracon, Xastir) communicate with KISS TNCs, including full AX.25 connected-mode support. Tested over the air with real BBS andWinlink sessions at 1200 baud. Developed because Linux kernel 7.1 removed native AX.25 kernel support, breaking applications that previously relied on AF_AX25 sockets.

Bluetooth TNCs are first-class citizens and handled directly by tncd. The old- fashoned workflow of running rfcomm, then kissattach, then starting your software was messy and prone to error (for me, forgetting various steps and which had to be elevated). tncd just handles a paired and trusted Bluetooth TNC directly. No rfcomm required.

Purpose

Many packet radio programs — Winlink clients (PAT), APRS applications (Xastir), and BBS software (Paracon) — talk to TNCs using the AGWPE protocol. This bridge translates AGWPE to KISS and implements the AX.25 layer 2 state machine that a KISS TNC does not provide.

Architecture

[AGWPE Client App] ----TCP----> [tncd] ----KISS----> [TNC Hardware]
                          (port 8000)            (serial/TCP)

The bridge listens on port 8000 (configurable) for AGWPE client connections and forwards frames to a KISS TNC via serial or TCP. Because KISS TNCs are dumb modems, the bridge fully implements AX.25 connected mode: SABM/UA handshake, I-frame sequencing, RR acknowledgement, duplicate detection, and clean DISC handling.

Supported AGWPE Frame Types

Kind Direction Description
R RX/TX Version query / response
G RX/TX Port info query / response
g RX/TX Port capabilities query / response
X RX/TX Register callsign
x RX Unregister callsign
m RX Enable frame monitoring
y RX/TX Outstanding frames query (per port)
Y RX/TX Outstanding frames query (per connection) — tracks unacked I-frames
H RX/TX Heard stations query
K RX/TX Raw AX.25 frame: send from client, and receive when raw mode is on
k RX Toggle raw AX.25 reception (Xastir uses this instead of m)
M RX Send UI (unproto) frame
V RX Send UI frame via digipeaters
v RX Connect via digipeaters
C RX/TX Connect / connected notification
D RX/TX Send / receive connected data
d RX/TX Disconnect / disconnected notification
I TX Monitor: received I-frame
S TX Monitor: received supervisory frame
U TX Monitor: received unnumbered frame

APRS and Unproto (UI) Frames

APRS and other unconnected-mode applications (Xastir, etc.) are fully supported. UI frames do not require the AX.25 layer 2 state machine — the bridge passes them directly between the AGWPE client and the KISS TNC:

  • Send — M (UI frame) and V (UI frame via digipeaters) transmit APRS packets
  • Receive — either enable monitoring with m, in which case received UI frames arrive as U monitor frames, or enable raw mode with k and receive every frame as a K frame carrying the undecoded AX.25. Xastir uses raw mode.

No special configuration is needed; APRS and connected-mode (Winlink/PAT) clients can share the same bridge instance simultaneously.

AX.25 Connected Mode

The bridge fully implements AX.25 v2.0 connected mode for KISS TNCs:

  • SABM/UA handshake — outgoing connections (PAT/Winlink) and incoming
  • I-frame sequencing — N(S)/N(R) send/receive sequence numbers, mod 8 window
  • RR acknowledgement — T2 delayed ACK batches acknowledgments for burst I-frames; immediate response to polls (P=1)
  • Piggybacked N(R) — outgoing and retransmitted I-frames carry the current receive sequence number, so data transfer implicitly acknowledges received frames
  • Duplicate detection — retransmitted I-frames from the remote are silently discarded; only in-sequence frames are forwarded to the AGWPE client
  • AGWPE flow control — Y (outstanding frames) accurately reflects unacked I-frames so clients like PAT know when it is safe to send the next data block
  • Digipeater support — v (connect via digipeaters) stores the via path and includes it on all subsequent frames (I, RR, REJ, DISC, UA) for the connection; incoming digipeated connections reverse the path automatically
  • TX echo suppression — some TNCs (e.g. BTECH UV-Pro) echo transmitted frames back via KISS; these are detected and discarded, including digipeated echoes where the H-bit differs from the original
  • Adaptive T1 (Karn's algorithm) — round-trip time measured from I-frame acknowledgments; SRTT/RTTVAR updated per RFC 2988; exponential backoff on timeout; retransmit RTT samples excluded
  • T3 inactive link timer — polls idle connections every 180 s with RR P=1; T1 handles retries if the remote doesn't respond
  • REJ gap detection — out-of-sequence I-frames trigger REJ to request retransmission from V(R), improving recovery on lossy links
  • Dynamic T1/T2 timers — retransmit and delayed-ACK timeouts calculated from the configured over-the-air baud rate and window size
  • DISC/DM handling — clean disconnect in both directions; F-bit echoed per spec

TX Pacing

A KISS TNC takes frames as fast as the link will accept them and queues them internally, so a full window of I-frames reaches the TNC in milliseconds and then takes seconds to actually transmit. tncd paces its writes by estimated air time at ota_baudrate, keeping at most one full-size frame of air time outstanding at the TNC rather than handing over the whole window at once.

Note

This bounds a class of failure where the host cannot see what went wrong: a radio that buffers internally accepts an entire burst and reports success while nothing reaches the air, and L2 can queue a retransmit of a frame the TNC has not started sending. The TX counter means "handed to the transport", never "transmitted". Pacing does not slow a healthy link — the TNC always has work queued — but it needs ota_baudrate to match the real channel rate. Set tx_pacing = false on the port to restore the old unpaced behaviour.

Supported TNC Connections

Serial TNC

Direct serial connection to a TNC via USB or RS-232.

Network KISS (TCP)

Connects to a KISS-over-TCP server (e.g. Dire Wolf, QtSoundModem).

Note

For a remote TNC (another host, not localhost), consider setting rx_wedge_timeout on the port. It defaults to 0 (off) for type = tcp: a half-open TCP connection whose receive side silently dies is only detected by the OS keepalive on kernel timescales (typically hours). The wedge watchdog notices "unacked TX outstanding but total RX silence" and relinks the port within the configured timeout instead.

Bluetooth TNC

On Linux, tncd connects to Bluetooth TNCs natively using the BlueZ D-Bus Profile API. Set type = bluetooth in the config — no external tools needed.

On Windows (2.0 beta), tncd connects natively over Winsock RFCOMM (AF_BTH): pair the TNC once in Windows Settings, then set type = bluetooth and bdaddr = AA:BB:CC:DD:EE:FF (the 2.0 installer lists paired devices for you). No virtual COM port needed.

Note

Some radios buffer frames inside the radio, on every platform. This is a property of the radio, not of Bluetooth and not of any one operating system.

The symptom: tncd reports frames as transmitted while nothing reaches the air. Receive keeps working, so the link looks healthy — the port stays online, counters advance, the log shows traffic going out. The frames are sitting in the radio's own TX queue and are typically released minutes later, when the radio next receives something. Classic SPP has no delivery acknowledgement, so no socket-level check can detect this; only an independent receiver on the frequency distinguishes "sent" from "transmitted".

tncd surfaces it rather than failing silently: it logs TX stalled or TX write failed, takes the port offline, and reconnects. If reconnecting does not restore traffic, the frames are stuck in the radio — tncd says so after a few futile relinks. Power-cycling the radio clears it; reconnecting generally will not. A radio that behaves this way is unsuitable for sustained transfers such as Winlink sessions, on any platform.

Measured against a Mobilinkd TNC4: sustained 10 KB Winlink uploads complete on Windows and Linux alike, with matching frame counts and timing.

On macOS, use the paired device's virtual serial port — type = serial with the /dev/cu.* path — since tncd has no native macOS Bluetooth transport.

Why no native macOS transport? tncd's transports are deliberately pure Go, CGO_ENABLED=0, so every platform cross-compiles from one Linux CI. Linux and Windows expose Bluetooth SPP through mechanisms Go can drive with no C code — BlueZ over D-Bus (godbus) and Winsock RFCOMM sockets (AF_BTH), respectively. macOS has neither: classic SPP is reachable only through the Objective-C IOBluetooth framework, whose delegate/run-loop RFCOMM API requires cgo and a macOS build host (breaking the no-cgo cross-compile) and doesn't map cleanly onto tncd's blocking transport model. Since macOS already surfaces a paired SPP TNC as a /dev/cu.* serial port that the existing serial transport handles, a native transport would only save the pairing/virtual-port step — not worth that trade-off. It could be added later as a cgo-gated bluetooth_darwin.go built only on macOS runners if the serial path proves painful.

Installation

Debian / Ubuntu

curl -fsSL https://tncd.dev/tncd.pub \
  | sudo gpg --dearmor -o /usr/share/keyrings/tncd.gpg
echo "deb [signed-by=/usr/share/keyrings/tncd.gpg] https://tncd.dev/apt stable main" \
  | sudo tee /etc/apt/sources.list.d/tncd.list
sudo apt update && sudo apt install tncd

Fedora / RHEL / openSUSE

sudo curl -fsSL https://tncd.dev/rpm/tncd.repo -o /etc/yum.repos.d/tncd.repo
sudo dnf install tncd          # Fedora / RHEL
# sudo zypper install tncd     # openSUSE

Arch Linux (AUR)

yay -S tncd

From source (Go)

tncd 2.0 is a single static Go binary (no runtime dependencies). Build it with Go 1.24+:

git clone https://github.com/ben-kuhn/tncd.git
cd tncd
CGO_ENABLED=0 go build -o tncd ./cmd/tncd
./tncd -c tncd.ini

Cross-compile for another platform with GOOS/GOARCH, e.g. GOOS=windows GOARCH=amd64 go build -o tncd.exe ./cmd/tncd.

Building the older 1.3.x Python line instead? It lives on the v1 branch.

Windows (2.0 beta)

Windows is supported only on the 2.0 Go beta line — there is no Windows build of the stable 1.3.x Python line.

Download tncd-<version>-windows-amd64.zip (or -arm64) from the GitHub releases, unzip, and double-click tncd.exe. With no config present it launches a small installer that asks for your callsign and TNC (serial ports and paired Bluetooth devices are listed for you), then installs tncd as an auto-starting Windows service. Running tncd.exe again opens a manage window (start/stop, open config, open the web monitor, uninstall).

Command-line equivalents are also available: tncd.exe ports, tncd.exe install -c tncd.ini, tncd.exe service start|stop, tncd.exe uninstall.

macOS / FreeBSD (2.0 beta)

Also 2.0-beta-only, and without OS service integration — run it yourself or wrap it in a launchd/rc unit.

Download the matching tncd-<version>-macos-*.tar.gz / tncd-<version>-freebsd-amd64.tar.gz from the GitHub releases, extract, and run ./tncd -c tncd.ini.

macOS Gatekeeper: macOS quarantines downloaded binaries and blocks unsigned ones ("tncd cannot be opened because the developer cannot be verified"). tncd is not notarized, so clear the quarantine flag once after extracting:

xattr -dr com.apple.quarantine ./tncd

(Or allow it via System Settings → Privacy & Security → Allow Anyway after the first blocked launch.) Signing/notarization may come later.

USB usb:VID:PID device references are Linux/Windows only; on macOS use the concrete /dev/cu.* path in the config.

Via Nix (NixOS)

tncd is packaged in nix-ham-packages. See nix/README.md for the full NixOS module with service options, automatic config generation, and Bluetooth support.

Configuration

Copy tncd.ini and adjust for your setup:

[server]
listen_host = 127.0.0.1
listen_port = 8000
callsign = AGWPE

# Each TNC is numbered: [client.0] = AGWPE port 0, [client.1] = port 1, etc.
[client.0]
# type = serial, tcp, or bluetooth
type = serial
device = /dev/ttyUSB0
serial_baudrate = 9600
ota_baudrate = 1200     # over-the-air baud rate (T1/T2 timers and TX pacing)
# tx_pacing = true      # pace TX by air time at ota_baudrate (default: true)
# name = My TNC         # human-readable name shown to AGWPE clients
# parity = N            # N=none, O=odd, E=even, M=mark, S=space (default: N)
# stopbits = 1          # 1, 1.5, or 2 (default: 1)
# rtscts = false        # RTS/CTS hardware flow control (default: false)

# KISS mode initialization (for TNCs that need a command to enter KISS mode)
# init_string = INT KISS\r   # \r = CR, \n = LF
# init_delay = 1.0

# Per-port KISS timing parameters (values in 10ms units)
[kiss.0]
# tx_delay = 40
# persistence = 63
# slot_time = 20
# tx_tail = 30
# full_duplex = 0

# --- 2.0 (Go line) only: optional extra frontends, disabled by default ---

# KISS-over-TCP passthrough (Direwolf-8001 style): lets KISS-native apps
# (woad, mobile clients, kissutil) share this TNC alongside AGWPE clients.
# [kisstcp]
# enabled = true
# listen_host = 127.0.0.1
# listen_port = 8001

# Read-only monitoring API (JSON + Server-Sent Events). UNAUTHENTICATED —
# enable only on a trusted host.
# [api]
# enabled = true
# listen_host = 127.0.0.1
# listen_port = 8002

# Rig control (Benshi radios only — BTech UV-PRO, RadioOddity GA-5WB/DB-50B, Vero
# VR-N76/VR-N7500). Opt-in hamlib Net rigctl server for the matching [client.N]
# port, so PAT and other rigctl clients can QSY the radio. See "Rig Control"
# below.
# [rigctl.0]
# enabled = true
# listen_host = 127.0.0.1
# listen_port = 4532
# allow_ptt = false

Monitoring API (2.0 Go line)

The 2.0 Go build can expose a small HTTP API for dashboards and web tooling. It is disabled by default; enable it with an [api] section. It is unauthenticated — its protections are that it is bound to 127.0.0.1 by default, off unless you turn it on, and read-only apart from the single reconnect endpoint below. Do not expose it to an untrusted network.

That reconnect endpoint is guarded against drive-by requests from a web page (POST only, a custom header browsers cannot send cross-origin, and a same-host Origin check), but those guards stop browsers, not scripts. Anyone who can reach the port can relink your radio ports and interrupt a session, so treat reachability as the control and use allowed_subnets or a firewall if it is not on loopback.

To block DNS-rebinding attacks, the API only answers requests whose Host header is an IP address or localhost. If you reach it by name (a LAN hostname, or a Cloudflare/SSH tunnel domain), list those names in allowed_hosts, e.g. allowed_hosts = shack-pi, tncd.example.org. Otherwise requests get 403.

  • GET /api/status — tncd version and per-port state with live frame counters.
  • GET /api/connections — active AX.25 connections with troubleshooting detail (V(S)/V(R) sequence numbers, unacked/queued frames, T1 retries, RNR busy, smoothed RTT, SREJ).
  • GET /api/events — a Server-Sent Events stream of rx / tx / connect / disconnect events (decoded frame metadata; frame payloads base64-encoded).
curl http://127.0.0.1:8002/api/status
curl -N http://127.0.0.1:8002/api/events   # live event stream

Rig Control (2.0 Go line, Benshi radios only)

tncd can expose frequency (and optional PTT) control for Benshi-protocol radios only — BTech UV-Pro, RadioOddity GA-5WB, RadioOddity DB-50B, Vero VR-N76/VR-N7500 — as a hamlib-compatible Net rigctld TCP server, so PAT and other rigctl clients can QSY the radio before connecting. It is disabled by default and rides the same Bluetooth link tncd already holds open for KISS: the radio's Benshi command protocol and KISS frames share one RFCOMM/BLE connection, told apart by their leading bytes (0xC0 for KISS, 0xFF 0x01 for the Benshi/Gaia framing). There is no separate control-channel connection or config key.

[rigctl.N] keys (one section per [client.N] port, e.g. [rigctl.0] controls [client.0]):

Key Default Meaning
enabled false Opt-in; the listener does not start otherwise
listen_host 127.0.0.1 Bind address for the rigctl TCP listener
listen_port 4532 + N So a single-radio setup ([rigctl.0]) gets hamlib's conventional 4532 with no configuration
allowed_subnets empty (allow all on the bound host) CIDR allowlist for client source IPs, same syntax as [server] allowed_subnets
allow_ptt false See PTT warning below
ptt_timeout 30 Seconds; maximum time tncd will leave the transmitter keyed before force-releasing it, only relevant when allow_ptt = true

One related key lives on the port ([client.N]), not here, because it describes the radio rather than the listener:

Key Default Meaning
vfo_channel_min 251 Lowest channel-record id rig control will overwrite when tuning. See "How tuning works" below.

How tuning works, and what it refuses to touch: these radios have no scratch frequency register. A VFO is an index into the radio's channel table, and "frequency mode" is that index pointed at an unnamed scratch record near the top of it (251 and 252 on a BTech UV-PRO). So a QSY rewrites the channel record the active VFO points at — there is no way around writing a channel record, because that record is the VFO.

Because the target is a real channel record, tuning refuses rather than writes whenever the record might be a memory you programmed:

  • the record carries a name — you named it, so the radio is on a memory, not its VFO;
  • its id is below vfo_channel_min (default 251) — an unnamed memory would otherwise pass the name check. If your radio genuinely keeps its VFO somewhere else, the refusal names the channel and you set that key for the port;
  • its tx and rx differ — a repeater split is certainly a memory, and tuning is simplex, so it would discard the offset;
  • the radio is in dual watch, where which VFO transmits is not derivable.

Only the frequencies are rewritten; sub-audio, bandwidth, power flags and everything else in the record are preserved byte for byte.

FREQ_MODE_SET_PAR, which reads like the command for this, is deliberately unused: on real UV-PRO firmware it writes a register the radio never actually tunes to, while reporting the value back to you. See the design spec's Component C.

PTT is off by default and experimental. The radio has no separate key/unkey command — a single "PTT" effect byte toggles the transmitter on every call, so tncd tracks the intended state itself and bounds every key with ptt_timeout, force-releasing on timeout, client disconnect, or tncd shutdown. If a toggle command is ever lost in transit (a Bluetooth write that silently fails to reach a wedged link, for example), tncd's belief about whether the radio is keyed can drift from the physical state — ptt_timeout bounds how long that can last, but does not prevent it. Enable allow_ptt only if you understand and accept that residual risk, and always test into a dummy load first.

Frequencies are always Hz, never MHz, on the wire and in tncd rig — there is no unit ambiguity to resolve.

tncd rig CLI — a one-shot command for scripting and bench testing without a rigctl client:

tncd rig -c tncd.ini --port 0 probe          # identify the radio (vendor/product/hw/firmware)
tncd rig -c tncd.ini --port 0 get-freq       # current frequency, Hz
tncd rig -c tncd.ini --port 0 set-freq 145030000
tncd rig -c tncd.ini --port 0 teardown       # undo a QSY made earlier in the SAME process
tncd rig -c tncd.ini --port 0 session-hold 15  # apply packet settings, hold, restore
tncd rig -c tncd.ini --port 0 session-acquire  # apply them and LEAVE them applied
tncd rig -c tncd.ini --port 0 session-set 5 1 on   # put them back by hand

teardown restores the record this session's first set-freq displaced, from a saved copy. Each tncd rig invocation is its own session, so running teardown on its own has nothing to restore and says so — it is there for the rigctld server, which keeps one session for the life of the connection. To put a radio back by hand, use set-freq.

Session settings

When rig control is enabled on a port, tncd puts the radio into a state where packet actually works for the duration of a session, and puts it back afterwards:

setting why
dual watch off one receiver time-slicing two frequencies drops inbound frames
APRS beaconing off the radio transmits on its own schedule, colliding with a transfer
memory channel → VFO a radio parked on a named memory cannot be tuned at all

This happens on the first AX.25 connection or the first QSY — not at startup, because people leave tncd running for hours while using the radio for voice. It is released once no connection and no rigctl client remain. Several simultaneous connections on a port share one radio, so it is refcounted.

Restore only puts back fields the radio still holds tncd's value for: if you flip dual watch back on from the front panel mid-session, tncd leaves it on.

These writes reach the radio's NVRAM — a power cycle does not undo them. So if tncd is killed mid-session, or the radio is unreachable when it tries to restore, the radio stays in packet configuration and the log says so. The session-set command above puts it back; session-acquire prints the exact line to use before it changes anything.

There is no separate setting for this: enabling [rigctl.N] is the opt-in, and ports without rig control are never touched.

--port is the index into [client.N] (default 0). See docs/superpowers/specs/rig-control-ota-checklist.md for the full hardware validation procedure, including a documented Bluetooth-link wedging failure mode worth reading before you debug tncd itself.

Bluetooth TNC (Linux)

First, pair and trust your TNC using bluetoothctl:

bluetoothctl
scan on                        # find your TNC
pair AA:BB:CC:DD:EE:FF         # pair with the TNC
trust AA:BB:CC:DD:EE:FF        # trust for auto-reconnect
exit

Then configure tncd:

[client.0]
type = bluetooth
bdaddr = AA:BB:CC:DD:EE:FF
ota_baudrate = 1200
# channel = 6             # optional, auto-detected via SDP
# reconnect = true        # auto-reconnect on drop (default)
# reconnect_delay = 5     # initial delay seconds (default)
# reconnect_max_delay = 60  # max delay seconds (default)

Bluetooth support is built into the binary — no extra packages to install. On Linux, just have BlueZ (bluetoothd) running and your user in the bluetooth group.

Bluetooth TNC on Windows

Pair the TNC once in Windows Settings, then connect natively (Winsock RFCOMM):

[client.0]
type = bluetooth
bdaddr = AA:BB:CC:DD:EE:FF   # the installer lists paired devices for you

Bluetooth TNC on macOS

macOS has no native transport, so use the paired device's virtual serial port:

[client.0]
type = serial
device = /dev/cu.BluetoothTNC     # paired SPP device
serial_baudrate = 9600
ota_baudrate = 1200

Usage

# Serial TNC (packaged install)
tncd -c /etc/tncd.ini

# Serial TNC (built from source)
./tncd -c tncd.ini

# With verbose frame logging
tncd -c /etc/tncd.ini -v    # frame types
tncd -c /etc/tncd.ini -vv   # + data content
tncd -c /etc/tncd.ini -vvv  # + AGWPE internals

# Bluetooth TNC (Linux) — automatic, no separate tool needed
tncd -c /etc/tncd.ini

KISS Parameters

Configured under [kiss.N] in the INI file (matching the TNC number, e.g. [kiss.0] for [client.0]). All values are in 10ms units.

Parameter Default Description
tx_delay 40 PTT key-up delay before TX data
persistence 63 CSMA persistence (0–255)
slot_time 20 CSMA slot time
tx_tail 30 PTT hold after last byte
full_duplex 0 Full duplex mode (0 or 1)

Serial Port Parameters

Some TNCs require non-standard serial settings. Configure under [client.N]:

Parameter Default Description
parity N Parity: N=none, O=odd, E=even, M=mark, S=space
stopbits 1 Stop bits: 1, 1.5, or 2
rtscts false Hardware RTS/CTS flow control

Example for AEA TNCs that use odd parity:

[client.0]
type = serial
device = /dev/ttyUSB0
serial_baudrate = 9600
parity = O
stopbits = 1

KISS Mode Initialization

Some serial TNCs power up in terminal mode and need a command to enter KISS mode. Configure under [client.N]:

[client.0]
type = serial
device = /dev/ttyUSB1
serial_baudrate = 1200
# Multi-step init is supported via \n between commands. \r and \n are interpreted as CR/LF.
init_string = INTFACE KISS\r\nRESET\r   # Kantronics KPC+ family
init_delay = 2.0

init_string is sent to the serial port before KISS framing begins. Use \r for carriage return and \n for line feed — most TNCs expect \r to terminate a command. Multiple commands can be separated by \n and are sent sequentially with init_delay seconds between them. init_delay (default 1.0 s) is also the wait after the last command before the KISS connection opens. tncd probes the serial port first to detect whether the TNC is already in KISS mode; init commands are only sent if a command-mode prompt response is seen.

KISS Exit on Shutdown

On graceful shutdown tncd sends the standard KISS exit byte sequence (C0 FF C0) to serial TNCs so they return to host/command mode. This is enabled by default and harmless for always-KISS TNCs (Dire Wolf, Mobilinkd). To disable:

send_kiss_exit = false

Some TNCs persist KISS state in NVRAM and come back up in KISS even after the exit byte is sent (notably the AEA PK-232MBX). For these, set a host_exit_string that clears the persistent flag in command mode:

[client.0]
type = serial
device = /dev/ttyUSB1
init_string = KISS ON\rRESTART\r       # PK-232MBX
host_exit_string = KISS OFF\r           # clears NVRAM flag on shutdown
exit_delay = 1.0                        # seconds between KISS exit and host_exit_string

host_exit_string is sent as raw bytes (with the same \r / \n escape rules as init_string) after a settle delay following the KISS exit byte.

Multiple TNCs (Multi-Port)

tncd can connect to multiple TNCs simultaneously. Each [client.N] section defines an AGWPE port numbered to match (port 0, port 1, etc.). Port numbers must be contiguous starting at 0. Each port can use any connection type independently.

[server]
listen_host = 127.0.0.1
listen_port = 8000
callsign = N0CALL

[client.0]
name = VHF Serial TNC
type = serial
device = /dev/ttyUSB0
serial_baudrate = 9600
ota_baudrate = 1200

[client.1]
name = Mobilinkd TNC3 (BT)
type = bluetooth
bdaddr = AA:BB:CC:DD:EE:FF
ota_baudrate = 1200

[kiss.1]
tx_delay = 50

AGWPE clients select a TNC by port number. The optional name field provides a human-readable label shown in the AGWPE port list. Each port can have its own [kiss.N] section for KISS timing parameters.

When multiple TNCs share the same frequency, tncd automatically suppresses overheard frames — packets received by the wrong TNC are silently dropped.

systemd Service

Packaged install (apt / dnf / zypper / AUR)

Service files are installed automatically. Copy the example config and start:

sudo cp /etc/tncd.ini.example /etc/tncd.ini
$EDITOR /etc/tncd.ini
sudo systemctl enable --now tncd

For Bluetooth TNCs, ensure the BlueZ service is running and the TNC is paired/trusted. tncd handles the Bluetooth connection directly — no separate service needed.

Manual / source install

cp tncd.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now tncd

For Bluetooth TNCs, tncd handles the connection directly — no separate service is needed. Just ensure bluetooth.service is running.

Compatibility

Below is a list of hardware and software I have easily available to test with. Something that's unchecked is untested, not incompatible.

Clients

  • PAT (Winlink) — connected mode and UI frames, OTA-verified
  • Paracon — connected mode OTA-verified
  • QtTermTCP — OTA-verified
  • Xastir

Software TNCs

  • Dire Wolf — KISS over TCP and PTY serial, OTA-verified at 1200 baud. Direwolf has a native AGWPE interface so this test is only for validation and debugging puposes. Please use the native interface in production.
  • QTSoundModem - Supports KISS over TCP (Also supports AGWPE so use that directly instead :-) )

Hardware TNCs

These are TNCs I own and can test against. Please feel free to add any TNCs you own and have verified.

  • BTECH UV-Pro/Radioddity GA-5WB/Radioddity DB-50B/Vero NR N76 (Bluetooth) — all Benshi-protocol radios sharing one Bluetooth implementation, so a fix for one generally applies to all. Treat that as an inference, not a result: the UV-PRO and the DB-50B are the units actually exercised here, and they have already diverged once — the DB-50B's SDP record defeats bluez on one host where the UV-PRO is fine (see docs/followups.md). Rig control is verified on the UV-PRO only.
  • Mobilinkd TNC4 (USB) — OTA-verified at 1200 baud with Kenwood TH-D7A
  • Mobilinkd TNC3 (Bluetooth SPP) — OTA-verified at 1200 baud via native D-Bus SPP, full Winlink CMS round-trip with 10KB attachment; 2-hop digipeater verified
  • Mobilinkd TNC2 (Bluetooth) — OTA-verified for APRS via native D-Bus SPP. Connected mode sort-of works, but there's a known hardware limitation (the TNC2 is really an APRS-focused device), so it's not recommended for Winlink/connected-mode use.
  • Kenwood TH-D7A (built-in TNC) — OTA-verified at 1200 baud, programmatic KISS init
  • Kenwood TS-2000 (built-in TNC) — OTA-verified at 1200 baud, serial KISS at 57600 baud
  • Kantronics KPC+ (KPC-3+ / KPC-9612+) — OTA-verified at 1200 baud serial, programmatic KISS init via INTFACE KISS\r + RESET\r. Other Kantronics TNCs are likely to work.
  • AEA PK-232 — OTA-verified at 1200 baud, host serial 9600 8N1, programmatic KISS init via KISS ON\r + RESTART\r. Note the PK-232 ships from the factory at 1200 7E1 — connect with 7E1 first, then normalize to 8N1 (AWLEN 8, PARITY n, PERM) before flipping to KISS, since KISS framing requires 8-bit clean.
  • [~] AEA DSP-2232 — KISS init verified (same KISS ON\r + RESTART\r as the PK-232, host serial 9600 8N1). OTA blocked at this station: the DSP-2232's DCD chokes on unsquelched discriminator audio and never keys PTT. Should work with a properly squelched audio feed; not retesting since modern TNCs are strictly better.

Running Tests

Go unit tests (fast, no hardware):

CGO_ENABLED=0 go test ./...

End-to-end tests drive the compiled binary as a black box against Dire Wolf / PAT (needs a local audio + radio-emulation setup):

pip install -r e2e/requirements-test.txt
pytest -c e2e/pytest.ini e2e/

The e2e harness builds the Go binary automatically, or set TNCD_BIN to point at a prebuilt one.

License

GNU General Public License v3.0 — see COPYING.

About

A userspace daemon for accessing KISS TNCs

Resources

Stars

8 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages