Header-only Arduino library for ESP32 + NimBLE-Arduino implementing the BLE-KISS API.
The library is split into a standalone KISS codec/parser, an Arduino Stream compatible BLE-KISS GATT byte stream, and a transport-agnostic KissStreamTnc.
BLE-KISS API spec:
UUIDs implemented by default:
- Service:
00000001-ba2a-46c9-ae49-01b0961f68bb - TX characteristic (app -> TNC, write):
00000002-ba2a-46c9-ae49-01b0961f68bb - RX characteristic (TNC -> app, notify/read):
00000003-ba2a-46c9-ae49-01b0961f68bb
- Header-only
BleKissGattStreamraw BLE byte-stream template implementing ArduinoStream - Header-only
KissStreamTncKISS parser/writer for any ArduinoStream - NimBLE-Arduino server with BLE-KISS service + characteristics
- Standalone KISS byte-stream parser (serial semantics over BLE)
- Correct KISS escaping/unescaping (
FEND/FESC/TFEND/TFESC) - Handles BLE chunk boundaries correctly:
- partial KISS frame across writes
- multiple KISS frames in one write
- Fixed-size incoming stream ring buffer
- Fixed-size decoded frame buffer
- Fixed-size outgoing frame queue (ring)
- MTU-aware notify chunking (
ATT payload = MTU - 3) - Raw BLE write callback for byte chunks before KISS parsing, or normal
Stream::read() - Callback for decoded KISS payloads in
KissStreamTnc(includes command/port byte atdata[0]) - Queue helpers and light stats counters
- Single-instance behavior is explicit (
begin()fails if another instance is active)
- The library itself uses fixed-size buffers and does not allocate dynamic containers.
- NimBLE-Arduino internals may still allocate memory internally.
- Single active
BleKissGattStreaminstance per template specialization. KissStreamTnc::sendDataFrame()andsendKissPayload()encode frames to the attached stream.BleKissGattStream::write()enqueues raw bytes; BLE notification sending happens vialoop()/drainOutgoing().- Decoded RX frame callbacks (
setFrameCallback) are dispatched fromloop()context (not NimBLE callback/task context). - If
loop()is starved, RX callback dispatch can be delayed and incoming bytes may be dropped when the fixed input ring buffer fills. - If
Config.requireNotifySubscriptionis true (default), outbound data is only sent after client enables notifications. - On disconnect, incoming parser state and outgoing queue are cleared.
Template parameters:
BleKissGattStream<INCOMING_STREAM_SIZE, OUTGOING_CHUNK_SIZE, OUTGOING_QUEUE_DEPTH>
KissStreamTnc<DECODED_FRAME_SIZE, OUTGOING_FRAME_SIZE>- Larger
INCOMING_STREAM_SIZEtolerates bursty BLE writes before parser drain. DECODED_FRAME_SIZEis max decoded KISS payload length (command/port + payload).OUTGOING_FRAME_SIZEmust fit worst-case escaped encoded frame length.- Rough bound for payload length
N: encoded size is at mostN*2 + 3.
- Rough bound for payload length
OUTGOING_QUEUE_DEPTHincreases burst tolerance but costs static RAM.
#include "BleKissGattStream.h"
#include "KissStreamTnc.h"
BleKissGattStream<512, 384, 3> bleStream;
KissStreamTnc<384, 384> kissTnc(bleStream);
static void onKissFrame(const uint8_t* data, size_t len, void* ctx) {
(void)ctx;
if (len == 0) return;
uint8_t cmdPort = data[0];
uint8_t command = cmdPort & 0x0F;
uint8_t port = (cmdPort >> 4) & 0x0F;
if (command == 0x00 && len > 1) {
// data[1..] is AX.25 payload for KISS data frame
}
}
void setup() {
kissTnc.setFrameCallback(onKissFrame);
kissTnc.begin();
bleStream.begin();
}
void loop() {
kissTnc.loop();
bleStream.loop();
}See:
examples/BasicBleKissTnc/src/main.cpp(PlatformIO + resource tracking variant)
Use BleKissGattStream when your project already owns KISS/session/protocol parsing and only needs the standard BLE-KISS GATT byte stream.
If setBytesReceivedCallback() is installed, loop() dispatches and consumes incoming bytes through that callback; otherwise incoming BLE writes remain available through the normal Stream read API.
#include "BleKissGattStream.h"
using RawGatt = BleKissGattStream<512, 256, 4>;
RawGatt bleStream;
static void onBytes(const uint8_t* data, size_t len, void* ctx) {
(void)ctx;
// Raw app -> TNC BLE write bytes. Parse or route them in your own layer.
}
void setup() {
bleStream.setBytesReceivedCallback(onBytes);
bleStream.begin();
}
void loop() {
bleStream.loop();
}Use KissStreamTnc with any Arduino stream, including Serial, HardwareSerial, or BleKissGattStream.
#include "KissStreamTnc.h"
KissStreamTnc<512, 1024> serialTnc(Serial);
static void onKissFrame(const uint8_t* data, size_t len, void* ctx) {
(void)ctx;
// data[0] is command/port, data[1..] is command payload.
}
void setup() {
Serial.begin(115200);
serialTnc.setFrameCallback(onKissFrame);
serialTnc.begin();
}
void loop() {
serialTnc.loop();
}examples/BasicBleKissTnc
- Simple BLE-KISS usage example.
- Includes resource tracking and empty-baseline build env for RAM/flash diffing.
examples/ModemTnc
- Real AFSK modem firmware (RF/audio path) using
esp32-afsk. - Supports both BLE and serial KISS transports:
esp32dev_ble(BLE-KISS)esp32dev_serial(USB serial KISS)
esp32-afskis pulled via PlatformIOlib_deps(no local clone required).
examples/RawBleKissGattStream
- Raw BLE-KISS GATT byte-stream example.
- Echoes incoming BLE write chunks through RX notifications without parsing KISS.
This repo now includes library.json for PlatformIO library metadata.
The example folder is also a standalone PlatformIO project:
examples/BasicBleKissTnc/platformio.ini(useslib_extra_dirs = ../..)examples/BasicBleKissTnc/src/main.cpp
Build the basic example project:
pio run -d examples/BasicBleKissTncBuild modem firmware examples:
pio run -d examples/ModemTnc -e esp32dev_ble
pio run -d examples/ModemTnc -e esp32dev_serial
pio run -d examples/RawBleKissGattStreamRun host unit tests (no hardware required):
pio test -e nativeThe repository includes host-native unit tests under test/test_kiss_core for core KISS behavior:
- command/port byte packing
- frame encoding and escape correctness
- byte-stream reassembly for split frames
- multiple frames within one stream chunk
- malformed escape detection
- decoded frame overflow handling
These tests validate standalone logic in src/KissCodec.h, including round-trip payloads where the decoded frame includes the command/port byte.
Current example uses a low-RAM profile:
- template sizes:
BleKissGattStream<512, 384, 3>+KissStreamTnc<384, 384> - preferred MTU:
185 - TX enqueue guarded by
canSend() - fixed buffer estimate for this profile:
2688bytes (estimatedStaticBufferBytes()) - NimBLE low-RAM compile flags (see
examples/BasicBleKissTnc/platformio.ini):CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1CONFIG_BT_NIMBLE_MAX_BONDS=1CONFIG_BT_NIMBLE_MAX_CCCDS=2CONFIG_BT_NIMBLE_MSYS1_BLOCK_COUNT=8CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU=185CONFIG_BT_NIMBLE_ROLE_CENTRAL_DISABLED=1CONFIG_BT_NIMBLE_ROLE_OBSERVER_DISABLED=1
Library fixed-buffer estimate is available at compile time:
BleKissGattStream<...>::estimatedStaticBufferBytes()+KissStreamTnc<...>::estimatedStaticBufferBytes()- Formula:
INCOMING_STREAM_SIZE + INCOMING_CALLBACK_CHUNK_SIZE + OUTGOING_QUEUE_DEPTH * (OUTGOING_FRAME_SIZE + sizeof(size_t)) + DECODED_FRAME_SIZE + OUTGOING_FRAME_SIZE
Baseline diff workflow (empty app baseline):
# compares:
# - esp32dev_baseline (empty app, no BLE-KISS instances)
# - esp32dev (full example)
scripts/resource_diff.shLatest measured resource_diff.sh output:
- RAM baseline:
21464 / 327680 - RAM full:
37676 / 327680 - RAM delta:
+16212 bytes - Flash baseline:
267137 / 1310720 - Flash full:
589481 / 1310720 - Flash delta:
+322344 bytes
Runtime heap probe in example:
- The example prints
[heap]snapshots at:bootafterBeginonConnectonDisconnect- periodic (every 10s)
- Fields:
free: current free heap (ESP.getFreeHeap())min: minimum-ever free heap (ESP.getMinFreeHeap())largest: largest free 8-bit block (heap_caps_get_largest_free_block(MALLOC_CAP_8BIT))deltaBoot:free - free_at_boot
Raw GATT stream:
BleKissGattStream<INCOMING_STREAM_SIZE, OUTGOING_CHUNK_SIZE, OUTGOING_QUEUE_DEPTH>- Arduino
Stream:available(),read(),peek(),flush(),write(...) setBytesReceivedCallback(BytesCallback cb, void* ctx = nullptr)setConnectCallback(EventCallback cb, void* ctx = nullptr)setDisconnectCallback(EventCallback cb, void* ctx = nullptr)bool sendBytes(const uint8_t* data, size_t len)bool isConnected() constbool isNotifySubscribed() constbool canSend() constuint16_t getMtu() const- queue capacity/backpressure helpers
const Stats& stats() const
Generic KISS stream TNC:
KissStreamTnc<DECODED_FRAME_SIZE, OUTGOING_FRAME_SIZE>void loop()setFrameCallback(FrameCallback cb, void* ctx = nullptr)bool sendDataFrame(const uint8_t* payload, size_t len, uint8_t port = 0)bool sendKissPayload(const uint8_t* kissPayload, size_t len)const Stats& stats() constvoid clearStats()
This is intended for BLE-KISS capable clients/apps implementing the BLE-KISS spec, rather than as a Classic Bluetooth SPP transport for APRSdroid.