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.
On this page
A packet application such as APRX can talk to a modem using KISS over a serial device. A TNC-Pi or TNC-Black can expose its modem interface over I²C. Both ends have useful work to do, but they don’t meet at the same sort of file descriptor.
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.
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 frame | Bytes sent on the wire |
|---|---|
C0 | DB DC |
DB | DB 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 other transport, which is why the bridge has to understand the frame boundary.
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; the length of a read is never enough to tell us where a KISS frame ends.
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. It restores the local transport and tries to send a complete frame; it cannot infer everything that happened beyond a failed connection. 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.