From e48812a226ad34c38bf0765a6c3597bc9a68e444 Mon Sep 17 00:00:00 2001 From: vfranchi Date: Wed, 30 Sep 2026 13:21:13 -0300 Subject: [PATCH 1/2] Add MQTT / Home Assistant integration with auto-discovery The radar publishes itself to Home Assistant over MQTT with auto-discovery: ten entities (range select, miles/runways switches, latitude/longitude numbers, and sensors for aircraft count, nearest aircraft, Wi-Fi RSSI, free heap and device info), so there is no YAML on the HA side. Commands arrive on /cmd/# and are applied to NVS and re-published as state, so HA always shows the value the radar actually accepted. Configuration is eight fields in the existing config portal, stored in NVS. Every field starts empty on purpose: the firmware ships no broker address, user or password, and an empty host keeps MQTT completely inert. Details that were measured, not guessed, on an ESP32-C3 Super Mini: - The broker session is kept (cleanSession=false, commands subscribed at QoS 1). With PubSubClient's default clean session the broker discards the subscription and any command published while the connection is down, which showed up as switch taps in Home Assistant being silently ignored. - Discovery publishes one entity at a time with a 250 ms gap, and the number entities declare step=0.001: Home Assistant drops a discovery message whose step is below that, without logging anything on the broker. - Memory. There is no room for a second TLS stack on this chip, so MQTT runs plain TCP on 1883 only. The ADS-B poll now keeps its TLS session alive between polls (static WiFiClientSecure/HTTPClient + setReuse(true)): the handshake costs ~1 s of CPU here, and paying it every poll is what made mbedtls_ssl_setup() -- two 16 KB record buffers, no CONFIG_MBEDTLS_SSL_VARIABLE_BUFFER_LENGTH on the C3 -- fail once the MQTT client was connected. The MQTT client also stays connected across the fetch (fetchInProgress() keeps the two off the same socket), and the fetch task computes the nearest aircraft while it holds the parsed list instead of the MQTT task copying 3.3 KB of aircraft on every publish. Measured over 120 s with MQTT enabled: 0 TLS allocation failures, 22 ADS-B polls, one MQTT connect, TLS session reused on 21 of 22 polls. The same window before the connection reuse: 799 failures, 5 polls. This relies on the RGB332 frame buffer (#100) for headroom: at 16bpp the frame sprite left no contiguous block for those 16 KB buffers. Build flags: -DMQTT_MAX_PACKET_SIZE=768, because PubSubClient's default 256 B truncates discovery payloads silently (the ceiling covers topic + payload + header together), and -DMQTT_KEEPALIVE=60. --- README.md | 51 ++++ include/config.h | 26 ++ include/services/adsb_client.h | 17 ++ include/services/mqtt_client.h | 12 + include/services/mqtt_config.h | 69 +++++ include/services/mqtt_discovery.h | 286 ++++++++++++++++++++ include/services/nearest_aircraft.h | 81 ++++++ include/ui/radar_range.h | 6 + platformio.ini | 6 + src/main.cpp | 3 + src/services/adsb_client.cpp | 41 ++- src/services/mqtt_client.cpp | 398 ++++++++++++++++++++++++++++ src/services/mqtt_config.cpp | 122 +++++++++ src/services/wifi_setup.cpp | 63 ++++- src/ui/radar_range.cpp | 15 ++ 15 files changed, 1191 insertions(+), 5 deletions(-) create mode 100644 include/services/mqtt_client.h create mode 100644 include/services/mqtt_config.h create mode 100644 include/services/mqtt_discovery.h create mode 100644 include/services/nearest_aircraft.h create mode 100644 src/services/mqtt_client.cpp create mode 100644 src/services/mqtt_config.cpp diff --git a/README.md b/README.md index 0318b3f4f..e4d159de7 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,15 @@ The same portal runs on the setup AP and on the device’s LAN IP while connecte | **Latitude / Longitude** | Radar center and ADS-B query position (defaults in `config.h` until set) | | **Display distances in miles** | Ring scale label in **mi** instead of **km** (e.g. `6mi` vs `10km`) | | **Show airport runways** | Major-airport runway overlay on the radar (off to hide) | +| **Publish to Home Assistant (MQTT)** | Master switch. **Off by default**: with no broker host the radar never tries to connect | +| **MQTT broker host / port** | Broker address; port empty = `1883`. Plain TCP only (see below) | +| **MQTT username / password** | Broker credentials, stored in NVS. Sent only to the broker | +| **MQTT base topic** | Empty = `planeradar/`. Two radars need two different topics | +| **HA discovery prefix** | Empty = `homeassistant`. Change it only if your HA uses a custom one | +| **Device name in Home Assistant** | Empty = `Plane Radar` | + +Every MQTT text field starts **empty** on purpose: the firmware ships no broker address, and the +hint is shown as a placeholder, never as a pre-filled value. After a reset, the device reboots and shows the setup screen immediately (no “Connecting” loop on stale credentials). @@ -101,9 +110,44 @@ Edit **`include/config.h`** for hardware and behavior: | Display SPI | pins, `kDisplayInvert`, `kDisplayRgbOrder`, `kDisplaySpiWriteHz` | | Default location | `kDefaultRadarLat`, `kDefaultRadarLon` (until portal overrides) | | ADS-B | `kAdsbFetchIntervalMs`, `kAdsbShowGroundAircraft` | +| MQTT | `kMqttDefaultPort`, `kMqttDefaultTopicPrefix`, `kMqttDefaultDiscoveryPrefix`, `kMqttDefaultDeviceName`, `kMqttStateIntervalMs`, `kMqttMinFreeHeap`, `kMqttPacketSize` (must equal `-DMQTT_MAX_PACKET_SIZE`) | Range presets: `include/ui/radar_range.h` (`kRangePresets`). +## Home Assistant (MQTT) + +The radar can publish itself to Home Assistant over MQTT with **auto-discovery**: no YAML on the HA +side, no entity to declare by hand. Fill in the MQTT fields in the config portal (see above), enable +**Publish to Home Assistant**, and the device appears as one HA device with ten entities. + +- **Discovery topics:** `////config` (retained), where `` is + the base topic with `/` replaced by `_`, e.g. `homeassistant/select/planeradar_a1b2c3/range/config`. +- **Topics:** state on `/state/`, commands on `/cmd/`, availability on + `/status` (`online` / `offline` via MQTT last will). +- **Session:** the broker session is kept (`cleanSession=false`) and commands arrive at QoS 1, so a + tap in HA is not lost while the board is busy with an ADS-B fetch. + +| Entity | Type | What it does | +|--------|------|--------------| +| Range | `select` | Radar range preset (5 / 10 / 15 / 25 km) | +| Miles | `switch` | Ring labels in mi instead of km | +| Runways | `switch` | Airport runway overlay | +| Latitude / Longitude | `number` | Radar center; the grid redraws immediately | +| Aircraft in range | `sensor` | Aircraft currently in the ADS-B snapshot | +| Nearest aircraft | `sensor` | Callsign, with type/altitude/distance/speed/track as attributes | +| Wi-Fi RSSI | `sensor` | dBm | +| Free heap | `sensor` | Bytes; the radar skips telemetry below `kMqttMinFreeHeap` | +| Radar info | `sensor` | IP as state, SSID/uptime/firmware as attributes | + +Commands are applied to NVS and **re-published as state**, so HA always shows the value the radar +actually accepted (an out-of-range coordinate is rejected, not echoed back). + +**Broker requirements:** plain **TCP, no TLS**. The ESP32-C3 already keeps a TLS client for the ADS-B +fetch alive (that session is reused between polls, which is what makes the poll affordable); a second +TLS handshake for MQTT does not fit the heap. Point it at port `1883`. A dedicated broker user with +write access limited to `planeradar/#` and `homeassistant/#` is recommended — the credentials are +stored unencrypted in NVS, and the LAN portal on port 80 is not authenticated. + ## Project layout ``` @@ -125,6 +169,10 @@ include/ wifi_setup.h radar_location.h adsb_client.h + mqtt_client.h — HA discovery, commands, telemetry + mqtt_config.h — the 8 portal fields in NVS + mqtt_discovery.h — pure discovery payload builders + nearest_aircraft.h — pure nearest-aircraft math data/ ui_font.vlw — embedded smooth UI font (Noto Sans Bold) scripts/ @@ -207,3 +255,6 @@ The release workflow builds firmware in CI and attaches the merged image to the - [LovyanGFX](https://github.com/lovyan03/LovyanGFX) - [WiFiManager](https://github.com/tzapu/WiFiManager) - [ArduinoJson](https://github.com/bblanchon/ArduinoJson) +- [PubSubClient](https://github.com/knolleary/pubsubclient) — MQTT. Built with + `-DMQTT_MAX_PACKET_SIZE=768`: the default (256 B) truncates discovery payloads silently, because + the ceiling covers topic + payload + header together. diff --git a/include/config.h b/include/config.h index d084875e7..29de1aa05 100644 --- a/include/config.h +++ b/include/config.h @@ -65,4 +65,30 @@ constexpr uint16_t kColorYellow = 0xFFE0; constexpr uint16_t kTextOnYellow = kColorBlack; constexpr uint16_t kTextOnBlack = 0xFFFF; +// --- MQTT / Home Assistant --- +/** Defaults applied when the matching portal field is left EMPTY. The firmware + * ships no broker address on purpose: host/user/pass are typed by the user in + * the config portal, and an empty host keeps MQTT inert. */ +constexpr uint16_t kMqttDefaultPort = 1883; +constexpr char kMqttDefaultTopicPrefix[] = "planeradar"; +constexpr char kMqttDefaultDiscoveryPrefix[] = "homeassistant"; +constexpr char kMqttDefaultDeviceName[] = "Plane Radar"; +/** Telemetry cadence once discovery is published. */ +constexpr unsigned long kMqttStateIntervalMs = 10000UL; +/** Gap between discovery publishes: the socket needs time to drain. */ +constexpr unsigned long kMqttDiscoverySpacingMs = 250UL; +/** Minimum gap between connect attempts after a failure. */ +constexpr unsigned long kMqttReconnectIntervalMs = 5000UL; +/** Skip telemetry below this free heap: publishing a 640 B payload with almost + * nothing left is what turns a rough patch into a dead radar. */ +constexpr size_t kMqttMinFreeHeap = 8000; +/** Re-publish the retained discovery after this long without one, as insurance + * against a broker that restarted and lost the retained topics. */ +constexpr unsigned long kMqttDiscoveryRefreshMs = 600000UL; +/** Advertised in dev.sw. */ +constexpr char kMqttSwVersion[] = "1.1.0-mqtt"; +/** Must match the -DMQTT_MAX_PACKET_SIZE build flag (asserted in mqtt_client.cpp): + * the ceiling covers topic + payload + header, not the payload alone. */ +constexpr size_t kMqttPacketSize = 768; + } // namespace config diff --git a/include/services/adsb_client.h b/include/services/adsb_client.h index 94ce29a18..3317b0b1b 100644 --- a/include/services/adsb_client.h +++ b/include/services/adsb_client.h @@ -22,6 +22,8 @@ struct Aircraft { constexpr size_t kMaxAircraft = 64; +struct NearestAircraft; + /** Create the internal lock. Call once before any fetch/snapshot. */ void init(); @@ -43,6 +45,21 @@ const Aircraft* aircraftList(); */ unsigned long lastUpdateMs(); +/** + * Closest aircraft as of the last successful fetch, computed by the fetch task + * from the list it had just parsed. Callers (MQTT telemetry) read this instead + * of copying the list: the copy cost 3.3 KB of heap per publish and starved the + * TLS handshake. valid=false before the first fetch. + */ +const NearestAircraft& nearest(); + +/** + * True while a fetch is in flight. Set on entry to fetchUpdate() and cleared on + * every exit path. A plain volatile bool is enough: the flag is advisory, so a + * stale read only costs one frame. + */ +bool fetchInProgress(); + /** Hook invoked during long HTTP I/O (e.g. wifiLoop). Optional. */ using PollFn = void (*)(); void setPollFn(PollFn fn); diff --git a/include/services/mqtt_client.h b/include/services/mqtt_client.h new file mode 100644 index 000000000..efc78e278 --- /dev/null +++ b/include/services/mqtt_client.h @@ -0,0 +1,12 @@ +#pragma once + +namespace services::mqtt { + +/** Load config, build the node id. Call once in setup() after location init. */ +void init(); + +/** Non-blocking: connect, publish discovery, poll, publish telemetry. + * Call every loop() iteration; never from the ADS-B fetch task. */ +void loop(); + +} // namespace services::mqtt diff --git a/include/services/mqtt_config.h b/include/services/mqtt_config.h new file mode 100644 index 000000000..ff70f8178 --- /dev/null +++ b/include/services/mqtt_config.h @@ -0,0 +1,69 @@ +#pragma once + +#include +#include + +namespace services::mqtt { + +constexpr size_t kBrokerHostLen = 64; +constexpr size_t kBrokerUserLen = 32; +constexpr size_t kBrokerPassLen = 96; +constexpr size_t kTopicLen = 40; +constexpr size_t kPrefixLen = 32; +constexpr size_t kDeviceNameLen = 40; + +/** Everything the radar needs to reach a broker and announce itself in HA. + * Every text field stays EMPTY until the user types it in the config portal; + * the defaults live in config.h and are applied at use time, never pre-filled. */ +struct MqttConfig { + bool enabled; // portal checkbox + char host[kBrokerHostLen + 1]; // broker IP or hostname + uint16_t port; // empty field -> kMqttDefaultPort + char user[kBrokerUserLen + 1]; + char pass[kBrokerPassLen + 1]; + char topic[kTopicLen + 1]; // base topic; empty -> planeradar/ + char prefix[kPrefixLen + 1]; // discovery prefix; empty -> homeassistant + char name[kDeviceNameLen + 1]; // HA device name; empty -> Plane Radar +}; + +/** + * Default node suffix used when no base topic is configured: the unique half of + * the eFuse MAC. + * + * ESP.getEfuseMac() packs mac[0] into the LOW byte, so the low 24 bits are the + * OUI -- identical on every board from the same batch. Taking those gave two + * radars the same base topic *and* the same MQTT client id (measured on the + * bench: e8:3d:c1:84:b8:78 and e8:3d:c1:81:d0:00), and a shared client id makes + * the two boards kick each other off the broker. The per-device bytes are + * mac[3..5]; they are returned in MAC order so the suffix reads like the tail of + * the address esptool prints. + * + * One expression on purpose: the firmware compiles these headers as C++11 (the + * `-std=gnu++17` in platformio.ini does not reach the Arduino framework's TUs), + * where a multi-statement constexpr body is rejected -- while the host test, built + * with an explicit -std=c++17, accepts it happily. Keep header helpers one-liners. + */ +constexpr uint32_t macSuffix(uint64_t efuse_mac) { + return (static_cast((efuse_mac >> 24) & 0xFF) << 16) | + (static_cast((efuse_mac >> 32) & 0xFF) << 8) | + static_cast((efuse_mac >> 40) & 0xFF); +} + +/** True when a broker host is filled in; without one there is nothing to reach. */ +bool configured(const MqttConfig& cfg); + +/** Load from nvs (ns "mqtt"); absent keys stay empty/default. */ +void loadConfig(MqttConfig& out); + +/** Persist; returns false when the nvs handle cannot be opened. */ +bool saveConfig(const MqttConfig& cfg); + +/** Drop every stored key (used by the BOOT long-press wipe). */ +void clearConfig(); + +/** Apply the portal form values ("T"/"on" checkbox semantics) and persist. */ +void saveFromPortal(const char* host, const char* port, const char* user, + const char* pass, const char* topic, const char* prefix, + const char* name, const char* enabled_checkbox); + +} // namespace services::mqtt diff --git a/include/services/mqtt_discovery.h b/include/services/mqtt_discovery.h new file mode 100644 index 000000000..32edcf575 --- /dev/null +++ b/include/services/mqtt_discovery.h @@ -0,0 +1,286 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include // strcasecmp + +#include "services/nearest_aircraft.h" +#include "ui/radar_range.h" // kRangePresets: single source for the option list + +namespace services::mqtt { + +/** One discovery payload per controllable/reportable value. Order is the + * publish order used by the client state machine. */ +enum class Entity : uint8_t { + Range = 0, + Miles, + Runways, + Latitude, + Longitude, + AircraftCount, + Nearest, + Rssi, + HeapFree, + Info, + Count, +}; + +struct Ctx { + /** Node id: "_", used in uniq_id and dev.ids. */ + const char* node; + /** Base topic (the "~" substitution): "planeradar/". */ + const char* base; + /** Firmware version advertised in dev.sw. */ + const char* sw; + /** Discovery prefix, normally "homeassistant". */ + const char* prefix; + /** Device name shown in HA (dev.name); defaults to Plane Radar. */ + const char* dev_name; +}; + +namespace detail { + +constexpr const char* kDevMf = "MatixYo"; +constexpr const char* kDevMdl = "ESP32-C3 SuperMini + GC9A01 240x240"; +constexpr const char* kConfigUrl = "http://plane-radar.local/"; + +/** Sequential, bounds-checked JSON writer: one pass, no intermediate Strings. */ +struct Writer { + char* buf; + size_t len; + size_t n; + + void add(const char* s) { + if (n >= len) return; + const size_t need = std::strlen(s); + const size_t room = len - 1 - n; + const size_t take = need < room ? need : room; + std::memcpy(buf + n, s, take); + n += take; + buf[n] = '\0'; + if (take < need) n = len; // mark overflow + } + + void addf(const char* fmt, ...) { + if (n >= len) return; + va_list ap; + va_start(ap, fmt); + const int written = std::vsnprintf(buf + n, len - n, fmt, ap); + va_end(ap); + if (written < 0 || static_cast(written) >= len - n) { + n = len; + return; + } + n += static_cast(written); + } + + bool overflowed() const { return n >= len; } +}; + +struct Spec { + const char* component; + const char* obj; + const char* name; // JSON-safe: no quotes/backslashes in these literals +}; + +// Order MUST match enum class Entity. +constexpr Spec kSpecs[] = { + {"select", "range", "Range"}, + {"switch", "miles", "Miles"}, + {"switch", "runways", "Runways"}, + {"number", "latitude", "Latitude"}, + {"number", "longitude", "Longitude"}, + {"sensor", "aircraft_count", "Aircraft in range"}, + {"sensor", "nearest", "Nearest aircraft"}, + {"sensor", "rssi", "Wi-Fi RSSI"}, + {"sensor", "heap_free", "Free heap"}, + {"sensor", "info", "Radar info"}, +}; + +constexpr const char* kStateTopic[] = { + "~/state/range", "~/state/miles", "~/state/runways", + "~/state/lat", "~/state/lon", "~/state/ac_count", + "~/state/nearest", "~/state/rssi", "~/state/heap", + "~/state/info"}; + +constexpr const char* kCmdTopic[] = { + "~/cmd/range", "~/cmd/miles", "~/cmd/runways", + "~/cmd/lat", "~/cmd/lon", nullptr, + nullptr, nullptr, nullptr, + nullptr}; + +/** Entity-specific keys, appended right after availability. No trailing comma. */ +inline void addEntityData(Writer& w, Entity e) { + switch (e) { + case Entity::Range: + w.add("\"options\":["); + for (size_t i = 0; i < ui::radar::kRangePresetCount; ++i) { + // Option labels come from the same table rangeIndex() indexes. + w.addf("%s\"%d km\"", i ? "," : "", + static_cast(lroundf(ui::radar::kRangePresets[i].ring3_km))); + } + w.add("],\"ic\":\"mdi:radar\""); + break; + case Entity::Miles: + w.add("\"ic\":\"mdi:tape-measure\",\"ent_cat\":\"config\""); + break; + case Entity::Runways: + w.add("\"ic\":\"mdi:run\",\"ent_cat\":\"config\""); + break; + case Entity::Latitude: + // HA rejects anything below 0.001 ("value must be at least 0.001 at + // 'step'") and drops the whole entity, so this is the floor, not a + // preference: 0.001 deg ~ 111 m, which is finer than the ring scale. + w.add("\"min\":-90,\"max\":90,\"step\":0.001,\"mode\":\"box\"," + "\"ic\":\"mdi:latitude\",\"ent_cat\":\"config\""); + break; + case Entity::Longitude: + w.add("\"min\":-180,\"max\":180,\"step\":0.001,\"mode\":\"box\"," + "\"ic\":\"mdi:longitude\",\"ent_cat\":\"config\""); + break; + case Entity::AircraftCount: + w.add("\"stat_cla\":\"measurement\",\"sug_dsp_prc\":0," + "\"ic\":\"mdi:airplane-marker\""); + break; + case Entity::Nearest: + w.add("\"json_attr_t\":\"~/state/nearest\"," + "\"val_t\":\"{{ value_json.callsign }}\",\"ic\":\"mdi:airplane\""); + break; + case Entity::Rssi: + w.add("\"dev_cla\":\"signal_strength\",\"unit_of_meas\":\"dBm\"," + "\"stat_cla\":\"measurement\",\"ent_cat\":\"diagnostic\""); + break; + case Entity::HeapFree: + w.add("\"unit_of_meas\":\"B\",\"stat_cla\":\"measurement\"," + "\"ent_cat\":\"diagnostic\""); + break; + case Entity::Info: + w.add("\"json_attr_t\":\"~/state/info\"," + "\"val_t\":\"{{ value_json.ip }}\",\"ent_cat\":\"diagnostic\"," + "\"ic\":\"mdi:information-outline\""); + break; + case Entity::Count: + break; + } +} + +inline void addJsonEscaped(Writer& w, const char* s) { + for (; s && *s; ++s) { + if (*s == '"' || *s == '\\') { + char esc[3] = {'\\', *s, '\0'}; + w.add(esc); + } else { + char one[2] = {*s, '\0'}; + w.add(one); + } + } +} + +} // namespace detail + +/** "////config". Returns bytes written, 0 when + * the buffer is too small. */ +inline size_t discoveryTopic(Entity e, const Ctx& ctx, char* out, size_t out_len) { + if (e >= Entity::Count || out_len == 0) return 0; + const size_t i = static_cast(e); + const int n = std::snprintf(out, out_len, "%s/%s/%s/%s/config", ctx.prefix, + detail::kSpecs[i].component, ctx.node, + detail::kSpecs[i].obj); + return (n < 0 || static_cast(n) >= out_len) ? 0 : static_cast(n); +} + +/** Retained discovery JSON. Returns bytes written, 0 when the buffer is too + * small (never a truncated payload). */ +inline size_t discoveryPayload(Entity e, const Ctx& ctx, char* out, size_t out_len) { + if (e >= Entity::Count || out_len == 0) return 0; + const size_t i = static_cast(e); + + detail::Writer w{out, out_len, 0}; + out[0] = '\0'; + w.addf("{\"~\":\"%s\",\"name\":\"%s\",\"uniq_id\":\"%s_%s\",\"obj_id\":\"%s\",", + ctx.base, detail::kSpecs[i].name, ctx.node, detail::kSpecs[i].obj, + detail::kSpecs[i].obj); + if (detail::kCmdTopic[i] != nullptr) { + w.addf("\"stat_t\":\"%s\",\"cmd_t\":\"%s\",", detail::kStateTopic[i], + detail::kCmdTopic[i]); + } else { + w.addf("\"stat_t\":\"%s\",", detail::kStateTopic[i]); + } + w.add("\"avty_t\":\"~/status\",\"pl_avail\":\"online\"," + "\"pl_not_avail\":\"offline\","); + detail::addEntityData(w, e); + w.addf(",\"dev\":{\"ids\":[\"%s\"],\"name\":\"", ctx.node); + w.add(ctx.dev_name); + w.addf("\",\"mf\":\"%s\",\"mdl\":\"%s\",\"sw\":\"%s\",\"cu\":\"%s\"}}", + detail::kDevMf, detail::kDevMdl, ctx.sw, detail::kConfigUrl); + + return w.overflowed() ? 0 : w.n; +} + +/** Telemetry JSON for the nearest aircraft; state and attributes come from + * this single document (stat_t == json_attr_t). */ +inline size_t nearestPayload(const adsb::NearestAircraft& a, char* out, size_t out_len) { + if (out_len == 0) return 0; + detail::Writer w{out, out_len, 0}; + out[0] = '\0'; + w.add("{\"callsign\":\""); + if (a.valid) { + detail::addJsonEscaped(w, a.callsign); + } else { + w.add("none"); + } + w.add("\",\"type\":\""); + if (a.valid) detail::addJsonEscaped(w, a.type); + w.add("\",\"alt\":\""); + if (a.valid) detail::addJsonEscaped(w, a.alt); + w.addf("\",\"distance_km\":%.1f,\"gs_kt\":%.0f,\"track_deg\":%.0f}", + static_cast(a.valid ? a.distance_km : 0.0f), + static_cast(a.valid ? a.gs_knots : 0.0f), + static_cast(a.valid ? a.track_deg : 0.0f)); + return w.overflowed() ? 0 : w.n; +} + +/** Diagnostic snapshot: state is the IP, the rest rides as attributes. The + * availability topic stays online/offline -- a JSON there would mark every + * entity unavailable. */ +inline size_t infoPayload(const char* ip, const char* ssid, unsigned long uptime_s, + const char* fw, char* out, size_t out_len) { + if (out_len == 0) return 0; + detail::Writer w{out, out_len, 0}; + out[0] = '\0'; + w.add("{\"ip\":\""); + detail::addJsonEscaped(w, ip); + w.add("\",\"ssid\":\""); + detail::addJsonEscaped(w, ssid); + w.addf("\",\"uptime_s\":%lu,\"fw\":\"", uptime_s); + detail::addJsonEscaped(w, fw); + w.add("\"}"); + return w.overflowed() ? 0 : w.n; +} + +/** Leading integer of a command payload ("25 km" -> 25); -1 when absent. */ +inline int leadingKm(const char* payload) { + if (payload == nullptr) return -1; + int value = 0; + size_t digits = 0; + for (const char* p = payload; *p; ++p) { + if (*p == ' ') continue; + if (*p < '0' || *p > '9') break; + value = value * 10 + (*p - '0'); + ++digits; + } + return digits == 0 ? -1 : value; +} + +/** True for ON/1/true/yes, case-insensitive. */ +inline bool payloadIsOn(const char* payload) { + if (payload == nullptr) return false; + return strcasecmp(payload, "ON") == 0 || std::strcmp(payload, "1") == 0 || + strcasecmp(payload, "true") == 0 || strcasecmp(payload, "yes") == 0; +} + +} // namespace services::mqtt diff --git a/include/services/nearest_aircraft.h b/include/services/nearest_aircraft.h new file mode 100644 index 000000000..cae44eb7d --- /dev/null +++ b/include/services/nearest_aircraft.h @@ -0,0 +1,81 @@ +#pragma once + +#include +#include +#include +#include + +#include "services/adsb_client.h" + +namespace services::adsb { + +/** Nearest aircraft measured from the radar centre. */ +struct NearestAircraft { + bool valid; + char callsign[9]; + char type[5]; + char alt[12]; + float distance_km; + float gs_knots; + float track_deg; +}; + +namespace detail { + +constexpr float kKmPerDeg = 111.0f; +constexpr float kDegToRad = 0.017453292519943295f; + +inline float distanceSqKm(double lat0, double lon0, float cos_lat, float lat1, + float lon1) { + const float dlat = (lat1 - static_cast(lat0)) * kKmPerDeg; + const float dlon = (lon1 - static_cast(lon0)) * kKmPerDeg * cos_lat; + return dlat * dlat + dlon * dlon; +} + +} // namespace detail + +/** Flat-earth ground distance in km; longitude scaled by cos(lat0), matching + * the renderer's projection. */ +inline float flatDistanceKm(double lat0, double lon0, float lat1, float lon1) { + const float cos_lat = cosf(static_cast(lat0) * detail::kDegToRad); + return sqrtf(detail::distanceSqKm(lat0, lon0, cos_lat, lat1, lon1)); +} + +/** Closest entry of list[0..count) to (lat0, lon0); valid=false when count==0. */ +inline NearestAircraft findNearest(const Aircraft* list, size_t count, double lat0, + double lon0) { + NearestAircraft best{}; + best.valid = false; + if (list == nullptr || count == 0) { + return best; + } + + // cos(lat0) is loop-invariant: on this FPU-less chip a cosf() per item is the + // difference between microseconds and milliseconds. + const float cos_lat = cosf(static_cast(lat0) * detail::kDegToRad); + float best_sq = 0.0f; + size_t best_i = 0; + for (size_t i = 0; i < count; ++i) { + const float d2 = + detail::distanceSqKm(lat0, lon0, cos_lat, list[i].lat, list[i].lon); + if (i == 0 || d2 < best_sq) { + best_sq = d2; + best_i = i; + } + } + + const Aircraft& a = list[best_i]; + best.valid = true; + std::strncpy(best.callsign, a.callsign, sizeof(best.callsign) - 1); + best.callsign[sizeof(best.callsign) - 1] = '\0'; + std::strncpy(best.type, a.type, sizeof(best.type) - 1); + best.type[sizeof(best.type) - 1] = '\0'; + std::strncpy(best.alt, a.alt, sizeof(best.alt) - 1); + best.alt[sizeof(best.alt) - 1] = '\0'; + best.distance_km = sqrtf(best_sq); + best.gs_knots = a.gs_knots; + best.track_deg = a.track_deg; + return best; +} + +} // namespace services::adsb diff --git a/include/ui/radar_range.h b/include/ui/radar_range.h index a95c53275..7e36984c9 100644 --- a/include/ui/radar_range.h +++ b/include/ui/radar_range.h @@ -38,6 +38,12 @@ constexpr size_t kRangePresetCount = void rangeInit(); /** Cycle preset and save to flash. */ void rangeNext(); +/** Select a preset by index (clamped); persists. Used by the MQTT command path. */ +void rangeSetIndex(uint8_t index); +/** Set distance units; persists. Used by the MQTT command path. */ +void setUseMiles(bool miles); +/** Set runway overlay; persists. Used by the MQTT command path. */ +void setShowRunways(bool show); const RangePreset& rangeCurrent(); uint8_t rangeIndex(); /** ADSB fetch radius (km): scaled to screen edge so beyond-ring dots have data. */ diff --git a/platformio.ini b/platformio.ini index c81ce535f..c60783ee5 100644 --- a/platformio.ini +++ b/platformio.ini @@ -13,14 +13,20 @@ extra_scripts = post:scripts/merge_firmware.py board_build.embed_files = data/ui_font.vlw ; USB serial over USB-C (Super Mini native USB) +; PubSubClient defaults to a 256-byte packet and TRUNCATES discovery payloads +; silently (MQTT_MAX_PACKET_SIZE covers topic + payload + header together, so +; 768 is what fits the longest payload ~420 B plus a 52-char discovery topic). build_flags = -std=gnu++17 -DARDUINO_USB_MODE=1 -DARDUINO_USB_CDC_ON_BOOT=1 -DWM_NODEBUG -DWM_MDNS + -DMQTT_MAX_PACKET_SIZE=768 + -DMQTT_KEEPALIVE=60 lib_deps = lovyan03/LovyanGFX@^1.2.7 tzapu/WiFiManager@^2.0.17 bblanchon/ArduinoJson@^7.4.2 + knolleary/PubSubClient@^2.8 diff --git a/src/main.cpp b/src/main.cpp index 6e98c651b..6a19d8ed2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8,6 +8,7 @@ #include "config.h" #include "hardware/display.h" #include "services/adsb_client.h" +#include "services/mqtt_client.h" #include "services/radar_location.h" #include "services/wifi_setup.h" #include "ui/radar_display.h" @@ -80,6 +81,7 @@ void setup() { services::location::init(); ui::radar::rangeInit(); services::adsb::init(); + services::mqtt::init(); if (wifiSetupConnect()) { showRadarIfConnected(); @@ -93,6 +95,7 @@ void setup() { void loop() { handleBootButton(); wifiLoop(); + services::mqtt::loop(); if (WiFi.status() != WL_CONNECTED) { if (g_radar_visible) { diff --git a/src/services/adsb_client.cpp b/src/services/adsb_client.cpp index 6bfb3eb6a..164c0c700 100644 --- a/src/services/adsb_client.cpp +++ b/src/services/adsb_client.cpp @@ -1,5 +1,7 @@ #include "services/adsb_client.h" +#include "services/nearest_aircraft.h" + #include #include @@ -27,9 +29,20 @@ size_t s_aircraft_count = 0; unsigned long s_last_update_ms = 0; PollFn s_poll_fn = nullptr; SemaphoreHandle_t s_mutex = nullptr; +// Set for the whole of fetchUpdate(), cleared on every return path, so the MQTT +// client can leave the shared Wi-Fi stack alone while a fetch is in flight. +volatile bool s_fetch_active = false; +// Nearest aircraft as of the last fetch, for the MQTT telemetry. +NearestAircraft s_nearest{}; + +/** Marks the fetch in flight and guarantees the clear on any early return. */ +struct FetchInProgressGuard { + FetchInProgressGuard() { s_fetch_active = true; } + ~FetchInProgressGuard() { s_fetch_active = false; } +}; /** Publish parsed aircraft to the shared buffer atomically. */ -void publish(const Aircraft* src, size_t count) { +void publish(const Aircraft* src, size_t count, double lat0, double lon0) { if (s_mutex != nullptr) { xSemaphoreTake(s_mutex, portMAX_DELAY); } @@ -38,6 +51,10 @@ void publish(const Aircraft* src, size_t count) { } s_aircraft_count = count; s_last_update_ms = millis(); // base time for dead-reckoning + // Computed here, where the list is already under the lock: the MQTT task used + // to do it from a copy of the list, and that 3.3 KB malloc per publish was + // what starved the TLS handshake. + s_nearest = findNearest(s_aircraft, count, lat0, lon0); if (s_mutex != nullptr) { xSemaphoreGive(s_mutex); } @@ -257,6 +274,10 @@ const Aircraft* aircraftList() { return s_aircraft; } unsigned long lastUpdateMs() { return s_last_update_ms; } +const NearestAircraft& nearest() { return s_nearest; } + +bool fetchInProgress() { return s_fetch_active; } + size_t snapshotAircraft(Aircraft* out, size_t max_out, unsigned long* out_last_update_ms) { if (s_mutex != nullptr) { @@ -277,6 +298,7 @@ size_t snapshotAircraft(Aircraft* out, size_t max_out, } bool fetchUpdate(double center_lat, double center_lon, float fetch_radius_km) { + const FetchInProgressGuard fetch_guard; const float dist_nm = kmToNauticalMiles(fetch_radius_km); String url = kApiBase; @@ -296,14 +318,25 @@ bool fetchUpdate(double center_lat, double center_lon, float fetch_radius_km) { f[key] = true; } - WiFiClientSecure client; + // Persistent across polls, and the connection stays alive between them. The + // TLS handshake costs ~1 s of CPU on this chip (measured: 899-1140 ms on the + // device against 10 ms for the same handshake from a LAN host), so it has to be + // paid once. Reuse requires BOTH objects to outlive this call: HTTPClient's + // destructor calls _client->stop() without consulting _reuse, so a per-call + // instance silently tore the session down on return -- which is what made + // mbedtls_ssl_setup(), allocating two 16 KB record buffers every poll, fail as + // soon as the display and the MQTT client had taken the heap. + static WiFiClientSecure client; + static HTTPClient http; client.setInsecure(); - HTTPClient http; if (!http.begin(client, url)) { Serial.println("adsb: http.begin failed"); return false; } + // Only with this does HTTPClient::disconnect() keep the socket open instead of + // calling _client->stop(): end() consults _reuse, the destructor does not. + http.setReuse(true); // HTTPClient only records Transfer-Encoding in the collected headers when // it is asked for up front; _transferEncoding itself is private. @@ -390,7 +423,7 @@ bool fetchUpdate(double center_lat, double center_lon, float fetch_radius_km) { } } - publish(parsed, n); + publish(parsed, n, center_lat, center_lon); Serial.printf("adsb: %u aircraft\n", static_cast(n)); return true; } diff --git a/src/services/mqtt_client.cpp b/src/services/mqtt_client.cpp new file mode 100644 index 000000000..2ab287beb --- /dev/null +++ b/src/services/mqtt_client.cpp @@ -0,0 +1,398 @@ +#include "services/mqtt_client.h" + +#include +#include +#include + +#include +#include +#include +#include + +#include "config.h" +#include "services/adsb_client.h" +#include "services/mqtt_config.h" +#include "services/mqtt_discovery.h" +#include "services/nearest_aircraft.h" +#include "services/radar_location.h" +#include "ui/radar_display.h" +#include "ui/radar_range.h" + +namespace services::mqtt { + +namespace { + +enum class State : uint8_t { Disabled, WaitingLink, Connecting, Discovery, Ready }; + +constexpr size_t kNodeIdLen = 48; // configurable base topic, slashes flattened +constexpr size_t kBaseTopicLen = 56; +constexpr size_t kClientIdLen = 64; +// Payload buffer: the host test caps every discovery payload at 600 B, and +// MQTT_MAX_PACKET_SIZE (768) must still fit topic (~52) + header. +constexpr size_t kPayloadMax = 640; +constexpr size_t kCmdMax = 64; + +MqttConfig s_cfg{}; +WiFiClient s_net; +PubSubClient s_mqtt(s_net); +State s_state = State::Disabled; +char s_node[kNodeIdLen] = {}; +char s_base[kBaseTopicLen] = {}; +char s_client_id[kClientIdLen] = {}; +char s_topic[128] = {}; +Ctx s_ctx{}; +size_t s_discovery_index = 0; +unsigned long s_last_discovery_ms = 0; +unsigned long s_last_state_ms = 0; +unsigned long s_last_connect_ms = 0; +unsigned long s_discovery_done_ms = 0; +unsigned long s_last_skip_log_ms = 0; + +// One buffer, allocated at boot and never freed. Both alternatives were +// measured on the bench board and both cost the ADS-B fetch its TLS handshake: +// a 3.3 KB aircraft snapshot held in .bss permanently (fixed 4.7 KB deficit), +// and the same snapshot malloc'd per publish (no fixed cost, but the +// malloc/free churn fragmented the heap so badly that the handshake lost its +// block ~50 s in). The fetch task caches the nearest aircraft instead, so +// nothing here needs the snapshot at all. +char s_payload[kPayloadMax]; + +void buildTopics() { + // macSuffix() (not the raw low bits): the low 24 bits of the eFuse MAC are the + // OUI, so two boards from one batch would share the base topic and the client + // id. See macSuffix() for the measured case. + const uint32_t mac6 = macSuffix(ESP.getEfuseMac()); + + // Base topic: user-configured, else the firmware default. + if (s_cfg.topic[0] != '\0') { + std::strncpy(s_base, s_cfg.topic, sizeof(s_base) - 1); + s_base[sizeof(s_base) - 1] = '\0'; + } else { + std::snprintf(s_base, sizeof(s_base), "%s/%06x", + config::kMqttDefaultTopicPrefix, static_cast(mac6)); + } + + // Node id flattens the separators: it goes into discovery topics and unique + // ids, where a slash would create phantom topic segments. + size_t i = 0; + for (; s_base[i] != '\0' && i + 1 < sizeof(s_node); ++i) { + s_node[i] = (s_base[i] == '/') ? '_' : s_base[i]; + } + s_node[i] = '\0'; + + // Client id stays unique per chip even when two boards are cloned with the + // same configured topic: a shared client id makes them kick each other off. + std::snprintf(s_client_id, sizeof(s_client_id), "%s_%06x", s_node, + static_cast(mac6)); + + s_ctx.node = s_node; + s_ctx.base = s_base; + s_ctx.sw = config::kMqttSwVersion; + s_ctx.prefix = s_cfg.prefix[0] != '\0' ? s_cfg.prefix + : config::kMqttDefaultDiscoveryPrefix; + s_ctx.dev_name = s_cfg.name[0] != '\0' ? s_cfg.name : config::kMqttDefaultDeviceName; +} + +void publishStateTopic(const char* key, const char* value, bool retained) { + std::snprintf(s_topic, sizeof(s_topic), "%s/state/%s", s_base, key); + // QoS 0: fire and forget, no ack wait inside the render loop. + if (!s_mqtt.publish(s_topic, value, retained)) { + Serial.printf("MQTT: publish %s failed (heap %u)\n", s_topic, + static_cast(ESP.getFreeHeap())); + } +} + +void publishRangeState() { + char label[16]; + std::snprintf(label, sizeof(label), "%d km", + static_cast(lroundf(ui::radar::rangeCurrent().ring3_km))); + publishStateTopic("range", label, false); +} + +void publishSwitchState(const char* key, bool on) { + publishStateTopic(key, on ? "ON" : "OFF", false); +} + +void publishLocationState() { + char buf[24]; + std::snprintf(buf, sizeof(buf), "%.6f", services::location::lat()); + publishStateTopic("lat", buf, false); + std::snprintf(buf, sizeof(buf), "%.6f", services::location::lon()); + publishStateTopic("lon", buf, false); +} + +/** Every controllable/reported value once, so HA holds real states instead of + * 'unknown' before the first command. */ +void publishAllStates() { + publishRangeState(); + publishSwitchState("miles", ui::radar::useMiles()); + publishSwitchState("runways", ui::radar::showRunways()); + publishLocationState(); +} + +void publishTelemetry() { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%u", + static_cast(adsb::aircraftCount())); + publishStateTopic("ac_count", buf, false); + + std::snprintf(buf, sizeof(buf), "%d", WiFi.RSSI()); + publishStateTopic("rssi", buf, false); + std::snprintf(buf, sizeof(buf), "%u", + static_cast(ESP.getFreeHeap())); + publishStateTopic("heap", buf, false); + + // Cached by the fetch task while it held the parsed list: no allocation, and + // no torn read of the whole aircraft list. + const adsb::NearestAircraft nearest = adsb::nearest(); + if (nearestPayload(nearest, s_payload, kPayloadMax) > 0) { + std::snprintf(s_topic, sizeof(s_topic), "%s/state/nearest", s_base); + if (!s_mqtt.publish(s_topic, s_payload, false)) { + Serial.println("MQTT: nearest publish failed"); + } + } + + // Diagnostic snapshot. Note the temporary Strings: both outlive the full + // expression, which is all infoPayload() needs. + const String ip = WiFi.localIP().toString(); + const String ssid = WiFi.SSID(); + if (infoPayload(ip.c_str(), ssid.c_str(), millis() / 1000UL, + config::kMqttSwVersion, s_payload, kPayloadMax) > 0) { + std::snprintf(s_topic, sizeof(s_topic), "%s/state/info", s_base); + if (!s_mqtt.publish(s_topic, s_payload, false)) { + Serial.println("MQTT: info publish failed"); + } + } +} + +void handleCommand(char* topic, const char* value) { + const char* key = std::strrchr(topic, '/'); + if (key == nullptr) return; + ++key; + + if (std::strcmp(key, "range") == 0) { + const int km = leadingKm(value); + for (size_t i = 0; i < ui::radar::kRangePresetCount; ++i) { + if (static_cast(lroundf(ui::radar::kRangePresets[i].ring3_km)) == km) { + ui::radar::rangeSetIndex(static_cast(i)); + break; + } + } + publishRangeState(); + } else if (std::strcmp(key, "miles") == 0) { + ui::radar::setUseMiles(payloadIsOn(value)); + publishSwitchState("miles", ui::radar::useMiles()); + } else if (std::strcmp(key, "runways") == 0) { + ui::radar::setShowRunways(payloadIsOn(value)); + publishSwitchState("runways", ui::radar::showRunways()); + } else if (std::strcmp(key, "lat") == 0 || std::strcmp(key, "lon") == 0) { + const double requested = std::atof(value); + char lat_buf[24]; + char lon_buf[24]; + if (std::strcmp(key, "lat") == 0) { + std::snprintf(lat_buf, sizeof(lat_buf), "%.6f", requested); + std::snprintf(lon_buf, sizeof(lon_buf), "%.6f", services::location::lon()); + } else { + std::snprintf(lat_buf, sizeof(lat_buf), "%.6f", services::location::lat()); + std::snprintf(lon_buf, sizeof(lon_buf), "%.6f", requested); + } + if (!services::location::saveFromStrings(lat_buf, lon_buf)) { + Serial.println("MQTT: rejected out-of-range coordinates"); + } + // Re-publish the truth, not the request: an invalid value must not show + // as applied in Home Assistant. + publishLocationState(); + } else { + Serial.printf("MQTT: unknown command topic %s\n", topic); + return; + } + + // Applied commands are worth a line: the only other feedback is the panel, + // which is exactly what a remote caller cannot see. + Serial.printf("MQTT: cmd %s -> %s\n", topic, value); + + // Grid geometry depends on range/units/location: full redraw, same call the + // BOOT-button path uses. Safe here: loop() is the render task. + if (WiFi.status() == WL_CONNECTED) { + ui::radarDisplayDraw(); + } +} + +void onMessage(char* topic, uint8_t* payload, unsigned int length) { + if (length >= kCmdMax) { + length = kCmdMax - 1; + } + // PubSubClient hands over a NON NUL-terminated buffer. + char value[kCmdMax]; + std::memcpy(value, payload, length); + value[length] = '\0'; + handleCommand(topic, value); +} + +/** Publishes one entity's discovery. False means "try me again later": the + * caller must NOT advance the index, or that entity is missing until reboot. */ +bool publishDiscoveryStep() { + const auto entity = static_cast(s_discovery_index); + if (discoveryTopic(entity, s_ctx, s_topic, sizeof(s_topic)) == 0) { + return false; + } + const size_t n = discoveryPayload(entity, s_ctx, s_payload, kPayloadMax); + if (n == 0) { + Serial.printf("MQTT: discovery payload %u too large\n", + static_cast(s_discovery_index)); + return false; + } + const bool ok = s_mqtt.publish(s_topic, s_payload, true); // retained + Serial.printf("MQTT: discovery %u/%u %s\n", + static_cast(s_discovery_index + 1), + static_cast(Entity::Count), ok ? "ok" : "FAILED"); + return ok; +} + +void connectBroker() { + std::snprintf(s_topic, sizeof(s_topic), "%s/status", s_base); + // cleanSession=false on purpose: a dropped link must not cost us the + // subscription, and with a clean session the broker throws away any QoS 1 + // command published while we are away -- measured as silently dropped switch + // taps in Home Assistant. + const bool ok = s_mqtt.connect(s_client_id, s_cfg.user, s_cfg.pass, s_topic, 0, + true, "offline", false); + if (!ok) { + Serial.printf("MQTT: connect failed, rc=%d\n", s_mqtt.state()); + return; + } + s_mqtt.publish(s_topic, "online", true); + std::snprintf(s_topic, sizeof(s_topic), "%s/cmd/#", s_base); + // QoS 1: a command published while the link is down must wait in the broker + // instead of being dropped, and a QoS 0 one would. PubSubClient acks the + // inbound QoS 1 publish. + s_mqtt.subscribe(s_topic, 1); + Serial.printf("MQTT: connected to %s:%u as %s (base '%s')\n", s_cfg.host, s_cfg.port, + s_client_id, s_base); + + // Reconnecting after the fetch borrowed the socket must not replay the + // discovery burst: it is retained in the broker and HA already has the + // entities. Refreshing once a minute is cheap insurance against a broker + // that restarted and lost the retained topics. + const bool discovery_fresh = + s_discovery_done_ms != 0 && + (millis() - s_discovery_done_ms) < config::kMqttDiscoveryRefreshMs; + if (discovery_fresh) { + publishAllStates(); + // Deliberately NOT resetting s_last_state_ms here: the fetch reconnects every + // few seconds, so touching it each time meant the telemetry interval never + // elapsed and every sensor silently froze at its last value. + s_state = State::Ready; + Serial.println("MQTT: reconnected (discovery still retained)"); + return; + } + s_discovery_index = 0; + s_last_discovery_ms = 0; + s_state = State::Discovery; +} + +} // namespace + +// The MQTT packet ceiling lives in platformio.ini; if the two drift, discovery +// payloads get truncated silently at run time. +static_assert(MQTT_MAX_PACKET_SIZE == config::kMqttPacketSize, + "MQTT_MAX_PACKET_SIZE must match config::kMqttPacketSize"); + +void init() { + loadConfig(s_cfg); + buildTopics(); + if (!s_cfg.enabled) { + s_state = State::Disabled; + Serial.println("MQTT: disabled (portal checkbox off)"); + return; + } + if (!configured(s_cfg)) { + s_state = State::Disabled; + Serial.println("MQTT: enabled but no broker host configured"); + return; + } + s_net.setTimeout(3000); // bounds the TCP connect; the default can hang for ~15 s + s_mqtt.setServer(s_cfg.host, s_cfg.port); + s_mqtt.setCallback(onMessage); + s_mqtt.setBufferSize(config::kMqttPacketSize); + s_state = State::WaitingLink; + Serial.printf("MQTT: node %s client %s broker %s:%u topic '%s' prefix '%s'\n", s_node, + s_client_id, s_cfg.host, s_cfg.port, s_base, s_ctx.prefix); +} + +void loop() { + if (s_state == State::Disabled) { + return; + } + // Never drive the client while the fetch is in flight: they share the Wi-Fi + // stack, and the fetch's TLS handshake is what needs the heap. + if (services::adsb::fetchInProgress()) { + return; + } + + if (WiFi.status() != WL_CONNECTED) { + if (s_mqtt.connected()) { + s_mqtt.disconnect(); + } + s_state = State::WaitingLink; + return; + } + + if (!s_mqtt.connected()) { + if (s_state == State::Discovery || s_state == State::Ready) { + s_state = State::Connecting; // broker dropped us + } + if (millis() - s_last_connect_ms < config::kMqttReconnectIntervalMs) { + return; + } + s_last_connect_ms = millis(); + connectBroker(); + return; + } + + s_mqtt.loop(); + + if (s_state == State::Connecting || s_state == State::WaitingLink) { + s_state = State::Discovery; // connectBroker() already moved us; safety net + s_discovery_index = 0; + } + + if (s_state == State::Discovery) { + if (millis() - s_last_discovery_ms < config::kMqttDiscoverySpacingMs) { + return; + } + s_last_discovery_ms = millis(); + if (s_discovery_index < static_cast(Entity::Count)) { + // A step that could not allocate its buffers must not consume its index: + // advancing would leave that entity unpublished until the next boot. + if (publishDiscoveryStep()) { + ++s_discovery_index; + } + return; + } + publishAllStates(); + s_last_state_ms = millis(); + s_discovery_done_ms = millis(); + s_state = State::Ready; + Serial.println("MQTT: discovery complete"); + return; + } + + if (millis() - s_last_state_ms >= config::kMqttStateIntervalMs) { + if (ESP.getFreeHeap() < config::kMqttMinFreeHeap) { + // A publish burst here would race the TLS handshake for the heap. Do not + // start the interval again: MQTT is only up part of each fetch cycle, so + // retry on the next pass (the log stays rate-limited). + if (millis() - s_last_skip_log_ms >= config::kMqttStateIntervalMs) { + s_last_skip_log_ms = millis(); + Serial.printf("MQTT: telemetry skipped, heap %u\n", + static_cast(ESP.getFreeHeap())); + } + return; + } + s_last_state_ms = millis(); + publishTelemetry(); + } +} + +} // namespace services::mqtt diff --git a/src/services/mqtt_config.cpp b/src/services/mqtt_config.cpp new file mode 100644 index 000000000..862bb54b1 --- /dev/null +++ b/src/services/mqtt_config.cpp @@ -0,0 +1,122 @@ +#include "services/mqtt_config.h" + +#include + +#include +#include +#include + +#include "config.h" + +namespace services::mqtt { + +namespace { + +constexpr char kPrefsNamespace[] = "mqtt"; +constexpr char kKeyEnabled[] = "on"; +constexpr char kKeyHost[] = "host"; +constexpr char kKeyPort[] = "port"; +constexpr char kKeyUser[] = "user"; +constexpr char kKeyPass[] = "pass"; +constexpr char kKeyTopic[] = "topic"; +constexpr char kKeyPrefix[] = "prefix"; +constexpr char kKeyName[] = "name"; + +void copyInto(char* dst, size_t dst_len, const char* src) { + if (src == nullptr) { + dst[0] = '\0'; + return; + } + std::strncpy(dst, src, dst_len - 1); + dst[dst_len - 1] = '\0'; +} + +/** Absent key must read as EMPTY (not as the previous default), so a cleared + * nvs really means "not configured". */ +void readString(Preferences& prefs, const char* key, char* dst, size_t dst_len) { + char tmp[128] = {}; + if (dst_len > sizeof(tmp)) { + dst[0] = '\0'; + return; + } + const size_t got = prefs.getString(key, tmp, sizeof(tmp)); + copyInto(dst, dst_len, got > 0 ? tmp : ""); +} + +bool checkboxOn(const char* value) { + if (value == nullptr || value[0] == '\0') return false; + return std::strcmp(value, "on") == 0 || value[0] == 'T' || value[0] == 't'; +} + +} // namespace + +bool configured(const MqttConfig& cfg) { return cfg.host[0] != '\0'; } + +void loadConfig(MqttConfig& out) { + std::memset(&out, 0, sizeof(out)); + out.port = config::kMqttDefaultPort; + + Preferences prefs; + if (!prefs.begin(kPrefsNamespace, true)) { + return; + } + out.enabled = prefs.getBool(kKeyEnabled, false); + readString(prefs, kKeyHost, out.host, sizeof(out.host)); + out.port = prefs.getUShort(kKeyPort, config::kMqttDefaultPort); + readString(prefs, kKeyUser, out.user, sizeof(out.user)); + readString(prefs, kKeyPass, out.pass, sizeof(out.pass)); + readString(prefs, kKeyTopic, out.topic, sizeof(out.topic)); + readString(prefs, kKeyPrefix, out.prefix, sizeof(out.prefix)); + readString(prefs, kKeyName, out.name, sizeof(out.name)); + prefs.end(); +} + +bool saveConfig(const MqttConfig& cfg) { + Preferences prefs; + if (!prefs.begin(kPrefsNamespace, false)) { + return false; + } + prefs.putBool(kKeyEnabled, cfg.enabled); + prefs.putString(kKeyHost, cfg.host); + prefs.putUShort(kKeyPort, cfg.port); + prefs.putString(kKeyUser, cfg.user); + prefs.putString(kKeyPass, cfg.pass); + prefs.putString(kKeyTopic, cfg.topic); + prefs.putString(kKeyPrefix, cfg.prefix); + prefs.putString(kKeyName, cfg.name); + prefs.end(); + return true; +} + +void clearConfig() { + Preferences prefs; + if (!prefs.begin(kPrefsNamespace, false)) { + return; + } + prefs.clear(); + prefs.end(); + Serial.println("MQTT: config cleared"); +} + +void saveFromPortal(const char* host, const char* port, const char* user, + const char* pass, const char* topic, const char* prefix, + const char* name, const char* enabled_checkbox) { + MqttConfig cfg{}; + copyInto(cfg.host, sizeof(cfg.host), host); // no default: empty stays empty + const long parsed = (port != nullptr && port[0]) ? std::strtol(port, nullptr, 10) : 0; + cfg.port = (parsed > 0 && parsed <= 65535) ? static_cast(parsed) + : config::kMqttDefaultPort; + copyInto(cfg.user, sizeof(cfg.user), user); + copyInto(cfg.pass, sizeof(cfg.pass), pass); + copyInto(cfg.topic, sizeof(cfg.topic), topic); + copyInto(cfg.prefix, sizeof(cfg.prefix), prefix); + copyInto(cfg.name, sizeof(cfg.name), name); + cfg.enabled = checkboxOn(enabled_checkbox); + saveConfig(cfg); + // Log what a connection needs; the password never reaches the log. + Serial.printf("MQTT: %s host '%s' port %u user '%s' topic '%s'\n", + cfg.enabled ? "enabled" : "disabled", cfg.host, cfg.port, cfg.user, + cfg.topic[0] ? cfg.topic : "(auto)"); +} + +} // namespace services::mqtt diff --git a/src/services/wifi_setup.cpp b/src/services/wifi_setup.cpp index e0fc4105a..ea62eb0eb 100644 --- a/src/services/wifi_setup.cpp +++ b/src/services/wifi_setup.cpp @@ -15,6 +15,7 @@ #endif #include "config.h" +#include "services/mqtt_config.h" #include "services/radar_location.h" #include "ui/radar_range.h" #include "ui/status_screens.h" @@ -71,6 +72,9 @@ bool wifiLinkUp(); constexpr int kCoordParamLen = 20; constexpr char kCoordInputAttrs[] = " type=\"number\" step=\"0.000001\""; +/** Max chars of a 16-bit port; must match the parameter's constructor length, + * because setValue() reallocates when the two disagree. */ +constexpr int kPortParamLen = 6; WiFiManagerParameter s_param_lat("radar_lat", "Latitude (deg)", "0", kCoordParamLen, kCoordInputAttrs); @@ -85,6 +89,28 @@ char s_runways_checkbox_attrs[32] = "type=\"checkbox\""; WiFiManagerParameter s_param_runways("show_runways", "Show airport runways", "T", 2, s_runways_checkbox_attrs, WFM_LABEL_AFTER); +// MQTT / Home Assistant. Every text field starts EMPTY on purpose: the firmware +// ships no broker address, and an empty host keeps MQTT inert. The hints are +// placeholders only, never pre-filled values. +char s_mqtt_on_checkbox_attrs[32] = "type=\"checkbox\""; +WiFiManagerParameter s_param_mqtt_on("mqtt_on", "Publish to Home Assistant (MQTT)", "T", 2, + s_mqtt_on_checkbox_attrs, WFM_LABEL_AFTER); +WiFiManagerParameter s_param_mqtt_host("mqtt_host", "MQTT broker host", "", 64, + " type=\"text\" placeholder=\"e.g. 192.168.0.10\""); +WiFiManagerParameter s_param_mqtt_port("mqtt_port", "MQTT broker port", "", kPortParamLen, + " type=\"number\" min=\"1\" max=\"65535\"" + " placeholder=\"1883\""); +WiFiManagerParameter s_param_mqtt_user("mqtt_user", "MQTT username", "", 32, + " type=\"text\" placeholder=\"mqtt user\""); +WiFiManagerParameter s_param_mqtt_pass("mqtt_pass", "MQTT password", "", 96, + " type=\"password\" placeholder=\"broker password\""); +WiFiManagerParameter s_param_mqtt_topic("mqtt_topic", "MQTT base topic", "", 40, + " type=\"text\" placeholder=\"planeradar/<mac>\""); +WiFiManagerParameter s_param_mqtt_prefix("mqtt_prefix", "HA discovery prefix", "", 32, + " type=\"text\" placeholder=\"homeassistant\""); +WiFiManagerParameter s_param_mqtt_name("mqtt_name", "Device name in Home Assistant", "", 40, + " type=\"text\" placeholder=\"Plane Radar\""); + void refreshPortalParamValues() { char lat_buf[kCoordParamLen + 1]; char lon_buf[kCoordParamLen + 1]; @@ -98,6 +124,27 @@ void refreshPortalParamValues() { snprintf(s_runways_checkbox_attrs, sizeof(s_runways_checkbox_attrs), "type=\"checkbox\"%s", ui::radar::showRunways() ? " checked" : ""); s_param_runways.setValue("T", 2); + + services::mqtt::MqttConfig mqtt{}; + services::mqtt::loadConfig(mqtt); + snprintf(s_mqtt_on_checkbox_attrs, sizeof(s_mqtt_on_checkbox_attrs), + "type=\"checkbox\"%s", mqtt.enabled ? " checked" : ""); + s_param_mqtt_on.setValue("T", 2); + s_param_mqtt_host.setValue(mqtt.host, services::mqtt::kBrokerHostLen); + // The port is only shown when it differs from the default: an untouched field + // stays empty (the placeholder carries the suggestion), like every other field. + char port_buf[8]; + if (mqtt.port != config::kMqttDefaultPort) { + snprintf(port_buf, sizeof(port_buf), "%u", static_cast(mqtt.port)); + } else { + port_buf[0] = '\0'; + } + s_param_mqtt_port.setValue(port_buf, kPortParamLen); + s_param_mqtt_user.setValue(mqtt.user, services::mqtt::kBrokerUserLen); + s_param_mqtt_pass.setValue(mqtt.pass, services::mqtt::kBrokerPassLen); + s_param_mqtt_topic.setValue(mqtt.topic, services::mqtt::kTopicLen); + s_param_mqtt_prefix.setValue(mqtt.prefix, services::mqtt::kPrefixLen); + s_param_mqtt_name.setValue(mqtt.name, services::mqtt::kDeviceNameLen); } void onPortalParamsSaved() { @@ -109,6 +156,11 @@ void onPortalParamsSaved() { ui::radar::saveRunwaysFromPortal(s_param_runways.getValue()); refreshPortalParamValues(); + services::mqtt::saveFromPortal( + s_param_mqtt_host.getValue(), s_param_mqtt_port.getValue(), + s_param_mqtt_user.getValue(), s_param_mqtt_pass.getValue(), + s_param_mqtt_topic.getValue(), s_param_mqtt_prefix.getValue(), + s_param_mqtt_name.getValue(), s_param_mqtt_on.getValue()); } void attachPortalParams(WiFiManager& wm) { @@ -117,6 +169,14 @@ void attachPortalParams(WiFiManager& wm) { wm.addParameter(&s_param_lon); wm.addParameter(&s_param_miles); wm.addParameter(&s_param_runways); + wm.addParameter(&s_param_mqtt_on); + wm.addParameter(&s_param_mqtt_host); + wm.addParameter(&s_param_mqtt_port); + wm.addParameter(&s_param_mqtt_user); + wm.addParameter(&s_param_mqtt_pass); + wm.addParameter(&s_param_mqtt_topic); + wm.addParameter(&s_param_mqtt_prefix); + wm.addParameter(&s_param_mqtt_name); wm.setSaveParamsCallback(onPortalParamsSaved); } @@ -194,7 +254,8 @@ void resetWifiCredentials() { eraseWifiCredentials(); services::location::clear(); ui::radar::unitsReset(); - Serial.println("WiFi credentials, location, and units cleared"); + services::mqtt::clearConfig(); + Serial.println("WiFi credentials, location, units, and MQTT config cleared"); } void onConfigPortalApStarted(WiFiManager*) { diff --git a/src/ui/radar_range.cpp b/src/ui/radar_range.cpp index d8bf002e2..8c0ba6f8e 100644 --- a/src/ui/radar_range.cpp +++ b/src/ui/radar_range.cpp @@ -78,6 +78,21 @@ void rangeNext() { saveRangeIndex(); } +void rangeSetIndex(uint8_t index) { + s_range_index = (index < kRangePresetCount) ? index : kDefaultRangeIndex; + saveRangeIndex(); +} + +void setUseMiles(bool miles) { + s_use_miles = miles; + saveUseMiles(); +} + +void setShowRunways(bool show) { + s_show_runways = show; + saveShowRunways(); +} + const RangePreset& rangeCurrent() { return kRangePresets[s_range_index]; } uint8_t rangeIndex() { return s_range_index; } From 6b3bbd1016d2590bea4479b8ae3864deda1b7541 Mon Sep 17 00:00:00 2001 From: vfranchi Date: Fri, 2 Oct 2026 12:35:46 -0300 Subject: [PATCH 2/2] fix: apply portal saves and break the MQTT host label Bugs in the config portal path, all in the MQTT fields: - read the parameters BEFORE refreshPortalParamValues(): it reloads the saved config into every parameter, so running it first replaced what the user had just typed with the old values -- and saveFromPortal() then stored those back. The portal could not change the broker at all. - call services::mqtt::init() after the save so a new host or port takes effect without a reboot. For that to be safe, init() closes the session it replaces: it publishes "offline" (retained) on the old base first, because a clean DISCONNECT suppresses the will and an undriven session never loses its socket. - reset the discovery burst in init(): a save can rename the base topic, the prefix or the device, and waiting out kMqttDiscoveryRefreshMs (10 min) looks exactly like a broken entity. - "
" in front of the MQTT broker host label: WiFiManager emits a text field's label glued to the checkbox label above it (WFM_LABEL_AFTER). wifi_setup.cpp now includes services/mqtt_client.h for init(). --- src/services/mqtt_client.cpp | 22 ++++++++++++++++++++-- src/services/wifi_setup.cpp | 13 +++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/services/mqtt_client.cpp b/src/services/mqtt_client.cpp index 2ab287beb..82b7af425 100644 --- a/src/services/mqtt_client.cpp +++ b/src/services/mqtt_client.cpp @@ -272,8 +272,8 @@ void connectBroker() { // Reconnecting after the fetch borrowed the socket must not replay the // discovery burst: it is retained in the broker and HA already has the - // entities. Refreshing once a minute is cheap insurance against a broker - // that restarted and lost the retained topics. + // entities. A periodic refresh is cheap insurance against a broker that + // restarted and lost the retained topics. const bool discovery_fresh = s_discovery_done_ms != 0 && (millis() - s_discovery_done_ms) < config::kMqttDiscoveryRefreshMs; @@ -299,6 +299,17 @@ static_assert(MQTT_MAX_PACKET_SIZE == config::kMqttPacketSize, "MQTT_MAX_PACKET_SIZE must match config::kMqttPacketSize"); void init() { + // init() runs again on every portal save, so it cannot assume it is booting: the + // socket Home Assistant is watching is still open, and loop() is about to stop + // driving it. A clean DISCONNECT suppresses the will and an undriven session never + // loses its socket, so say goodbye on the old base before anything changes. + if (s_mqtt.connected()) { + std::snprintf(s_topic, sizeof(s_topic), "%s/status", s_base); + s_mqtt.publish(s_topic, "offline", true); + s_mqtt.disconnect(); + } else { + s_net.stop(); // a socket left over from a failed reconnect, if any + } loadConfig(s_cfg); buildTopics(); if (!s_cfg.enabled) { @@ -315,6 +326,13 @@ void init() { s_mqtt.setServer(s_cfg.host, s_cfg.port); s_mqtt.setCallback(onMessage); s_mqtt.setBufferSize(config::kMqttPacketSize); + // A saved config can rename the base topic, the discovery prefix or the device, so + // the retained configs HA is holding no longer match what we are about to publish. + // Reset the burst instead of waiting out kMqttDiscoveryRefreshMs with no entities. + // Boot already starts at zero; only a portal save can arrive with these set. + s_discovery_done_ms = 0; + s_discovery_index = 0; + s_last_discovery_ms = 0; s_state = State::WaitingLink; Serial.printf("MQTT: node %s client %s broker %s:%u topic '%s' prefix '%s'\n", s_node, s_client_id, s_cfg.host, s_cfg.port, s_base, s_ctx.prefix); diff --git a/src/services/wifi_setup.cpp b/src/services/wifi_setup.cpp index ea62eb0eb..ff5945f40 100644 --- a/src/services/wifi_setup.cpp +++ b/src/services/wifi_setup.cpp @@ -16,6 +16,7 @@ #include "config.h" #include "services/mqtt_config.h" +#include "services/mqtt_client.h" #include "services/radar_location.h" #include "ui/radar_range.h" #include "ui/status_screens.h" @@ -95,7 +96,7 @@ WiFiManagerParameter s_param_runways("show_runways", "Show airport runways", "T" char s_mqtt_on_checkbox_attrs[32] = "type=\"checkbox\""; WiFiManagerParameter s_param_mqtt_on("mqtt_on", "Publish to Home Assistant (MQTT)", "T", 2, s_mqtt_on_checkbox_attrs, WFM_LABEL_AFTER); -WiFiManagerParameter s_param_mqtt_host("mqtt_host", "MQTT broker host", "", 64, +WiFiManagerParameter s_param_mqtt_host("mqtt_host", "
MQTT broker host", "", 64, " type=\"text\" placeholder=\"e.g. 192.168.0.10\""); WiFiManagerParameter s_param_mqtt_port("mqtt_port", "MQTT broker port", "", kPortParamLen, " type=\"number\" min=\"1\" max=\"65535\"" @@ -155,12 +156,20 @@ void onPortalParamsSaved() { ui::radar::saveMilesFromPortal(s_param_miles.getValue()); ui::radar::saveRunwaysFromPortal(s_param_runways.getValue()); - refreshPortalParamValues(); + // The fields must be READ before the refresh: refreshPortalParamValues() reloads + // the saved config into every parameter, so running it first replaced what the + // user had just typed with the old values -- and saveFromPortal() then stored + // those back. The portal could never change the broker. services::mqtt::saveFromPortal( s_param_mqtt_host.getValue(), s_param_mqtt_port.getValue(), s_param_mqtt_user.getValue(), s_param_mqtt_pass.getValue(), s_param_mqtt_topic.getValue(), s_param_mqtt_prefix.getValue(), s_param_mqtt_name.getValue(), s_param_mqtt_on.getValue()); + + refreshPortalParamValues(); + // Apply the saved config now: a host or port that only takes effect on the next + // boot is exactly what this callback is here to avoid. + services::mqtt::init(); } void attachPortalParams(WiFiManager& wm) {