Files
wadamesh/src/helpers/esp32/MultiTransportCompanionInterface.cpp
T
Kaj SchittecatandClaude Opus 4.8 2353c56adc p4: T-Display P4 port — web VNC/remote, time-sync hardening, brightness + sound
LilyGo T-Display P4 (ESP32-P4 + factory ESP-AT C6) reaches POC: LoRa mesh, Wi-Fi
companion (TCP:5000), GPS, SD, battery gauge, chat persistence, and now:

- Web VNC/REMOTE/terminal via a first-byte router on the single ESP-AT listener
  (HTTP verbs -> WS server, '<' frames -> phone companion; both live at once).
  C6Server gains a _begun gate so never-listening instances stay inert.
- VNC freeze fixed: bounded one-CIPSEND-chunk drains under the WS client mutex
  (a whole-band blocking write starved the main loop), fast-drop of stalled
  mirror clients, SEND-OK wait 8s->4s.
- Wi-Fi robustness: CWJAP? miss-streak tolerance, CIPSERVER liveness verify +
  re-arm (a re-association silently killed the listener until reboot).
- Time sync: HTTP-Date fallback when SNTP is blocked; ClockFloorRTC gains a
  MAX_PLAUSIBLE_EPOCH guard (all boards) after a garbage-future hardware RTC
  read (2043) latched the ratchet.
- Brightness: RM69A10 DCS 0x51 driven live (CC slider + persisted pref).
- Sound: ES8311 + NS4150B notification chimes (P4Audio, esp-bsp-derived
  register sequence on Wire1), full HAS_UI_SOUND surface + CC Screen/Sound
  sliders; ccVolumeReleaseCb's hardcoded tanBeep -> uiSoundPreview.
- tdisplay_p4/: IDF project (build.sh, c6_at AT-over-SDIO driver, sdkconfigs,
  fetch-deps.sh vendoring, .gitignore for local payloads).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 19:13:23 +02:00

513 lines
19 KiB
C++

#include "MultiTransportCompanionInterface.h"
#include <helpers/RepeaterTcpOtaEmit.h>
#include "WifiRuntimeStore.h" // persist BLE on/off (ble_en) across reboots
#include "WebMirror.h" // web UI mirror bridge (served over the WS server)
#include <string.h>
// Companion push code for the per-packet RX log (matches MyMesh.cpp). It is kept OFF
// the BLE transport in writeFrameToAll — see the note there (issues #46, #54) — EXCEPT
// for one-shot passes armed via bleAllowNextRxLog() (echoes of our own sends, #94).
#define PUSH_CODE_LOG_RX_DATA 0x88
bool MultiTransportCompanionInterface::s_ble_rxlog_once = false;
MultiTransportCompanionInterface::MultiTransportCompanionInterface()
: _tcp_port(0), _ws_port(0), _tcp_started(false), _ws_started(false), _tcp_enabled(true), _isEnabled(false), _broadcast(false), _last_reply_target(REPLY_TARGET_USB), _ota_tcp_suspended(false), _ota_ws_suspended(false), _ota_ws_listen_paused(false)
#ifdef BLE_PIN_CODE
, _ble_begun(false), _ble_enabled(false), _ota_ble_released(false), _ble_pin_code(0)
#endif
{
for (size_t i = 0; i < sizeof(_client_ids) / sizeof(_client_ids[0]); i++)
_client_ids[i][0] = '\0';
#ifdef BLE_PIN_CODE
_ble_prefix[0] = '\0';
_ble_name[0] = '\0';
#endif
}
void MultiTransportCompanionInterface::begin(Stream& usb_serial, uint16_t tcp_port, uint16_t ws_port) {
_usb.begin(usb_serial);
_tcp_port = tcp_port;
_ws_port = ws_port;
_last_reply_target = REPLY_TARGET_USB;
}
void MultiTransportCompanionInterface::startTcpServer(bool wifi_connected) {
#if defined(HAS_TDISPLAY_P4)
// ESP-AT allows ONE listening port, so the router server (_p4_srv) owns it and dispatches
// each inbound connection by first byte — see p4RouteClients(). _tcp/_ws never listen
// themselves here; started flags only mark them willing to adopt routed clients.
if (_tcp_enabled && !_tcp_started && _tcp_port != 0) {
_p4_srv.begin(_tcp_port);
_tcp_started = true;
}
if (_tcp_enabled && !_ws_started && _ws_port != 0 && wifi_connected) {
_ws_started = true; // enables tickHandshake + the mirror stream task; _ws itself stays listen-less
printf("[WS] P4 router: web UI shares TCP:%u (first-byte dispatch)\n", (unsigned)_tcp_port);
}
#else
if (_tcp_enabled && !_tcp_started && _tcp_port != 0) {
_tcp.begin(_tcp_port);
_tcp_started = true;
}
// Plain WebSocket: start only when Wi-Fi has an address (caller defers TCP/WS start after splash).
if (_tcp_enabled && !_ws_started && _ws_port != 0 && wifi_connected) {
_ws.begin(_ws_port);
_ws_started = true;
}
#endif
}
#if defined(HAS_TDISPLAY_P4)
// One AT listener serves BOTH protocols. Accept every inbound connection here, peek its first
// byte, and hand the socket to the right server: HTTP requests start with an ASCII verb
// (GET/HEAD/POST/PUT/OPTIONS/DELETE...) and cover the web viewer page, the mirror/terminal
// WebSockets and the WS companion; companion frames from the phone app always start with '<'.
// A peer that stays silent gets the legacy benefit of the doubt after 3 s: some companion
// clients connect and only listen for pushes without ever sending.
void MultiTransportCompanionInterface::p4RouteClients() {
if (!_tcp_started && !_ws_started) return;
const int NPEND = (int)(sizeof(_p4_pend) / sizeof(_p4_pend[0]));
while (_p4_srv.hasClient()) {
int slot = -1;
for (int i = 0; i < NPEND; i++) {
if (!_p4_pend[i].used) {
slot = i;
break;
}
}
if (slot < 0) break; // all pending slots mid-peek; the accept FIFO holds the rest
WiFiClient incoming = _p4_srv.accept();
if (!incoming) continue;
_p4_pend[slot].c = incoming;
_p4_pend[slot].ms = millis();
_p4_pend[slot].used = true;
}
for (int i = 0; i < NPEND; i++) {
if (!_p4_pend[i].used) continue;
WiFiClient& c = _p4_pend[i].c;
if (!c.connected()) {
c.stop();
_p4_pend[i].used = false;
continue;
}
int b = c.peek();
bool is_http = (b == 'G' || b == 'H' || b == 'P' || b == 'O' || b == 'D');
if (b < 0 && millis() - _p4_pend[i].ms < 3000) continue; // no first byte yet
if (is_http && _ws_started) {
_ws.adoptClient(c);
} else if (!is_http && _tcp_started) {
_tcp.adoptClient(c);
} else {
c.stop(); // the matching server is suspended (OTA) — refuse politely
}
_p4_pend[i].used = false;
_p4_pend[i].c = WiFiClient(); // drop our shared link ref; the adopting server holds its own
}
}
#endif
// Web-mirror streaming runs on CORE 0 (parallel to the UI on core 1) so the browser's
// socket stays fed at full link speed even while core 1 is busy with a heavy LVGL render.
// The WS client array is guarded by WebSocketCompanionServer's recursive mutex, so this
// task and the main-loop companion/handshake code never corrupt each other. serviceMirror
// is non-blocking, so the mutex is held only briefly and cross-core contention is tiny.
static void wsMirrorStreamTask(void* arg) {
WebSocketCompanionServer* ws = static_cast<WebSocketCompanionServer*>(arg);
for (;;) {
ws->serviceMirror(g_web_mirror);
vTaskDelay(pdMS_TO_TICKS(g_web_mirror.clients() > 0 ? 2 : 25)); // fast when a browser is watching, idle otherwise
}
}
void MultiTransportCompanionInterface::tickWebSocketHandshake() {
#if defined(HAS_TDISPLAY_P4)
p4RouteClients(); // dispatch newly accepted AT-listener connections to _tcp / _ws
#endif
if (_ws_started) {
_ws.tickHandshake(); // accept + WS handshake stay on core 1 (the main loop)
if (!_mirror_task) { // spawn the core-0 streamer once, on first tick after the WS server is up
xTaskCreatePinnedToCore(wsMirrorStreamTask, "ws_mirror", 4096, &_ws, 2,
reinterpret_cast<TaskHandle_t*>(&_mirror_task), 0);
}
}
}
void MultiTransportCompanionInterface::stopTcpServer() {
if (_ws_started) {
_ws.stop();
_ws_started = false;
}
if (_tcp_started) {
_tcp.stop();
_tcp_started = false;
}
#if defined(HAS_TDISPLAY_P4)
_p4_srv.stop(); // the router owns the AT listener (see startTcpServer)
for (size_t i = 0; i < sizeof(_p4_pend) / sizeof(_p4_pend[0]); i++) {
if (_p4_pend[i].used) {
_p4_pend[i].c.stop();
_p4_pend[i].c = WiFiClient();
_p4_pend[i].used = false;
}
}
#endif
_tcp_enabled = false;
}
void MultiTransportCompanionInterface::enableTcp() {
_tcp_enabled = true;
// Restart immediately: don't wait for the next main-loop tick, and don't
// require wifi_started to be true (TCP itself doesn't need an IP address).
startTcpServer(false);
}
void MultiTransportCompanionInterface::disableTcp() {
stopTcpServer();
}
#ifdef BLE_PIN_CODE
void MultiTransportCompanionInterface::prepareBle(const char* prefix, char* name, uint32_t pin_code) {
if (prefix) {
strncpy(_ble_prefix, prefix, sizeof(_ble_prefix) - 1);
_ble_prefix[sizeof(_ble_prefix) - 1] = '\0';
} else {
_ble_prefix[0] = '\0';
}
if (name) {
strncpy(_ble_name, name, sizeof(_ble_name) - 1);
_ble_name[sizeof(_ble_name) - 1] = '\0';
} else {
_ble_name[0] = '\0';
}
_ble_pin_code = pin_code;
}
void MultiTransportCompanionInterface::beginBle(const char* prefix, char* name, uint32_t pin_code) {
prepareBle(prefix, name, pin_code);
_ble.begin(prefix, name, pin_code);
_ble_begun = true;
_ble_enabled = true;
_ota_ble_released = false;
_ble.enable();
}
void MultiTransportCompanionInterface::enableBle() {
if (!_ble_begun) {
// Deferred at boot (heap guard) or toggled on from off: bring the stack up
// now, live, from the params stashed by prepareBle()/beginBle().
if (_ble_prefix[0] == '\0' && _ble_name[0] == '\0') return; // no params known
char name[sizeof(_ble_name)];
strncpy(name, _ble_name, sizeof(name) - 1);
name[sizeof(name) - 1] = '\0';
_ble.begin(_ble_prefix, name, _ble_pin_code);
_ble_begun = true;
}
_ble_enabled = true;
wifiConfigSetBleEnabled(true); // persist so it survives reboot
_ble.enable();
}
void MultiTransportCompanionInterface::disableBle() {
_ble_enabled = false;
wifiConfigSetBleEnabled(false); // persist so BT stays off across reboot
_ble.disable(); // stop advertising + drop any connection
// NOTE: we deliberately do NOT NimBLEDevice::deinit() here. Tearing the BT
// controller down while Wi-Fi+BLE coexistence is active crashes — the esp_coex
// layer still holds a reference to the controller — so "off" stops advertising
// but keeps the NimBLE host resident. Its RAM is only fully reclaimed on reboot.
}
bool MultiTransportCompanionInterface::getBlePeerAddress(char* buf, size_t len) const {
if (!_ble_begun || !_ble_enabled) {
if (buf && len > 0) buf[0] = '\0';
return false;
}
return _ble.getConnectedPeerAddress(buf, len);
}
#endif
void MultiTransportCompanionInterface::enable() {
_isEnabled = true;
_usb.enable();
_last_reply_target = REPLY_TARGET_USB;
#ifdef BLE_PIN_CODE
if (_ble_begun && _ble_enabled)
_ble.enable();
#endif
}
void MultiTransportCompanionInterface::disable() {
_isEnabled = false;
_usb.disable();
#ifdef BLE_PIN_CODE
_ble.disable();
#endif
}
void MultiTransportCompanionInterface::prepareForHttpOta() {
_ota_tcp_suspended = false;
_ota_ws_suspended = false;
_ota_ws_listen_paused = false;
// Keep the Wi-Fi control path that issued `ota url` (TCP or WebSocket) alive so the meshcomod
// client stays connected for progress and the final OK/reboot message. Suspend the other
// transport plus BLE to free RAM for HTTPS/TLS.
const bool preserve_tcp = (_last_reply_target >= 0 && _last_reply_target < REPLY_TARGET_WS_0);
const bool preserve_ws = (_last_reply_target >= REPLY_TARGET_WS_0);
char line[168];
if (preserve_tcp) {
snprintf(line, sizeof(line), "OTA: minimal companion preserve=tcp suspend=ws,ble heap=%u",
(unsigned)ESP.getFreeHeap());
} else if (preserve_ws) {
snprintf(line, sizeof(line), "OTA: minimal companion preserve=ws suspend=tcp,ble heap=%u",
(unsigned)ESP.getFreeHeap());
} else {
snprintf(line, sizeof(line), "OTA: minimal companion unexpected reply_target=%d heap=%u", _last_reply_target,
(unsigned)ESP.getFreeHeap());
}
meshcoreRepeaterTcpOtaEmitLine(line);
if (preserve_ws && _tcp_started) {
_tcp.stop();
_tcp_started = false;
_ota_tcp_suspended = true;
meshcoreRepeaterTcpOtaEmitLine("OTA: suspended companion TCP server");
}
if (preserve_tcp && _ws_started) {
_ws.stop();
_ws_started = false;
_ota_ws_suspended = true;
meshcoreRepeaterTcpOtaEmitLine("OTA: suspended companion WebSocket server");
}
if (preserve_ws && _ws_started) {
_ws.pauseListen();
_ota_ws_listen_paused = true;
meshcoreRepeaterTcpOtaEmitLine("OTA: paused WS listen (client kept, no new connections)");
}
#ifdef BLE_PIN_CODE
if (_ble_begun && _ble_enabled) {
_ble.disable();
NimBLEDevice::deinit(true);
_ble_begun = false;
_ble_enabled = false;
_ota_ble_released = true;
meshcoreRepeaterTcpOtaEmitLine("OTA: released BLE stack");
}
#endif
snprintf(line, sizeof(line), "OTA: minimal companion after heap=%u max=%u", (unsigned)ESP.getFreeHeap(),
(unsigned)ESP.getMaxAllocHeap());
meshcoreRepeaterTcpOtaEmitLine(line);
}
bool MultiTransportCompanionInterface::isHttpOtaWifiControlSession() const {
return _last_reply_target != REPLY_TARGET_USB && _last_reply_target != REPLY_TARGET_BLE;
}
void MultiTransportCompanionInterface::restoreAfterHttpOta() {
if (_ota_ws_listen_paused) {
_ws.resumeListen();
_ota_ws_listen_paused = false;
meshcoreRepeaterTcpOtaEmitLine("OTA: resumed WebSocket listen");
}
#ifdef BLE_PIN_CODE
if (_ota_ble_released && _ble_prefix[0] && _ble_name[0]) {
char ble_name[sizeof(_ble_name)];
strncpy(ble_name, _ble_name, sizeof(ble_name) - 1);
ble_name[sizeof(ble_name) - 1] = '\0';
_ble.begin(_ble_prefix, ble_name, _ble_pin_code);
_ble_begun = true;
_ble_enabled = true;
_ble.enable();
_ota_ble_released = false;
meshcoreRepeaterTcpOtaEmitLine("OTA: restored BLE stack");
}
#endif
if (_ota_tcp_suspended) {
#if defined(HAS_TDISPLAY_P4)
_tcp_started = true; // the router still owns the AT listener; just resume adopting
#else
_tcp.begin(_tcp_port);
_tcp_started = true;
#endif
_ota_tcp_suspended = false;
meshcoreRepeaterTcpOtaEmitLine("OTA: restored companion TCP server");
}
if (_ota_ws_suspended) {
#if defined(HAS_TDISPLAY_P4)
_ws_started = true; // ditto — never begin a second AT listener on _ws_port
#else
_ws.begin(_ws_port);
_ws_started = true;
#endif
_ota_ws_suspended = false;
meshcoreRepeaterTcpOtaEmitLine("OTA: restored companion WebSocket server");
}
}
bool MultiTransportCompanionInterface::isConnected() const {
if (_usb.isConnected()) return true;
#ifdef BLE_PIN_CODE
if (_ble_begun && _ble_enabled && _ble.isConnected()) return true;
#endif
if (_tcp_started && _tcp.connectedCount() > 0) return true;
if (_ws_started && _ws.connectedCount() > 0) return true;
return false;
}
bool MultiTransportCompanionInterface::isWriteBusy() const {
if (_usb.isWriteBusy()) return true;
#ifdef BLE_PIN_CODE
if (_ble_begun && _ble_enabled && _ble.isWriteBusy()) return true;
#endif
return false;
}
size_t MultiTransportCompanionInterface::checkRecvFrame(uint8_t dest[]) {
if (!_isEnabled) return 0;
#ifdef BLE_PIN_CODE
// Drain BLE send queue every loop so PC (or any BLE client) gets pushes even when USB/TCP are polled first.
if (_ble_begun && _ble_enabled)
_ble.drainSendQueue();
#endif
// Poll USB first (preserve Home Assistant / USB priority). Do not overwrite _last_reply_target
// when caller is in the middle of a contact-list stream (handled by caller saving/restoring target).
size_t len = _usb.checkRecvFrame(dest);
if (len > 0) {
_last_reply_target = REPLY_TARGET_USB;
return len;
}
// Then poll TCP clients (only after TCP server was started)
if (_tcp_started) {
int tcp_client = -1;
len = _tcp.pollRecvFrame(dest, &tcp_client);
if (len > 0) {
_last_reply_target = tcp_client;
return len;
}
}
// Then poll WebSocket clients
if (_ws_started) {
int ws_client = -1;
len = _ws.pollRecvFrame(dest, &ws_client);
if (len > 0) {
_last_reply_target = REPLY_TARGET_WS_0 + ws_client;
return len;
}
}
#ifdef BLE_PIN_CODE
if (_ble_begun && _ble_enabled) {
len = _ble.checkRecvFrame(dest);
if (len > 0) {
_last_reply_target = REPLY_TARGET_BLE;
return len;
}
}
#endif
return 0;
}
size_t MultiTransportCompanionInterface::writeFrame(const uint8_t src[], size_t len) {
if (len > MAX_FRAME_SIZE) return 0;
// Single-target only (command responses, sync history). Never broadcast.
if (_last_reply_target == REPLY_TARGET_USB)
return _usb.writeFrame(src, len);
#ifdef BLE_PIN_CODE
if (_last_reply_target == REPLY_TARGET_BLE && _ble_begun && _ble_enabled)
return _ble.writeFrame(src, len);
#endif
if (_last_reply_target >= REPLY_TARGET_WS_0 && _last_reply_target < REPLY_TARGET_WS_0 + WS_COMPANION_MAX_CLIENTS && _ws_started)
return _ws.writeToClient(_last_reply_target - REPLY_TARGET_WS_0, src, len);
if (_tcp_started)
return _tcp.writeToClient(_last_reply_target, src, len);
return 0;
}
size_t MultiTransportCompanionInterface::writeFrameToAll(const uint8_t src[], size_t len) {
if (len > MAX_FRAME_SIZE) return 0;
if (!_broadcast)
return writeFrame(src, len);
bool all_ok = true;
if (_usb.isConnected() && _usb.writeFrame(src, len) != len)
all_ok = false;
#ifdef BLE_PIN_CODE
// The per-packet RX log floods BLE's ~16 frames/sec budget on a busy mesh, starving
// the frames that matter — chat messages + admin responses (issues #46, #54). So it
// is kept OFF BLE (USB/TCP/WS have the bandwidth) — EXCEPT when MyMesh::logRxRaw
// armed a one-shot pass because this frame is an echo of OUR OWN flood send: the
// app's "Repeats heard" is computed exactly from those (it broke on BLE when the
// blanket skip landed in beta_23 — issue #94), and a few echoes per send are
// nowhere near the flood that caused #46/#54.
const bool rxlog = (len > 0 && src[0] == PUSH_CODE_LOG_RX_DATA);
const bool ble_pass = rxlog && s_ble_rxlog_once;
if (rxlog) s_ble_rxlog_once = false; // consume the one-shot either way
const bool skip_ble = rxlog && !ble_pass;
if (!skip_ble && _ble_begun && _ble_enabled && _ble.isConnected() && _ble.writeFrame(src, len) != len)
all_ok = false;
#endif
if (_tcp_started && _tcp.connectedCount() > 0 && _tcp.writeToAllClients(src, len) != len)
all_ok = false;
if (_ws_started && _ws.connectedCount() > 0 && _ws.writeToAllClients(src, len) != len)
all_ok = false;
return all_ok ? len : 0;
}
int MultiTransportCompanionInterface::_clientIdSlot() const {
#ifdef BLE_PIN_CODE
if (_last_reply_target == REPLY_TARGET_USB) return 0;
if (_last_reply_target == REPLY_TARGET_BLE) return 1;
if (_last_reply_target >= REPLY_TARGET_WS_0 && _last_reply_target < REPLY_TARGET_WS_0 + WS_COMPANION_MAX_CLIENTS)
return 2 + TCP_COMPANION_MAX_CLIENTS + (_last_reply_target - REPLY_TARGET_WS_0);
return _last_reply_target + 2; // TCP 0..N -> slots 2..
#else
if (_last_reply_target >= REPLY_TARGET_WS_0 && _last_reply_target < REPLY_TARGET_WS_0 + WS_COMPANION_MAX_CLIENTS)
return 1 + TCP_COMPANION_MAX_CLIENTS + (_last_reply_target - REPLY_TARGET_WS_0);
return _last_reply_target + 1;
#endif
}
void MultiTransportCompanionInterface::setCurrentClientId(const char* id) {
int slot = _clientIdSlot();
if (slot >= 0 && slot < (int)(sizeof(_client_ids) / sizeof(_client_ids[0]))) {
if (id) {
strncpy(_client_ids[slot], id, _max_client_id_len - 1);
_client_ids[slot][_max_client_id_len - 1] = '\0';
} else {
_client_ids[slot][0] = '\0';
}
}
}
void MultiTransportCompanionInterface::getCurrentClientId(char* dest, size_t max_len) const {
if (!dest || max_len == 0) return;
dest[0] = '\0';
int slot = _clientIdSlot();
if (slot < 0 || slot >= (int)(sizeof(_client_ids) / sizeof(_client_ids[0]))) return;
// If app sent client_id in CMD_APP_START, use it; otherwise use connection-based id
// so non-custom clients (HA, MeshCore app) still get per-connection history without sending anything.
if (_client_ids[slot][0] != '\0') {
strncpy(dest, _client_ids[slot], max_len - 1);
dest[max_len - 1] = '\0';
return;
}
#ifdef BLE_PIN_CODE
static const char* const default_ids[] = { "usb", "ble", "tcp0", "tcp1", "tcp2", "ws0", "ws1" };
#else
static const char* const default_ids[] = { "usb", "tcp0", "tcp1", "tcp2", "ws0", "ws1" };
#endif
size_t n = sizeof(default_ids) / sizeof(default_ids[0]);
if ((size_t)slot < n) {
strncpy(dest, default_ids[slot], max_len - 1);
dest[max_len - 1] = '\0';
}
}