#include "HostKiss.h" #if MESH_ENABLE_HOST_KISS #include "HostKissCodec.h" #include "HostKissSettings.h" #include "RadioProfileCLI.h" #include "UsbLogging.h" #include #include #include #include #if defined(ESP32) #include #include #include #endif namespace mesh { namespace hostkiss { namespace { constexpr size_t ByteBudget = 128; constexpr uint16_t TcpPort = 5004; Settings settings; Radio* radio = nullptr; RadioProfileCLI* profiles = nullptr; Dispatcher* dispatcher = nullptr; Codec* codec = nullptr; // All frame buffers are absent while the setting is off. bool usb_session = false, suspended = false, leased = false, transmitting = false; bool ready_pending = false, completion_pending = false, completion_success = false; uint8_t quarantine_return = 0; uint32_t profile_generation = 0, intent_generation = 0; uint32_t received = 0, transmitted = 0, failed = 0, discarded = 0; #if defined(ESP32) struct Tcp { WiFiServer server; WiFiClient client; uint32_t address = 0, last_attempt = 0; bool listening = false, attempted = false, connected = false; Tcp() : server(TcpPort, 2) {} }; Tcp* tcp = nullptr; #endif bool tcpConnected() { #if defined(ESP32) return tcp && tcp->connected; #else return false; #endif } bool active() { return usb_session || tcpConnected(); } void releaseRadio() { if (dispatcher) dispatcher->cancelHostKissFrame(); transmitting = false; const auto* p = radio ? radio->profiles() : nullptr; // This service is the sole owner of the Kiss overlay. Its generation may // already have changed; let RadioProfileCLI reconcile that change itself. // An unrelated foreign protocol must never be cleared on our way out. if (leased && profiles && p && p->secondary.params.protocol == RadioProfileProtocol::Kiss) profiles->clearSecondaryProtocol(); leased = false; } void resetFrames() { if (codec) codec->reset(); ready_pending = completion_pending = false; quarantine_return = 0; } void closeSession() { releaseRadio(); resetFrames(); usb_session = suspended = false; setUsbBinaryStreamOwned(false); #if defined(ESP32) if (tcp) { tcp->client.stop(); tcp->connected = false; } #endif } const char* admission() { if (!radio || !profiles || !dispatcher || !radio->supportsHostKiss()) return "unsupported radio"; if (!profiles->manualSecondaryConfigured() || profiles->savedSecondaryConfig().mode != RadioProfileMode::RxTx) return "configure saved radio2 rxtx"; if (profiles->savedSecondaryConfig().params.protocol != RadioProfileProtocol::MeshCore) return "radio2 protocol reserved"; const auto* p = radio->profiles(); if (!p || !profiles->secondaryRuntimeAvailable()) return "temporary or pending radio settings"; if (p->secondary.mode != RadioProfileMode::RxTx || p->primary_temporary || p->secondary_temporary) return "radio2 unavailable"; if (p->secondary.params.protocol != (leased ? RadioProfileProtocol::Kiss : RadioProfileProtocol::MeshCore)) return "radio2 owned by another service"; return nullptr; } bool leaseRadio() { if (admission() || !profiles->useSecondaryProtocol(RadioProfileProtocol::Kiss)) return false; const auto* p = radio->profiles(); if (!p || p->secondary.params.protocol != RadioProfileProtocol::Kiss) return false; leased = true; profile_generation = p->generation[1]; intent_generation = profiles->secondaryIntentGeneration(); return true; } bool current() { if (!leased || !active() || suspended || admission()) return false; const auto* p = radio->profiles(); return p && p->generation[1] == profile_generation && profiles->secondaryIntentGeneration() == intent_generation; } const char* state(const Selection& selected, const char* reason) { if (!selected.enabled) return "off"; if (suspended) return "suspended"; if (!codec) return "unavailable"; if (active()) return "connected"; if (reason) return "blocked"; #if defined(ESP32) if (selected.transport == Transport::Tcp) { if (!(WiFi.getMode() & WIFI_STA) || WiFi.status() != WL_CONNECTED || !uint32_t(WiFi.localIP())) return "waiting-wifi"; return tcp && tcp->listening ? "listening" : "starting"; } #endif return "ready"; } void invalidateSession() { ++discarded; releaseRadio(); resetFrames(); if (usb_session) { // Keep binary input quarantined until RETURN or a USB session boundary. // Releasing it to the ASCII parser could interpret a stale binary tail. suspended = true; } else { closeSession(); #if defined(ESP32) // Drop accepts queued in the old profile session, not only its owner. if (tcp) { tcp->server.end(); tcp->listening = tcp->attempted = false; } #endif } } void queueControls() { if (!codec) return; // Completion must survive a full RX queue, rather than being silently lost. if (completion_pending && codec->queuedOutput() < Codec::QueueDepth) { codec->queueTxDone(completion_success); completion_pending = false; ready_pending = true; } if (ready_pending && codec->queuedOutput() < Codec::QueueDepth) { codec->queueReady(); ready_pending = false; } } void pumpTransmit() { if (!current() || transmitting || completion_pending || dispatcher->hostKissFramePending()) return; const uint8_t* bytes; size_t length; if (codec->peekTransmit(bytes, length) && dispatcher->submitHostKissFrame(bytes, length, profile_generation)) { codec->consumeTransmit(); transmitting = true; } } void stopTransport() { closeSession(); #if defined(ESP32) if (tcp) tcp->server.end(); delete tcp; tcp = nullptr; #endif } bool allocate(const Selection& selection, Codec*& next_codec #if defined(ESP32) , Tcp*& next_tcp #endif ) { next_codec = codec; #if defined(ESP32) next_tcp = tcp; #endif if (!selection.enabled) return true; if (!next_codec) next_codec = new (std::nothrow) Codec; if (!next_codec) return false; #if defined(ESP32) if (selection.transport == Transport::Tcp && !next_tcp) { next_tcp = new (std::nothrow) Tcp; if (!next_tcp) { if (next_codec != codec) delete next_codec; next_codec = codec; return false; } } #endif return true; } #if defined(ESP32) void loopTcp() { if (!tcp) return; const bool station = (WiFi.getMode() & WIFI_STA) && WiFi.status() == WL_CONNECTED; const uint32_t address = station ? uint32_t(WiFi.localIP()) : 0; if (!address && !tcp->address && !tcp->listening && !tcp->connected) return; if (!address || address != tcp->address) { closeSession(); tcp->server.end(); tcp->listening = tcp->attempted = false; tcp->address = address; if (!address) return; // Never bind this unauthenticated modem to the setup AP. tcp->server = WiFiServer(IPAddress(address), TcpPort, 2); } const uint32_t now = millis(); if (tcp->listening && !bool(tcp->server)) { tcp->server.end(); tcp->listening = tcp->attempted = false; } if (!tcp->listening && (!tcp->attempted || uint32_t(now - tcp->last_attempt) >= 1000)) { tcp->last_attempt = now; tcp->attempted = true; tcp->server.begin(); tcp->listening = bool(tcp->server); } if (tcp->connected && !tcp->client.connected()) closeSession(); if (tcp->listening) { WiFiClient incoming = tcp->server.available(); if (incoming) { if (tcp->connected || admission()) incoming.stop(); else { resetFrames(); if (!leaseRadio()) incoming.stop(); else { tcp->client = incoming; tcp->client.setNoDelay(true); tcp->connected = true; suspended = false; ready_pending = true; } } } } if (!tcp->connected) return; queueControls(); const uint8_t* bytes; size_t length; if (codec->peekOutput(bytes, length)) { if (length > ByteBudget) length = ByteBudget; const int written = ::send(tcp->client.fd(), bytes, length, MSG_DONTWAIT); if (written > 0) codec->consumeOutput(size_t(written)); else if (written < 0 && errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR) { closeSession(); return; } } for (size_t i = 0; i < ByteBudget && codec->canAcceptByte(); ++i) { uint8_t byte; const int got = ::recv(tcp->client.fd(), &byte, 1, MSG_DONTWAIT); if (got == 1) { codec->feed(byte); if (codec->returned()) { closeSession(); break; } } else { if (!got || (errno != EAGAIN && errno != EWOULDBLOCK && errno != EINTR)) closeSession(); break; } } } #endif } // namespace void begin(FILESYSTEM* fs, Radio& r, RadioProfileCLI& p, Dispatcher& d) { stopTransport(); delete codec; codec = nullptr; radio = &r; profiles = &p; dispatcher = &d; received = transmitted = failed = discarded = 0; settings.begin(fs); const auto& selected = settings.selection(); if (!selected.enabled || !r.supportsHostKiss()) return; #if !defined(ESP32) if (selected.transport == Transport::Tcp) return; #endif Codec* next_codec = nullptr; #if defined(ESP32) Tcp* next_tcp = nullptr; if (allocate(selected, next_codec, next_tcp)) { codec = next_codec; tcp = next_tcp; } #else if (allocate(selected, next_codec)) codec = next_codec; #endif } bool handleCommand(const char* command, char* reply, size_t capacity, bool local) { const bool get = !strncmp(command, "get ", 4); const bool set = !strncmp(command, "set ", 4); if (!get && !set) return false; const char* key = command + 4; bool transport = !strncmp(key, "kiss.transport", 14) && (!key[14] || key[14] == ' '); const bool status = !strncmp(key, "kiss", 4) && (!key[4] || key[4] == ' '); if (!transport && !status) return false; const char* value = key + (transport ? 14 : 4); if (get) { if (*value) { snprintf(reply, capacity, "Error: use get kiss%s", transport ? ".transport" : ""); return true; } const auto& selected = settings.selection(); if (transport) snprintf(reply, capacity, "> %s", selected.transport == Transport::Usb ? "usb" : "tcp"); else { const char* reason = admission(); snprintf(reply, capacity, "> %s transport=%s state=%s rx=%lu tx=%lu fail=%lu drop=%lu bad=%lu%s%s", selected.enabled ? "on" : "off", selected.transport == Transport::Usb ? "usb" : "tcp:5004", state(selected, reason), (unsigned long)received, (unsigned long)transmitted, (unsigned long)failed, (unsigned long)(discarded + (codec ? codec->droppedFrames() : 0)), (unsigned long)(codec ? codec->malformedFrames() : 0), selected.enabled && !active() && reason ? " reason=" : "", selected.enabled && !active() && reason ? reason : ""); } return true; } if (!local) { snprintf(reply, capacity, "Error: KISS settings require a local connection"); return true; } while (*value == ' ') ++value; Selection candidate = settings.selection(); if (transport) { if (!strcmp(value, "usb")) candidate.transport = Transport::Usb; else if (!strcmp(value, "tcp")) candidate.transport = Transport::Tcp; else { snprintf(reply, capacity, "Error: use set kiss.transport usb|tcp"); return true; } #if !defined(ESP32) if (candidate.transport == Transport::Tcp) { snprintf(reply, capacity, "Error: TCP KISS requires ESP32 station WiFi"); return true; } #endif } else if (!strcmp(value, "on")) candidate.enabled = true; else if (!strcmp(value, "off")) candidate.enabled = false; else { snprintf(reply, capacity, "Error: use set kiss on|off"); return true; } if (candidate.enabled && (!radio || !radio->supportsHostKiss())) { snprintf(reply, capacity, "Error: raw radio2 KISS is unsupported by this radio"); return true; } if (candidate == settings.selection() && settings.healthy()) { snprintf(reply, capacity, "OK - KISS unchanged"); return true; } Codec* next_codec; #if defined(ESP32) Tcp* next_tcp; if (!allocate(candidate, next_codec, next_tcp)) { #else if (!allocate(candidate, next_codec)) { #endif snprintf(reply, capacity, "Error: insufficient memory; KISS unchanged"); return true; } // Persist before disrupting the old stream. A failed save leaves its exact // radio lease and pending frames intact. if (!settings.save(candidate)) { if (next_codec != codec) delete next_codec; #if defined(ESP32) if (next_tcp != tcp) delete next_tcp; #endif snprintf(reply, capacity, "Error: KISS save failed; service unchanged"); return true; } closeSession(); codec = next_codec; #if defined(ESP32) tcp = next_tcp; if (tcp && (!candidate.enabled || candidate.transport != Transport::Tcp)) { tcp->server.end(); delete tcp; tcp = nullptr; } #endif if (!candidate.enabled) { delete codec; codec = nullptr; } snprintf(reply, capacity, candidate.enabled ? "OK - KISS on (%s); get kiss for status" : "OK - KISS off", candidate.transport == Transport::Usb ? "USB: send KISS FEND to start" : "station TCP 5004"); return true; } void loop() { if (!settings.selection().enabled || !codec) return; if (active() && !suspended && !current()) invalidateSession(); #if defined(ESP32) if (settings.selection().transport == Transport::Tcp) loopTcp(); #endif pumpTransmit(); } bool serviceUsb() { if (!settings.selection().enabled || settings.selection().transport != Transport::Usb) return false; Stream& port = usbCompanionPort(); if (!usb_session) { if (!port.available() || port.peek() != Codec::Fend) return false; resetFrames(); usb_session = true; suspended = !codec || !leaseRadio(); setUsbBinaryStreamOwned(true); ready_pending = !suspended; } // USB text writers may have been admitted just before the binary claim. // Their nonblocking retirement must finish before READY or host bytes move. if (!isUsbBinaryStreamReady()) return true; if (!codec) { // Allocation/capability failures must also keep binary bytes out of CLI. // This three-state RETURN recognizer needs no packet buffer. for (size_t i = 0; i < ByteBudget && port.available(); ++i) { const int value = port.read(); if (value < 0) break; if (value == Codec::Fend) { if (quarantine_return == 2) { closeSession(); return true; } quarantine_return = 1; } else if (value == 0xff && quarantine_return == 1) quarantine_return = 2; else quarantine_return = 0; } return true; } if (!suspended && !current()) invalidateSession(); if (!suspended) { queueControls(); const uint8_t* bytes; size_t length; if (codec->peekOutput(bytes, length)) { if (length > ByteBudget) length = ByteBudget; codec->consumeOutput(port.write(bytes, length)); } } for (size_t i = 0; i < ByteBudget && port.available() && codec->canAcceptByte(); ++i) { const int value = port.read(); if (value < 0) break; codec->feed(uint8_t(value)); if (codec->returned()) { closeSession(); return true; } if (suspended) codec->consumeTransmit(); // Never queue TX while suspended. } pumpTransmit(); return true; } bool usbActive() { return usb_session; } bool radioActive() { return current(); } void resetUsbSession() { if (usb_session) closeSession(); } void receive(const uint8_t* bytes, size_t length, uint8_t profile, uint32_t generation, float, float) { if (!codec || profile != 1 || generation != profile_generation || !current()) return; if (codec->queueReceive(bytes, length)) ++received; } void sendComplete(bool success) { // A completion from an invalidated session must not reach its replacement. if (!transmitting) return; transmitting = false; if (!current()) return; if (success) ++transmitted; else ++failed; completion_success = success; completion_pending = true; } }} // namespace mesh::hostkiss #endif