From cdcef6e161cf71cd21ea665b7413839750c775d7 Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 22 Jun 2026 15:40:21 -0700 Subject: [PATCH] feat(mqtt): add dual-stack IPv4/IPv6 support to MQTT bridge --- MQTT_IMPLEMENTATION.md | 20 ++++++++++++++ src/helpers/CommonCLI_Observer.cpp | 23 ++++++++++++++-- src/helpers/bridges/MQTTBridge.cpp | 43 +++++++++++++++++++++++++++++- src/helpers/bridges/MQTTBridge.h | 4 +++ 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/MQTT_IMPLEMENTATION.md b/MQTT_IMPLEMENTATION.md index 5ba66247..301b281a 100644 --- a/MQTT_IMPLEMENTATION.md +++ b/MQTT_IMPLEMENTATION.md @@ -249,6 +249,26 @@ The MQTT bridge comes with the following defaults for fresh installs (unless ove - **Timezone Offset**: 0 (fallback, no offset, unless `MQTT_DEFAULT_TIMEZONE_OFFSET` is set) - **Repeat (forwarding)**: On (set `repeat off` for receive-only observers) +## IPv6 Support + +Observer builds run **dual-stack**: IPv4 continues to work exactly as before, and the node +*additionally* acquires an IPv6 address when the network supports it. This is fully automatic +and requires no configuration. + +- **How it works**: once WiFi has an IPv4 address, the node enables IPv6 and obtains a global + address via Router Advertisement / SLAAC. A dual-stack router with RA/SLAAC is required; + on IPv4-only networks the node simply stays IPv4-only (graceful degradation). +- **Visibility**: the global IPv6 address appears in `get wifi.status` once assigned, e.g. + `> connected, IP: 192.168.1.42, IPv6: 2001:db8::abcd, RSSI: -62 dBm, uptime: ...`. + The field is omitted when no global address is present (link-local is not reported). IPv6 is + CLI/serial-visible only — it is not shown on the OLED. +- **Custom broker over IPv6**: use a bracketed literal in a full URI + (`set mqttN.server mqtts://[2001:db8::1]:8883`), or a bare literal + (`set mqttN.server 2001:db8::1`) which is bracketed automatically. Hostname presets need no + changes — DNS resolves AAAA records automatically once the stack is dual-stack. +- **Cost**: none to budget. IPv6 is already compiled into the ESP32 Arduino lwIP that every + build links; enabling it at runtime only adds a couple of address slots. + ## CLI Commands ### MQTT Slot Commands diff --git a/src/helpers/CommonCLI_Observer.cpp b/src/helpers/CommonCLI_Observer.cpp index 413e92b4..22de73cf 100644 --- a/src/helpers/CommonCLI_Observer.cpp +++ b/src/helpers/CommonCLI_Observer.cpp @@ -780,7 +780,26 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf default: status_str = "unknown"; break; } if (status == WL_CONNECTED) { - sprintf(reply, "> %s, IP: %s, RSSI: %d dBm", status_str, WiFi.localIP().toString().c_str(), WiFi.RSSI()); + // reply points at the caller's char[160] command buffer (see main.cpp). + const size_t kReplyBufSize = 160; + sprintf(reply, "> %s, IP: %s", status_str, WiFi.localIP().toString().c_str()); +#ifdef WITH_MQTT_BRIDGE + // Group IPv6 directly after IPv4 when a global/ULA address is assigned. + char v6[46]; + if (MQTTBridge::getGlobalIPv6(v6, sizeof(v6))) { + size_t v6_len = strlen(reply); + if (v6_len < kReplyBufSize) { + snprintf(reply + v6_len, kReplyBufSize - v6_len, ", IPv6: %s", v6); + } + } +#endif + // RSSI right after the IP addresses. + { + size_t rssi_len = strlen(reply); + if (rssi_len < kReplyBufSize) { + snprintf(reply + rssi_len, kReplyBufSize - rssi_len, ", RSSI: %d dBm", WiFi.RSSI()); + } + } #ifdef WITH_MQTT_BRIDGE unsigned long connect_at = MQTTBridge::getWifiConnectedAtMillis(); if (connect_at != 0) { @@ -791,7 +810,7 @@ bool CommonCLI::handleObserverGetCmd(uint32_t sender_timestamp, const char* conf unsigned long m = (uptime_sec % 3600) / 60; unsigned long s = uptime_sec % 60; size_t len = strlen(reply); - const size_t reply_remaining = 128; + const size_t reply_remaining = (len < kReplyBufSize) ? (kReplyBufSize - len) : 0; if (d > 0) { snprintf(reply + len, reply_remaining, ", uptime: %lud %luh %lum %lus", d, h, m, s); } else if (h > 0) { diff --git a/src/helpers/bridges/MQTTBridge.cpp b/src/helpers/bridges/MQTTBridge.cpp index aa737d98..eeae09d2 100644 --- a/src/helpers/bridges/MQTTBridge.cpp +++ b/src/helpers/bridges/MQTTBridge.cpp @@ -15,6 +15,7 @@ #ifdef ESP_PLATFORM #include +#include #include #include #include @@ -179,6 +180,10 @@ static unsigned long s_wifi_connected_at = 0; static uint8_t s_wifi_disconnect_reason = 0; static unsigned long s_wifi_disconnect_time = 0; +// Most recent global/unique-local IPv6 address (SLAAC), as a string; empty when none. +// Populated from the ARDUINO_EVENT_WIFI_STA_GOT_IP6 handler; cleared on WiFi disconnect. +static char s_global_ipv6[46] = ""; + #ifdef MQTT_MEMORY_DEBUG // #region agent log static void agentLogHeap(const char* location, const char* message, const char* hypothesisId, @@ -201,6 +206,17 @@ unsigned long MQTTBridge::getWifiConnectedAtMillis() { return s_wifi_connected_at; } +bool MQTTBridge::getGlobalIPv6(char* buf, size_t len) { + if (buf == nullptr || len == 0) return false; + if (s_global_ipv6[0] == '\0') { + buf[0] = '\0'; + return false; + } + strncpy(buf, s_global_ipv6, len - 1); + buf[len - 1] = '\0'; + return true; +} + void MQTTBridge::formatMqttStatusReply(char* buf, size_t bufsize, const MQTTPrefs* obs) { if (buf == nullptr || bufsize == 0) return; const char* msgs = (obs && obs->mqtt_status_enabled) ? "on" : "off"; @@ -829,11 +845,23 @@ void MQTTBridge::initializeWiFiInTask() { switch(event) { case ARDUINO_EVENT_WIFI_STA_GOT_IP: MQTT_DEBUG_PRINTLN("WiFi connected: %s", IPAddress(info.got_ip.ip_info.ip.addr).toString().c_str()); + // Kick off IPv6 link-local + RA/SLAAC (additive; IPv4 path unchanged). Idempotent. + WiFi.enableIpV6(); // Set flag to trigger NTP sync from loop() instead of doing it here if (!_ntp_synced && !_ntp_sync_pending) { _ntp_sync_pending = true; } break; + case ARDUINO_EVENT_WIFI_STA_GOT_IP6: { + // Store only global/unique-local addresses; link-local isn't useful for diagnostics. + esp_ip6_addr_t ip6 = info.got_ip6.ip6_info.ip; + esp_ip6_addr_type_t type = esp_netif_ip6_get_addr_type(&ip6); + if (type == ESP_IP6_ADDR_IS_GLOBAL || type == ESP_IP6_ADDR_IS_UNIQUE_LOCAL) { + snprintf(s_global_ipv6, sizeof(s_global_ipv6), IPV6STR, IPV62STR(ip6)); + MQTT_DEBUG_PRINTLN("WiFi IPv6: %s", s_global_ipv6); + } + break; + } case ARDUINO_EVENT_WIFI_STA_DISCONNECTED: s_wifi_disconnect_reason = info.wifi_sta_disconnected.reason; s_wifi_disconnect_time = millis(); @@ -1325,7 +1353,16 @@ void MQTTBridge::setupSlot(int index) { } else if (slot.port == 443) { proto = "wss"; } - snprintf(slot.broker_uri, sizeof(slot.broker_uri), "%s://%s:%d", proto, slot.host, slot.port); + // Bare IPv6 literals (contain ':' but no '.', not already bracketed) must be wrapped + // in [..] so the ":port" suffix isn't mistaken for part of the address. + bool bare_ipv6 = (slot.host[0] != '[') && + (strchr(slot.host, ':') != nullptr) && + (strchr(slot.host, '.') == nullptr); + if (bare_ipv6) { + snprintf(slot.broker_uri, sizeof(slot.broker_uri), "%s://[%s]:%d", proto, slot.host, slot.port); + } else { + snprintf(slot.broker_uri, sizeof(slot.broker_uri), "%s://%s:%d", proto, slot.host, slot.port); + } } slot.client->setServer(slot.broker_uri); MQTT_DEBUG_PRINTLN("MQTT%d custom broker URI: %s (host='%s', port=%u)", @@ -2080,6 +2117,9 @@ bool MQTTBridge::handleWiFiConnection(unsigned long now) { _wifi_disconnected_time = 0; s_wifi_connected_at = now; _wifi_reconnect_backoff_attempt = 0; + // Re-arm IPv6 link-local after a reconnect (covers paths that re-run WiFi.begin + // and skip the GOT_IP event ordering). Idempotent. + WiFi.enableIpV6(); #ifdef ESP_PLATFORM wifi_ps_type_t ps_mode; uint8_t ps_pref = _obs->wifi_power_save; @@ -2106,6 +2146,7 @@ bool MQTTBridge::handleWiFiConnection(unsigned long now) { if (_last_wifi_status == WL_CONNECTED) { _wifi_disconnected_time = now; s_wifi_connected_at = 0; + s_global_ipv6[0] = '\0'; // drop stale IPv6 so get wifi.status doesn't report it // Disconnect all slot clients when WiFi drops for (int i = 0; i < RUNTIME_MQTT_SLOTS; i++) { if (_slots[i].client && _slots[i].connected) { diff --git a/src/helpers/bridges/MQTTBridge.h b/src/helpers/bridges/MQTTBridge.h index 377ea51c..ee5b8b98 100644 --- a/src/helpers/bridges/MQTTBridge.h +++ b/src/helpers/bridges/MQTTBridge.h @@ -430,6 +430,10 @@ public: static unsigned long getWifiConnectedAtMillis(); + // Copies the current global/unique-local IPv6 address (string) into buf. + // Returns false (and writes an empty string) when no global IPv6 is assigned. + static bool getGlobalIPv6(char* buf, size_t len); + /** * Per-slot outage accessors used by AlertReporter to detect prolonged * MQTT broker outages. Indices are 0..RUNTIME_MQTT_SLOTS-1.