Skip to main content
Back to articles
2026 / 09
| 3 min read

Giving an I²C packet modem a serial interface

How i2ckiss-ng translates between a TNC-Pi or TNC-Black and an ordinary KISS client, including the bytes that need changing.

packet radio linux i2c interfaces
On this page

APRX can talk to a packet modem using KISS over a serial device, but a TNC-Pi or TNC-Black can present its modem interface over I²C.

i2ckiss-ng sits between them. It gives the application a pseudo-terminal, opens the Linux I²C device on the other side, and translates the framing as bytes pass through. The first release was published on 1 September; the examples here follow version 2.0.1. It is an independent implementation of the interface associated with John Wiseman G8BPQ’s original i2ckiss work.

A serial application connected to an I2C modemA KISS application opens a stable pseudo-terminal path. The i2ckiss-ng bridge translates frames and manages queues and reconnects between that pseudo-terminal and the Linux I2C device connected to a TNC and radio.

Serial KISS bytes

I2C framing and checksum

APRX or another KISS application

Stable PTY path

i2ckiss-ng

Linux I2C device

TNC-Pi or TNC-Black

Radio

The application can open a path such as /run/i2ckiss-tnc0/pty while the bridge deals with /dev/i2c-1 and the modem’s bus address. The Linux AX.25 stack is another possible consumer, but it isn’t required just to give a KISS application that serial-looking endpoint.

The payload can contain the framing bytes

KISS uses C0 as a frame delimiter. A binary payload may also contain a byte whose value is C0, so that byte has to be escaped when it appears inside a frame. The escape byte, DB, needs escaping too:

Byte inside the frameBytes sent on the wire
C0DB DC
DBDB DD

Take the short command-and-data sequence 00 01 C0 DB 7F. The first byte is the KISS command and port byte; the remaining values are chosen to include both special cases. This is an illustration of framing, not a complete AX.25 radio packet.

The serial representation is:

C0 00 01 DB DC DB DD 7F C0

The I²C transport adds one more piece: an XOR byte calculated across the unescaped command and data. For this example:

00 XOR 01 XOR C0 XOR DB XOR 7F = 65

That gives the I²C side:

C0 00 01 DB DC DB DD 7F 65 C0

The released encoder produces those sequences; they were checked on 2 October 2026 by compiling and running that encoder with this input. The extra byte belongs to the local I²C transport. It isn’t the AX.25 frame check sequence used on the radio link.

On the return path, the bridge checks and removes the I²C XOR byte before making a serial KISS frame. It also uses KISS port zero for its endpoint. A direct byte-for-byte copy would leave the serial client seeing data that belongs to the I²C transport.

Reads introduce another boundary of their own. An operating-system read can end just after DB, leaving the escaped value to arrive in the next read. The decoder has to retain that state. The public tests include this split-read case, along with malformed and oversized frames.

Keeping the endpoint useful when the bus stops responding

Framing is only part of running the bridge. If the I²C connection fails, the reconnect path closes that descriptor, resets the decoder and schedules another attempt with exponential backoff. The default delay grows from 250 milliseconds up to thirty seconds. A partially sent queued frame starts again at its beginning when the connection returns.

That retry doesn’t establish exactly-once delivery of an application packet. The queues are bounded as well, so a modem that remains unavailable cannot consume memory indefinitely while a client continues writing.

The pseudo-terminal is held independently of the I²C connection, and a stable symlink gives the application a predictable name to open. A daemon restart can still require a client to reconnect: an already-open descriptor doesn’t move to a newly created PTY just because the symlink now points there.