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.devrepositories serve. A 2.0 line (v1.9x/v1.10x-Betatags) 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.
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.
[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.
| 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 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) andV(UI frame via digipeaters) transmit APRS packets - Receive — either enable monitoring with
m, in which case received UI frames arrive asUmonitor frames, or enable raw mode withkand receive every frame as aKframe 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.
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
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.
Direct serial connection to a TNC via USB or RS-232.
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.
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.
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 tncdsudo 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 # openSUSEyay -S tncdtncd 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.iniCross-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
v1branch.
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.
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.
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.
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 = falseThe 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 ofrx/tx/connect/disconnectevents (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 streamtncd 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 handteardown 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.
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.
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
exitThen 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.
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 youmacOS 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# 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.iniConfigured 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) |
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 = 1Some 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.0init_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.
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 = falseSome 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_stringhost_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.
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 = 50AGWPE 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.
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 tncdFor Bluetooth TNCs, ensure the BlueZ service is running and the TNC is paired/trusted. tncd handles the Bluetooth connection directly — no separate service needed.
cp tncd.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now tncdFor Bluetooth TNCs, tncd handles the connection directly — no separate
service is needed. Just ensure bluetooth.service is running.
Below is a list of hardware and software I have easily available to test with. Something that's unchecked is untested, not incompatible.
- PAT (Winlink) — connected mode and UI frames, OTA-verified
- Paracon — connected mode OTA-verified
- QtTermTCP — OTA-verified
- Xastir
- 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 :-) )
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\ras 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.
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.
GNU General Public License v3.0 — see COPYING.