- Contacts list rebuilds on advert (force past the count-cache) + a count-delta safety net, so a node discovered off-tab or added by the phone app shows immediately (#73) - New "Advertise on boot" toggle in the Advertise app, off by default (#76) - Tanmatsu: cache Wi-Fi map tiles on microSD (the P4's internal FFat metadata layer is broken — f_getfree/f_stat lie); read tiles via open() not the misreporting exists()/size() - Tanmatsu: persist contacts/channels/chat on microSD (DataStore secondary FS + chat history) so they survive reboot; identity/prefs stay on the (working) internal FFat - Tanmatsu: boot fix (drop Serial.begin re-init of USB-Serial-JTAG), MQTT disabled, popups fit + scroll, keyboard-nav scoped to the open popup, 2000 contacts, Large UI default - Emoji/quick-reply Esc/red-X closes the popup not the chat; lock-screen unlock after msg-wake Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
221 lines
8.8 KiB
C++
221 lines
8.8 KiB
C++
#if defined(ESP32) && defined(MULTI_TRANSPORT_COMPANION)
|
|
#include "MqttBridge.h"
|
|
|
|
// The single global bridge instance (extern in the header). Defined for every board: the header
|
|
// makes MqttBridge a no-op stub on the Tanmatsu, and the real bridge below on all other boards.
|
|
MqttBridge mqtt_bridge;
|
|
|
|
#if !defined(HAS_TANMATSU) // ---- real PubSubClient/WiFiClient implementation; NOT built on the P4 ----
|
|
#include <Preferences.h>
|
|
#include <WiFi.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include "esp_random.h"
|
|
#include "mbedtls/gcm.h"
|
|
#include "mbedtls/md.h"
|
|
#include "mbedtls/base64.h"
|
|
|
|
// Read every field from the "mqtt" NVS namespace into the members. isKey() guards
|
|
// avoid the [E] NOT_FOUND log spam that getString emits for absent keys on the
|
|
// USB-CDC companion stream (see TouchPrefsStore prefsGetStr note).
|
|
void MqttBridge::loadConfig() {
|
|
_enabled = false; _pubDm = false; _pubChannel = true;
|
|
_host[0] = _user[0] = _pwd[0] = _psk[0] = '\0';
|
|
_port = 1883;
|
|
|
|
Preferences p;
|
|
if (p.begin("mqtt", true)) {
|
|
_enabled = p.getBool("en", false);
|
|
_port = (uint16_t)p.getUInt("port", 1883);
|
|
_pubDm = p.getBool("dm", false);
|
|
_pubChannel = p.getBool("ch", true);
|
|
if (p.isKey("host")) p.getString("host", _host, sizeof(_host));
|
|
if (p.isKey("user")) p.getString("user", _user, sizeof(_user));
|
|
if (p.isKey("pwd")) p.getString("pwd", _pwd, sizeof(_pwd));
|
|
if (p.isKey("psk")) p.getString("psk", _psk, sizeof(_psk));
|
|
p.end();
|
|
}
|
|
deriveKey();
|
|
if (!_enabled || _host[0] == '\0') _enabled = false;
|
|
}
|
|
|
|
// Derive the AES key from the passphrase. mbedtls_md() (generic digest) is used
|
|
// rather than mbedtls_sha256(), whose _ret suffix differs across mbedTLS 2.x/3.x.
|
|
void MqttBridge::deriveKey() {
|
|
_encOn = (_psk[0] != '\0');
|
|
if (!_encOn) { memset(_key, 0, sizeof(_key)); return; }
|
|
const mbedtls_md_info_t* info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256);
|
|
if (!info || mbedtls_md(info, (const unsigned char*)_psk, strlen(_psk), _key) != 0) {
|
|
memset(_key, 0, sizeof(_key));
|
|
_encOn = false; // can't derive → fail closed to plaintext-disabled publishing
|
|
}
|
|
}
|
|
|
|
void MqttBridge::begin(const char* nodeHex) {
|
|
strncpy(_nodeHex, nodeHex, sizeof(_nodeHex) - 1);
|
|
_nodeHex[sizeof(_nodeHex) - 1] = '\0';
|
|
|
|
loadConfig();
|
|
if (!_enabled) return;
|
|
|
|
_mqtt.setServer(_host, _port);
|
|
_mqtt.setKeepAlive(60);
|
|
_mqtt.setSocketTimeout(2); // bound connect/read — a dead broker must not stall loop()
|
|
_mqtt.setBufferSize(_encOn ? 1024 : 512); // sealed base64 needs the bigger buffer
|
|
Serial.printf("[MQTT] configured -> %s:%u dm=%d ch=%d enc=%d\n",
|
|
_host, _port, (int)_pubDm, (int)_pubChannel, (int)_encOn);
|
|
}
|
|
|
|
bool MqttBridge::reconnect() {
|
|
if (!_enabled || WiFi.status() != WL_CONNECTED) return false;
|
|
|
|
char clientId[32], lwtTopic[80];
|
|
snprintf(clientId, sizeof(clientId), "wadamesh-%s", _nodeHex);
|
|
snprintf(lwtTopic, sizeof(lwtTopic), "wadamesh/%s/status", _nodeHex);
|
|
|
|
bool ok = _user[0]
|
|
? _mqtt.connect(clientId, _user, _pwd, lwtTopic, 0, true, "offline")
|
|
: _mqtt.connect(clientId, nullptr, nullptr, lwtTopic, 0, true, "offline");
|
|
|
|
if (ok) {
|
|
_mqtt.publish(lwtTopic, "online", true);
|
|
Serial.printf("[MQTT] connected as %s\n", clientId);
|
|
} else {
|
|
Serial.printf("[MQTT] connect failed, rc=%d\n", _mqtt.state());
|
|
}
|
|
return ok;
|
|
}
|
|
|
|
void MqttBridge::loop() {
|
|
if (!_enabled) return;
|
|
if (!_mqtt.connected()) {
|
|
uint32_t now = millis();
|
|
if ((uint32_t)(now - _lastReconnectMs) >= RECONNECT_INTERVAL_MS) {
|
|
_lastReconnectMs = now;
|
|
reconnect();
|
|
}
|
|
return;
|
|
}
|
|
_mqtt.loop();
|
|
}
|
|
|
|
// AES-256-GCM seal: out = base64( nonce[12] || ciphertext || tag[16] ). Single-task
|
|
// use (mesh callbacks + loop() run on the same Arduino task), so the stack buffers
|
|
// are safe. Returns false on any crypto/size failure (caller then publishes nothing).
|
|
bool MqttBridge::sealToB64(const char* plain, char* out, size_t outCap) {
|
|
size_t plen = strlen(plain);
|
|
if (plen > 480) return false; // matches the json[] producers below
|
|
|
|
uint8_t blob[12 + 480 + 16];
|
|
esp_fill_random(blob, 12); // fresh nonce per message
|
|
uint8_t tag[16];
|
|
|
|
mbedtls_gcm_context gcm;
|
|
mbedtls_gcm_init(&gcm);
|
|
int rc = mbedtls_gcm_setkey(&gcm, MBEDTLS_CIPHER_ID_AES, _key, 256);
|
|
if (rc == 0) {
|
|
rc = mbedtls_gcm_crypt_and_tag(&gcm, MBEDTLS_GCM_ENCRYPT, plen,
|
|
blob, 12, nullptr, 0,
|
|
(const unsigned char*)plain, blob + 12,
|
|
16, tag);
|
|
}
|
|
mbedtls_gcm_free(&gcm);
|
|
if (rc != 0) return false;
|
|
|
|
memcpy(blob + 12 + plen, tag, 16);
|
|
size_t blobLen = 12 + plen + 16, olen = 0;
|
|
return mbedtls_base64_encode((unsigned char*)out, outCap, &olen, blob, blobLen) == 0;
|
|
}
|
|
|
|
void MqttBridge::pub(const char* subtopic, const char* json) {
|
|
if (!_enabled || !_mqtt.connected()) return;
|
|
char topic[80];
|
|
snprintf(topic, sizeof(topic), "wadamesh/%s/%s", _nodeHex, subtopic);
|
|
if (_encOn) {
|
|
char b64[720]; // base64 of the ~508-byte sealed blob
|
|
if (sealToB64(json, b64, sizeof(b64))) _mqtt.publish(topic, b64);
|
|
} else {
|
|
_mqtt.publish(topic, json);
|
|
}
|
|
}
|
|
|
|
void MqttBridge::escapeJson(const char* src, char* dst, size_t dstLen) {
|
|
static const char* hexd = "0123456789abcdef";
|
|
size_t j = 0;
|
|
for (size_t i = 0; src[i] && j + 7 < dstLen; ++i) { // +7: worst case is \u00XX (6) + NUL
|
|
unsigned char c = (unsigned char)src[i];
|
|
if (c == '"' || c == '\\') { dst[j++] = '\\'; dst[j++] = (char)c; }
|
|
else if (c == '\n') { dst[j++] = '\\'; dst[j++] = 'n'; }
|
|
else if (c == '\r') { dst[j++] = '\\'; dst[j++] = 'r'; }
|
|
else if (c == '\t') { dst[j++] = '\\'; dst[j++] = 't'; }
|
|
else if (c < 0x20) { // other control chars → \u00XX (valid JSON)
|
|
dst[j++] = '\\'; dst[j++] = 'u'; dst[j++] = '0'; dst[j++] = '0';
|
|
dst[j++] = hexd[(c >> 4) & 0xF]; dst[j++] = hexd[c & 0xF];
|
|
} else { dst[j++] = (char)c; }
|
|
}
|
|
dst[j] = '\0';
|
|
}
|
|
|
|
void MqttBridge::publishDM(const char* senderName, const uint8_t* senderKey32,
|
|
float snr, uint8_t hops, uint32_t ts, const char* text) {
|
|
if (!_enabled || !_pubDm || !_mqtt.connected()) return; // DMs are opt-in
|
|
char keyHex[13] = {};
|
|
for (int i = 0; i < 6; ++i) snprintf(keyHex + i * 2, 3, "%02x", senderKey32[i]);
|
|
|
|
char safeName[48], safeText[300];
|
|
escapeJson(senderName, safeName, sizeof(safeName));
|
|
escapeJson(text, safeText, sizeof(safeText));
|
|
|
|
char json[480];
|
|
snprintf(json, sizeof(json),
|
|
"{\"sender\":\"%s\",\"key\":\"%s\",\"snr\":%.1f,\"hops\":%u,\"ts\":%lu,\"text\":\"%s\"}",
|
|
safeName, keyHex, snr, (unsigned)hops, (unsigned long)ts, safeText);
|
|
pub("msg/dm", json);
|
|
}
|
|
|
|
void MqttBridge::publishChannel(int channelIdx, const char* channelName,
|
|
float snr, uint8_t hops, uint32_t ts, const char* text) {
|
|
if (!_enabled || !_pubChannel || !_mqtt.connected()) return; // channel publish toggle
|
|
char safeName[48], safeText[300];
|
|
escapeJson(channelName, safeName, sizeof(safeName));
|
|
escapeJson(text, safeText, sizeof(safeText));
|
|
|
|
char json[480];
|
|
snprintf(json, sizeof(json),
|
|
"{\"channel\":\"%s\",\"ch_idx\":%d,\"snr\":%.1f,\"hops\":%u,\"ts\":%lu,\"text\":\"%s\"}",
|
|
safeName, channelIdx, snr, (unsigned)hops, (unsigned long)ts, safeText);
|
|
pub("msg/ch", json);
|
|
}
|
|
|
|
void MqttBridge::saveConfig(const char* host, uint16_t port,
|
|
const char* user, const char* pwd,
|
|
bool pubDm, bool pubChannel, const char* psk, bool enable) {
|
|
Preferences p;
|
|
if (!p.begin("mqtt", false)) return;
|
|
p.putBool("en", enable);
|
|
p.putString("host", host);
|
|
p.putUInt("port", port);
|
|
p.putString("user", user);
|
|
p.putString("pwd", pwd);
|
|
p.putBool("dm", pubDm);
|
|
p.putBool("ch", pubChannel);
|
|
p.putString("psk", psk);
|
|
p.end();
|
|
}
|
|
|
|
void MqttBridge::reloadConfig() {
|
|
if (_mqtt.connected()) _mqtt.disconnect();
|
|
loadConfig();
|
|
if (!_enabled) { Serial.println("[MQTT] disabled"); return; }
|
|
_mqtt.setServer(_host, _port);
|
|
_mqtt.setKeepAlive(60);
|
|
_mqtt.setSocketTimeout(2);
|
|
_mqtt.setBufferSize(_encOn ? 1024 : 512);
|
|
_lastReconnectMs = 0; // reconnect on next loop() tick
|
|
Serial.printf("[MQTT] reloaded -> %s:%u en=%d dm=%d ch=%d enc=%d\n",
|
|
_host, _port, (int)_enabled, (int)_pubDm, (int)_pubChannel, (int)_encOn);
|
|
}
|
|
|
|
#endif // !HAS_TANMATSU (real implementation)
|
|
#endif // ESP32 && MULTI_TRANSPORT_COMPANION
|