457 lines
15 KiB
C++
457 lines
15 KiB
C++
#include "HostKiss.h"
|
|
#if MESH_ENABLE_HOST_KISS
|
|
|
|
#include "HostKissCodec.h"
|
|
#include "HostKissSettings.h"
|
|
#include "RadioProfileCLI.h"
|
|
#include "UsbLogging.h"
|
|
#include <Dispatcher.h>
|
|
#include <new>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#if defined(ESP32)
|
|
#include <WiFi.h>
|
|
#include <errno.h>
|
|
#include <lwip/sockets.h>
|
|
#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
|