Quellcode durchsuchen

Merge pull request #1545 from ViezeVingertjes/kiss-modem-firmware

Add KISS-like Modem example and protocol documentation
ripplebiz vor 6 Monaten
Ursprung
Commit
e03f311e51

+ 2 - 0
README.md

@@ -39,9 +39,11 @@ For developers;
 - Clone and open the MeshCore repository in Visual Studio Code.
 - See the example applications you can modify and run:
   - [Companion Radio](./examples/companion_radio) - For use with an external chat app, over BLE, USB or WiFi.
+  - [KISS Modem](./examples/kiss_modem) - Serial KISS protocol bridge for host applications. ([protocol docs](./docs/kiss_modem_protocol.md))
   - [Simple Repeater](./examples/simple_repeater) - Extends network coverage by relaying messages.
   - [Simple Room Server](./examples/simple_room_server) - A simple BBS server for shared Posts.
   - [Simple Secure Chat](./examples/simple_secure_chat) - Secure terminal based text communication between devices.
+  - [Simple Sensor](./examples/simple_sensor) - Remote sensor node with telemetry and alerting.
 
 The Simple Secure Chat example can be interacted with through the Serial Monitor in Visual Studio Code, or with a Serial USB Terminal on Android.
 

+ 152 - 0
docs/kiss_modem_protocol.md

@@ -0,0 +1,152 @@
+# MeshCore KISS Modem Protocol
+
+Serial protocol for the KISS modem firmware. Enables sending/receiving MeshCore packets over LoRa and cryptographic operations using the modem's identity.
+
+## Serial Configuration
+
+115200 baud, 8N1, no flow control.
+
+## Frame Format
+
+Standard KISS framing with byte stuffing.
+
+| Byte | Name | Description |
+|------|------|-------------|
+| `0xC0` | FEND | Frame delimiter |
+| `0xDB` | FESC | Escape character |
+| `0xDC` | TFEND | Escaped FEND (FESC + TFEND = 0xC0) |
+| `0xDD` | TFESC | Escaped FESC (FESC + TFESC = 0xDB) |
+
+```
+┌──────┬─────────┬──────────────┬──────┐
+│ FEND │ Command │ Data (escaped)│ FEND │
+│ 0xC0 │ 1 byte  │ 0-510 bytes  │ 0xC0 │
+└──────┴─────────┴──────────────┴──────┘
+```
+
+Maximum unescaped frame size: 512 bytes.
+
+## Commands
+
+### Request Commands (Host → Modem)
+
+| Command | Value | Data |
+|---------|-------|------|
+| `CMD_DATA` | `0x00` | Packet (2-255 bytes) |
+| `CMD_GET_IDENTITY` | `0x01` | - |
+| `CMD_GET_RANDOM` | `0x02` | Length (1 byte, 1-64) |
+| `CMD_VERIFY_SIGNATURE` | `0x03` | PubKey (32) + Signature (64) + Data |
+| `CMD_SIGN_DATA` | `0x04` | Data to sign |
+| `CMD_ENCRYPT_DATA` | `0x05` | Key (32) + Plaintext |
+| `CMD_DECRYPT_DATA` | `0x06` | Key (32) + MAC (2) + Ciphertext |
+| `CMD_KEY_EXCHANGE` | `0x07` | Remote PubKey (32) |
+| `CMD_HASH` | `0x08` | Data to hash |
+| `CMD_SET_RADIO` | `0x09` | Freq (4) + BW (4) + SF (1) + CR (1) |
+| `CMD_SET_TX_POWER` | `0x0A` | Power dBm (1) |
+| `CMD_SET_SYNC_WORD` | `0x0B` | Sync word (1) |
+| `CMD_GET_RADIO` | `0x0C` | - |
+| `CMD_GET_TX_POWER` | `0x0D` | - |
+| `CMD_GET_SYNC_WORD` | `0x0E` | - |
+| `CMD_GET_VERSION` | `0x0F` | - |
+| `CMD_GET_CURRENT_RSSI` | `0x10` | - |
+| `CMD_IS_CHANNEL_BUSY` | `0x11` | - |
+| `CMD_GET_AIRTIME` | `0x12` | Packet length (1) |
+| `CMD_GET_NOISE_FLOOR` | `0x13` | - |
+| `CMD_GET_STATS` | `0x14` | - |
+| `CMD_GET_BATTERY` | `0x15` | - |
+| `CMD_PING` | `0x16` | - |
+| `CMD_GET_SENSORS` | `0x17` | Permissions (1) |
+
+### Response Commands (Modem → Host)
+
+| Command | Value | Data |
+|---------|-------|------|
+| `CMD_DATA` | `0x00` | SNR (1) + RSSI (1) + Packet |
+| `RESP_IDENTITY` | `0x21` | PubKey (32) |
+| `RESP_RANDOM` | `0x22` | Random bytes (1-64) |
+| `RESP_VERIFY` | `0x23` | Result (1): 0x00=invalid, 0x01=valid |
+| `RESP_SIGNATURE` | `0x24` | Signature (64) |
+| `RESP_ENCRYPTED` | `0x25` | MAC (2) + Ciphertext |
+| `RESP_DECRYPTED` | `0x26` | Plaintext |
+| `RESP_SHARED_SECRET` | `0x27` | Shared secret (32) |
+| `RESP_HASH` | `0x28` | SHA-256 hash (32) |
+| `RESP_OK` | `0x29` | - |
+| `RESP_RADIO` | `0x2A` | Freq (4) + BW (4) + SF (1) + CR (1) |
+| `RESP_TX_POWER` | `0x2B` | Power dBm (1) |
+| `RESP_SYNC_WORD` | `0x2C` | Sync word (1) |
+| `RESP_VERSION` | `0x2D` | Version (1) + Reserved (1) |
+| `RESP_ERROR` | `0x2E` | Error code (1) |
+| `RESP_TX_DONE` | `0x2F` | Result (1): 0x00=failed, 0x01=success |
+| `RESP_CURRENT_RSSI` | `0x30` | RSSI dBm (1, signed) |
+| `RESP_CHANNEL_BUSY` | `0x31` | Result (1): 0x00=clear, 0x01=busy |
+| `RESP_AIRTIME` | `0x32` | Milliseconds (4) |
+| `RESP_NOISE_FLOOR` | `0x33` | dBm (2, signed) |
+| `RESP_STATS` | `0x34` | RX (4) + TX (4) + Errors (4) |
+| `RESP_BATTERY` | `0x35` | Millivolts (2) |
+| `RESP_PONG` | `0x36` | - |
+| `RESP_SENSORS` | `0x37` | CayenneLPP payload |
+
+## Error Codes
+
+| Code | Value | Description |
+|------|-------|-------------|
+| `ERR_INVALID_LENGTH` | `0x01` | Request data too short |
+| `ERR_INVALID_PARAM` | `0x02` | Invalid parameter value |
+| `ERR_NO_CALLBACK` | `0x03` | Feature not available |
+| `ERR_MAC_FAILED` | `0x04` | MAC verification failed |
+| `ERR_UNKNOWN_CMD` | `0x05` | Unknown command |
+| `ERR_ENCRYPT_FAILED` | `0x06` | Encryption failed |
+| `ERR_TX_PENDING` | `0x07` | TX already pending |
+
+## Data Formats
+
+### Radio Parameters (CMD_SET_RADIO / RESP_RADIO)
+
+All values little-endian.
+
+| Field | Size | Description |
+|-------|------|-------------|
+| Frequency | 4 bytes | Hz (e.g., 869618000) |
+| Bandwidth | 4 bytes | Hz (e.g., 62500) |
+| SF | 1 byte | Spreading factor (5-12) |
+| CR | 1 byte | Coding rate (5-8) |
+
+### Received Packet (CMD_DATA response)
+
+| Field | Size | Description |
+|-------|------|-------------|
+| SNR | 1 byte | Signal-to-noise × 4, signed |
+| RSSI | 1 byte | Signal strength dBm, signed |
+| Packet | variable | Raw MeshCore packet |
+
+### Stats (RESP_STATS)
+
+All values little-endian.
+
+| Field | Size | Description |
+|-------|------|-------------|
+| RX | 4 bytes | Packets received |
+| TX | 4 bytes | Packets transmitted |
+| Errors | 4 bytes | Receive errors |
+
+### Sensor Permissions (CMD_GET_SENSORS)
+
+| Bit | Value | Description |
+|-----|-------|-------------|
+| 0 | `0x01` | Base (battery) |
+| 1 | `0x02` | Location (GPS) |
+| 2 | `0x04` | Environment (temp, humidity, pressure) |
+
+Use `0x07` for all permissions.
+
+### Sensor Data (RESP_SENSORS)
+
+Data returned in CayenneLPP format. See [CayenneLPP documentation](https://docs.mydevices.com/docs/lorawan/cayenne-lpp) for parsing.
+
+## Notes
+
+- Modem generates identity on first boot (stored in flash)
+- SNR values multiplied by 4 for 0.25 dB precision
+- Wait for `RESP_TX_DONE` before sending next packet
+- Sending `CMD_DATA` while TX is pending returns `ERR_TX_PENDING`
+- See [packet_structure.md](./packet_structure.md) for packet format

+ 437 - 0
examples/kiss_modem/KissModem.cpp

@@ -0,0 +1,437 @@
+#include "KissModem.h"
+#include <CayenneLPP.h>
+
+KissModem::KissModem(Stream& serial, mesh::LocalIdentity& identity, mesh::RNG& rng,
+                     mesh::Radio& radio, mesh::MainBoard& board, SensorManager& sensors)
+  : _serial(serial), _identity(identity), _rng(rng), _radio(radio), _board(board), _sensors(sensors) {
+  _rx_len = 0;
+  _rx_escaped = false;
+  _rx_active = false;
+  _has_pending_tx = false;
+  _pending_tx_len = 0;
+  _setRadioCallback = nullptr;
+  _setTxPowerCallback = nullptr;
+  _getCurrentRssiCallback = nullptr;
+  _getStatsCallback = nullptr;
+  _config = {0, 0, 0, 0, 0};
+}
+
+void KissModem::begin() {
+  _rx_len = 0;
+  _rx_escaped = false;
+  _rx_active = false;
+  _has_pending_tx = false;
+}
+
+void KissModem::writeByte(uint8_t b) {
+  if (b == KISS_FEND) {
+    _serial.write(KISS_FESC);
+    _serial.write(KISS_TFEND);
+  } else if (b == KISS_FESC) {
+    _serial.write(KISS_FESC);
+    _serial.write(KISS_TFESC);
+  } else {
+    _serial.write(b);
+  }
+}
+
+void KissModem::writeFrame(uint8_t cmd, const uint8_t* data, uint16_t len) {
+  _serial.write(KISS_FEND);
+  writeByte(cmd);
+  for (uint16_t i = 0; i < len; i++) {
+    writeByte(data[i]);
+  }
+  _serial.write(KISS_FEND);
+}
+
+void KissModem::writeErrorFrame(uint8_t error_code) {
+  writeFrame(RESP_ERROR, &error_code, 1);
+}
+
+void KissModem::loop() {
+  while (_serial.available()) {
+    uint8_t b = _serial.read();
+    
+    if (b == KISS_FEND) {
+      if (_rx_active && _rx_len > 0) {
+        processFrame();
+      }
+      _rx_len = 0;
+      _rx_escaped = false;
+      _rx_active = true;
+      continue;
+    }
+    
+    if (!_rx_active) continue;
+    
+    if (b == KISS_FESC) {
+      _rx_escaped = true;
+      continue;
+    }
+    
+    if (_rx_escaped) {
+      _rx_escaped = false;
+      if (b == KISS_TFEND) b = KISS_FEND;
+      else if (b == KISS_TFESC) b = KISS_FESC;
+    }
+    
+    if (_rx_len < KISS_MAX_FRAME_SIZE) {
+      _rx_buf[_rx_len++] = b;
+    }
+  }
+}
+
+void KissModem::processFrame() {
+  if (_rx_len < 1) return;
+  
+  uint8_t cmd = _rx_buf[0];
+  const uint8_t* data = &_rx_buf[1];
+  uint16_t data_len = _rx_len - 1;
+  
+  switch (cmd) {
+    case CMD_DATA:
+      if (data_len < 2) {
+        writeErrorFrame(ERR_INVALID_LENGTH);
+      } else if (data_len > KISS_MAX_PACKET_SIZE) {
+        writeErrorFrame(ERR_INVALID_LENGTH);
+      } else if (_has_pending_tx) {
+        writeErrorFrame(ERR_TX_PENDING);
+      } else {
+        memcpy(_pending_tx, data, data_len);
+        _pending_tx_len = data_len;
+        _has_pending_tx = true;
+      }
+      break;
+    case CMD_GET_IDENTITY:
+      handleGetIdentity();
+      break;
+    case CMD_GET_RANDOM:
+      handleGetRandom(data, data_len);
+      break;
+    case CMD_VERIFY_SIGNATURE:
+      handleVerifySignature(data, data_len);
+      break;
+    case CMD_SIGN_DATA:
+      handleSignData(data, data_len);
+      break;
+    case CMD_ENCRYPT_DATA:
+      handleEncryptData(data, data_len);
+      break;
+    case CMD_DECRYPT_DATA:
+      handleDecryptData(data, data_len);
+      break;
+    case CMD_KEY_EXCHANGE:
+      handleKeyExchange(data, data_len);
+      break;
+    case CMD_HASH:
+      handleHash(data, data_len);
+      break;
+    case CMD_SET_RADIO:
+      handleSetRadio(data, data_len);
+      break;
+    case CMD_SET_TX_POWER:
+      handleSetTxPower(data, data_len);
+      break;
+    case CMD_GET_RADIO:
+      handleGetRadio();
+      break;
+    case CMD_GET_TX_POWER:
+      handleGetTxPower();
+      break;
+    case CMD_GET_VERSION:
+      handleGetVersion();
+      break;
+    case CMD_GET_CURRENT_RSSI:
+      handleGetCurrentRssi();
+      break;
+    case CMD_IS_CHANNEL_BUSY:
+      handleIsChannelBusy();
+      break;
+    case CMD_GET_AIRTIME:
+      handleGetAirtime(data, data_len);
+      break;
+    case CMD_GET_NOISE_FLOOR:
+      handleGetNoiseFloor();
+      break;
+    case CMD_GET_STATS:
+      handleGetStats();
+      break;
+    case CMD_GET_BATTERY:
+      handleGetBattery();
+      break;
+    case CMD_PING:
+      handlePing();
+      break;
+    case CMD_GET_SENSORS:
+      handleGetSensors(data, data_len);
+      break;
+    default:
+      writeErrorFrame(ERR_UNKNOWN_CMD);
+      break;
+  }
+}
+
+void KissModem::handleGetIdentity() {
+  writeFrame(RESP_IDENTITY, _identity.pub_key, PUB_KEY_SIZE);
+}
+
+void KissModem::handleGetRandom(const uint8_t* data, uint16_t len) {
+  if (len < 1) {
+    writeErrorFrame(ERR_INVALID_LENGTH);
+    return;
+  }
+  
+  uint8_t requested = data[0];
+  if (requested < 1 || requested > 64) {
+    writeErrorFrame(ERR_INVALID_PARAM);
+    return;
+  }
+  
+  uint8_t buf[64];
+  _rng.random(buf, requested);
+  writeFrame(RESP_RANDOM, buf, requested);
+}
+
+void KissModem::handleVerifySignature(const uint8_t* data, uint16_t len) {
+  if (len < PUB_KEY_SIZE + SIGNATURE_SIZE + 1) {
+    writeErrorFrame(ERR_INVALID_LENGTH);
+    return;
+  }
+  
+  mesh::Identity signer(data);
+  const uint8_t* signature = data + PUB_KEY_SIZE;
+  const uint8_t* msg = data + PUB_KEY_SIZE + SIGNATURE_SIZE;
+  uint16_t msg_len = len - PUB_KEY_SIZE - SIGNATURE_SIZE;
+  
+  uint8_t result = signer.verify(signature, msg, msg_len) ? 0x01 : 0x00;
+  writeFrame(RESP_VERIFY, &result, 1);
+}
+
+void KissModem::handleSignData(const uint8_t* data, uint16_t len) {
+  if (len < 1) {
+    writeErrorFrame(ERR_INVALID_LENGTH);
+    return;
+  }
+  
+  uint8_t signature[SIGNATURE_SIZE];
+  _identity.sign(signature, data, len);
+  writeFrame(RESP_SIGNATURE, signature, SIGNATURE_SIZE);
+}
+
+void KissModem::handleEncryptData(const uint8_t* data, uint16_t len) {
+  if (len < PUB_KEY_SIZE + 1) {
+    writeErrorFrame(ERR_INVALID_LENGTH);
+    return;
+  }
+  
+  const uint8_t* key = data;
+  const uint8_t* plaintext = data + PUB_KEY_SIZE;
+  uint16_t plaintext_len = len - PUB_KEY_SIZE;
+  
+  uint8_t buf[KISS_MAX_FRAME_SIZE];
+  int encrypted_len = mesh::Utils::encryptThenMAC(key, buf, plaintext, plaintext_len);
+  
+  if (encrypted_len > 0) {
+    writeFrame(RESP_ENCRYPTED, buf, encrypted_len);
+  } else {
+    writeErrorFrame(ERR_ENCRYPT_FAILED);
+  }
+}
+
+void KissModem::handleDecryptData(const uint8_t* data, uint16_t len) {
+  if (len < PUB_KEY_SIZE + CIPHER_MAC_SIZE + 1) {
+    writeErrorFrame(ERR_INVALID_LENGTH);
+    return;
+  }
+  
+  const uint8_t* key = data;
+  const uint8_t* ciphertext = data + PUB_KEY_SIZE;
+  uint16_t ciphertext_len = len - PUB_KEY_SIZE;
+  
+  uint8_t buf[KISS_MAX_FRAME_SIZE];
+  int decrypted_len = mesh::Utils::MACThenDecrypt(key, buf, ciphertext, ciphertext_len);
+  
+  if (decrypted_len > 0) {
+    writeFrame(RESP_DECRYPTED, buf, decrypted_len);
+  } else {
+    writeErrorFrame(ERR_MAC_FAILED);
+  }
+}
+
+void KissModem::handleKeyExchange(const uint8_t* data, uint16_t len) {
+  if (len < PUB_KEY_SIZE) {
+    writeErrorFrame(ERR_INVALID_LENGTH);
+    return;
+  }
+  
+  uint8_t shared_secret[PUB_KEY_SIZE];
+  _identity.calcSharedSecret(shared_secret, data);
+  writeFrame(RESP_SHARED_SECRET, shared_secret, PUB_KEY_SIZE);
+}
+
+void KissModem::handleHash(const uint8_t* data, uint16_t len) {
+  if (len < 1) {
+    writeErrorFrame(ERR_INVALID_LENGTH);
+    return;
+  }
+  
+  uint8_t hash[32];
+  mesh::Utils::sha256(hash, 32, data, len);
+  writeFrame(RESP_HASH, hash, 32);
+}
+
+bool KissModem::getPacketToSend(uint8_t* packet, uint16_t* len) {
+  if (!_has_pending_tx) return false;
+  
+  memcpy(packet, _pending_tx, _pending_tx_len);
+  *len = _pending_tx_len;
+  _has_pending_tx = false;
+  return true;
+}
+
+void KissModem::onPacketReceived(int8_t snr, int8_t rssi, const uint8_t* packet, uint16_t len) {
+  uint8_t buf[2 + KISS_MAX_PACKET_SIZE];
+  buf[0] = (uint8_t)snr;
+  buf[1] = (uint8_t)rssi;
+  memcpy(&buf[2], packet, len);
+  writeFrame(CMD_DATA, buf, 2 + len);
+}
+
+void KissModem::handleSetRadio(const uint8_t* data, uint16_t len) {
+  if (len < 10) {
+    writeErrorFrame(ERR_INVALID_LENGTH);
+    return;
+  }
+  if (!_setRadioCallback) {
+    writeErrorFrame(ERR_NO_CALLBACK);
+    return;
+  }
+  
+  uint32_t freq_hz, bw_hz;
+  memcpy(&freq_hz, data, 4);
+  memcpy(&bw_hz, data + 4, 4);
+  uint8_t sf = data[8];
+  uint8_t cr = data[9];
+  
+  _config.freq_hz = freq_hz;
+  _config.bw_hz = bw_hz;
+  _config.sf = sf;
+  _config.cr = cr;
+  
+  float freq = freq_hz / 1000000.0f;
+  float bw = bw_hz / 1000.0f;
+  
+  _setRadioCallback(freq, bw, sf, cr);
+  writeFrame(RESP_OK, nullptr, 0);
+}
+
+void KissModem::handleSetTxPower(const uint8_t* data, uint16_t len) {
+  if (len < 1) {
+    writeErrorFrame(ERR_INVALID_LENGTH);
+    return;
+  }
+  if (!_setTxPowerCallback) {
+    writeErrorFrame(ERR_NO_CALLBACK);
+    return;
+  }
+  
+  _config.tx_power = data[0];
+  _setTxPowerCallback(data[0]);
+  writeFrame(RESP_OK, nullptr, 0);
+}
+
+void KissModem::handleGetRadio() {
+  uint8_t buf[10];
+  memcpy(buf, &_config.freq_hz, 4);
+  memcpy(buf + 4, &_config.bw_hz, 4);
+  buf[8] = _config.sf;
+  buf[9] = _config.cr;
+  writeFrame(RESP_RADIO, buf, 10);
+}
+
+void KissModem::handleGetTxPower() {
+  writeFrame(RESP_TX_POWER, &_config.tx_power, 1);
+}
+
+void KissModem::handleGetVersion() {
+  uint8_t buf[2];
+  buf[0] = KISS_FIRMWARE_VERSION;
+  buf[1] = 0;
+  writeFrame(RESP_VERSION, buf, 2);
+}
+
+void KissModem::onTxComplete(bool success) {
+  uint8_t result = success ? 0x01 : 0x00;
+  writeFrame(RESP_TX_DONE, &result, 1);
+}
+
+void KissModem::handleGetCurrentRssi() {
+  if (!_getCurrentRssiCallback) {
+    writeErrorFrame(ERR_NO_CALLBACK);
+    return;
+  }
+  
+  float rssi = _getCurrentRssiCallback();
+  int8_t rssi_byte = (int8_t)rssi;
+  writeFrame(RESP_CURRENT_RSSI, (uint8_t*)&rssi_byte, 1);
+}
+
+void KissModem::handleIsChannelBusy() {
+  uint8_t busy = _radio.isReceiving() ? 0x01 : 0x00;
+  writeFrame(RESP_CHANNEL_BUSY, &busy, 1);
+}
+
+void KissModem::handleGetAirtime(const uint8_t* data, uint16_t len) {
+  if (len < 1) {
+    writeErrorFrame(ERR_INVALID_LENGTH);
+    return;
+  }
+  
+  uint8_t packet_len = data[0];
+  uint32_t airtime = _radio.getEstAirtimeFor(packet_len);
+  writeFrame(RESP_AIRTIME, (uint8_t*)&airtime, 4);
+}
+
+void KissModem::handleGetNoiseFloor() {
+  int16_t noise_floor = _radio.getNoiseFloor();
+  writeFrame(RESP_NOISE_FLOOR, (uint8_t*)&noise_floor, 2);
+}
+
+void KissModem::handleGetStats() {
+  if (!_getStatsCallback) {
+    writeErrorFrame(ERR_NO_CALLBACK);
+    return;
+  }
+  
+  uint32_t rx, tx, errors;
+  _getStatsCallback(&rx, &tx, &errors);
+  uint8_t buf[12];
+  memcpy(buf, &rx, 4);
+  memcpy(buf + 4, &tx, 4);
+  memcpy(buf + 8, &errors, 4);
+  writeFrame(RESP_STATS, buf, 12);
+}
+
+void KissModem::handleGetBattery() {
+  uint16_t mv = _board.getBattMilliVolts();
+  writeFrame(RESP_BATTERY, (uint8_t*)&mv, 2);
+}
+
+void KissModem::handlePing() {
+  writeFrame(RESP_PONG, nullptr, 0);
+}
+
+void KissModem::handleGetSensors(const uint8_t* data, uint16_t len) {
+  if (len < 1) {
+    writeErrorFrame(ERR_INVALID_LENGTH);
+    return;
+  }
+  
+  uint8_t permissions = data[0];
+  CayenneLPP telemetry(255);
+  if (_sensors.querySensors(permissions, telemetry)) {
+    writeFrame(RESP_SENSORS, telemetry.getBuffer(), telemetry.getSize());
+  } else {
+    writeFrame(RESP_SENSORS, nullptr, 0);
+  }
+}

+ 152 - 0
examples/kiss_modem/KissModem.h

@@ -0,0 +1,152 @@
+#pragma once
+
+#include <Arduino.h>
+#include <Identity.h>
+#include <Utils.h>
+#include <Mesh.h>
+#include <helpers/SensorManager.h>
+
+#define KISS_FEND  0xC0
+#define KISS_FESC  0xDB
+#define KISS_TFEND 0xDC
+#define KISS_TFESC 0xDD
+
+#define KISS_MAX_FRAME_SIZE 512
+#define KISS_MAX_PACKET_SIZE 255
+
+#define CMD_DATA              0x00
+#define CMD_GET_IDENTITY      0x01
+#define CMD_GET_RANDOM        0x02
+#define CMD_VERIFY_SIGNATURE  0x03
+#define CMD_SIGN_DATA         0x04
+#define CMD_ENCRYPT_DATA      0x05
+#define CMD_DECRYPT_DATA      0x06
+#define CMD_KEY_EXCHANGE      0x07
+#define CMD_HASH              0x08
+#define CMD_SET_RADIO         0x09
+#define CMD_SET_TX_POWER      0x0A
+#define CMD_GET_RADIO         0x0C
+#define CMD_GET_TX_POWER      0x0D
+#define CMD_GET_VERSION       0x0F
+#define CMD_GET_CURRENT_RSSI  0x10
+#define CMD_IS_CHANNEL_BUSY   0x11
+#define CMD_GET_AIRTIME       0x12
+#define CMD_GET_NOISE_FLOOR   0x13
+#define CMD_GET_STATS         0x14
+#define CMD_GET_BATTERY       0x15
+#define CMD_PING              0x16
+#define CMD_GET_SENSORS       0x17
+
+#define RESP_IDENTITY         0x21
+#define RESP_RANDOM           0x22
+#define RESP_VERIFY           0x23
+#define RESP_SIGNATURE        0x24
+#define RESP_ENCRYPTED        0x25
+#define RESP_DECRYPTED        0x26
+#define RESP_SHARED_SECRET    0x27
+#define RESP_HASH             0x28
+#define RESP_OK               0x29
+#define RESP_RADIO            0x2A
+#define RESP_TX_POWER         0x2B
+#define RESP_VERSION          0x2D
+#define RESP_ERROR            0x2E
+#define RESP_TX_DONE          0x2F
+#define RESP_CURRENT_RSSI     0x30
+#define RESP_CHANNEL_BUSY     0x31
+#define RESP_AIRTIME          0x32
+#define RESP_NOISE_FLOOR      0x33
+#define RESP_STATS            0x34
+#define RESP_BATTERY          0x35
+#define RESP_PONG             0x36
+#define RESP_SENSORS          0x37
+
+#define ERR_INVALID_LENGTH    0x01
+#define ERR_INVALID_PARAM     0x02
+#define ERR_NO_CALLBACK       0x03
+#define ERR_MAC_FAILED        0x04
+#define ERR_UNKNOWN_CMD       0x05
+#define ERR_ENCRYPT_FAILED    0x06
+#define ERR_TX_PENDING        0x07
+
+#define KISS_FIRMWARE_VERSION 1
+
+typedef void (*SetRadioCallback)(float freq, float bw, uint8_t sf, uint8_t cr);
+typedef void (*SetTxPowerCallback)(uint8_t power);
+typedef float (*GetCurrentRssiCallback)();
+typedef void (*GetStatsCallback)(uint32_t* rx, uint32_t* tx, uint32_t* errors);
+
+struct RadioConfig {
+  uint32_t freq_hz;
+  uint32_t bw_hz;
+  uint8_t sf;
+  uint8_t cr;
+  uint8_t tx_power;
+};
+
+class KissModem {
+  Stream& _serial;
+  mesh::LocalIdentity& _identity;
+  mesh::RNG& _rng;
+  mesh::Radio& _radio;
+  mesh::MainBoard& _board;
+  SensorManager& _sensors;
+  
+  uint8_t _rx_buf[KISS_MAX_FRAME_SIZE];
+  uint16_t _rx_len;
+  bool _rx_escaped;
+  bool _rx_active;
+  
+  uint8_t _pending_tx[KISS_MAX_PACKET_SIZE];
+  uint16_t _pending_tx_len;
+  bool _has_pending_tx;
+
+  SetRadioCallback _setRadioCallback;
+  SetTxPowerCallback _setTxPowerCallback;
+  GetCurrentRssiCallback _getCurrentRssiCallback;
+  GetStatsCallback _getStatsCallback;
+  
+  RadioConfig _config;
+
+  void writeByte(uint8_t b);
+  void writeFrame(uint8_t cmd, const uint8_t* data, uint16_t len);
+  void writeErrorFrame(uint8_t error_code);
+  void processFrame();
+  
+  void handleGetIdentity();
+  void handleGetRandom(const uint8_t* data, uint16_t len);
+  void handleVerifySignature(const uint8_t* data, uint16_t len);
+  void handleSignData(const uint8_t* data, uint16_t len);
+  void handleEncryptData(const uint8_t* data, uint16_t len);
+  void handleDecryptData(const uint8_t* data, uint16_t len);
+  void handleKeyExchange(const uint8_t* data, uint16_t len);
+  void handleHash(const uint8_t* data, uint16_t len);
+  void handleSetRadio(const uint8_t* data, uint16_t len);
+  void handleSetTxPower(const uint8_t* data, uint16_t len);
+  void handleGetRadio();
+  void handleGetTxPower();
+  void handleGetVersion();
+  void handleGetCurrentRssi();
+  void handleIsChannelBusy();
+  void handleGetAirtime(const uint8_t* data, uint16_t len);
+  void handleGetNoiseFloor();
+  void handleGetStats();
+  void handleGetBattery();
+  void handlePing();
+  void handleGetSensors(const uint8_t* data, uint16_t len);
+
+public:
+  KissModem(Stream& serial, mesh::LocalIdentity& identity, mesh::RNG& rng,
+            mesh::Radio& radio, mesh::MainBoard& board, SensorManager& sensors);
+  
+  void begin();
+  void loop();
+  
+  void setRadioCallback(SetRadioCallback cb) { _setRadioCallback = cb; }
+  void setTxPowerCallback(SetTxPowerCallback cb) { _setTxPowerCallback = cb; }
+  void setGetCurrentRssiCallback(GetCurrentRssiCallback cb) { _getCurrentRssiCallback = cb; }
+  void setGetStatsCallback(GetStatsCallback cb) { _getStatsCallback = cb; }
+  
+  bool getPacketToSend(uint8_t* packet, uint16_t* len);
+  void onPacketReceived(int8_t snr, int8_t rssi, const uint8_t* packet, uint16_t len);
+  void onTxComplete(bool success);
+};

+ 117 - 0
examples/kiss_modem/main.cpp

@@ -0,0 +1,117 @@
+#include <Arduino.h>
+#include <target.h>
+#include <helpers/ArduinoHelpers.h>
+#include <helpers/IdentityStore.h>
+#include "KissModem.h"
+
+#if defined(NRF52_PLATFORM)
+  #include <InternalFileSystem.h>
+#elif defined(RP2040_PLATFORM)
+  #include <LittleFS.h>
+#elif defined(ESP32)
+  #include <SPIFFS.h>
+#endif
+
+StdRNG rng;
+mesh::LocalIdentity identity;
+KissModem* modem;
+
+void halt() {
+  while (1) ;
+}
+
+void loadOrCreateIdentity() {
+#if defined(NRF52_PLATFORM)
+  InternalFS.begin();
+  IdentityStore store(InternalFS, "");
+#elif defined(ESP32)
+  SPIFFS.begin(true);
+  IdentityStore store(SPIFFS, "/identity");
+#elif defined(RP2040_PLATFORM)
+  LittleFS.begin();
+  IdentityStore store(LittleFS, "/identity");
+  store.begin();
+#else
+  #error "Filesystem not defined"
+#endif
+
+  if (!store.load("_main", identity)) {
+    identity = radio_new_identity();
+    while (identity.pub_key[0] == 0x00 || identity.pub_key[0] == 0xFF) {
+      identity = radio_new_identity();
+    }
+    store.save("_main", identity);
+  }
+}
+
+void onSetRadio(float freq, float bw, uint8_t sf, uint8_t cr) {
+  radio_set_params(freq, bw, sf, cr);
+}
+
+void onSetTxPower(uint8_t power) {
+  radio_set_tx_power(power);
+}
+
+float onGetCurrentRssi() {
+  return radio_driver.getCurrentRSSI();
+}
+
+void onGetStats(uint32_t* rx, uint32_t* tx, uint32_t* errors) {
+  *rx = radio_driver.getPacketsRecv();
+  *tx = radio_driver.getPacketsSent();
+  *errors = radio_driver.getPacketsRecvErrors();
+}
+
+void setup() {
+  board.begin();
+
+  if (!radio_init()) {
+    halt();
+  }
+
+  radio_driver.begin();
+
+  rng.begin(radio_get_rng_seed());
+  loadOrCreateIdentity();
+
+  Serial.begin(115200);
+  uint32_t start = millis();
+  while (!Serial && millis() - start < 3000) delay(10);
+  delay(100);
+
+  sensors.begin();
+
+  modem = new KissModem(Serial, identity, rng, radio_driver, board, sensors);
+  modem->setRadioCallback(onSetRadio);
+  modem->setTxPowerCallback(onSetTxPower);
+  modem->setGetCurrentRssiCallback(onGetCurrentRssi);
+  modem->setGetStatsCallback(onGetStats);
+  modem->begin();
+}
+
+void loop() {
+  modem->loop();
+
+  uint8_t packet[KISS_MAX_PACKET_SIZE];
+  uint16_t len;
+  
+  if (modem->getPacketToSend(packet, &len)) {
+    radio_driver.startSendRaw(packet, len);
+    while (!radio_driver.isSendComplete()) {
+      delay(1);
+    }
+    radio_driver.onSendFinished();
+    modem->onTxComplete(true);
+  }
+
+  uint8_t rx_buf[256];
+  int rx_len = radio_driver.recvRaw(rx_buf, sizeof(rx_buf));
+  
+  if (rx_len > 0) {
+    int8_t snr = (int8_t)(radio_driver.getLastSNR() * 4);
+    int8_t rssi = (int8_t)radio_driver.getLastRSSI();
+    modem->onPacketReceived(snr, rssi, rx_buf, rx_len);
+  }
+
+  radio_driver.loop();
+}