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..82b7af425 --- /dev/null +++ b/src/services/mqtt_client.cpp @@ -0,0 +1,416 @@ +#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. 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; + 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() { + // 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) { + 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); + // 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); +} + +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..ff5945f40 100644 --- a/src/services/wifi_setup.cpp +++ b/src/services/wifi_setup.cpp @@ -15,6 +15,8 @@ #endif #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" @@ -71,6 +73,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 +90,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 +125,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() { @@ -108,7 +156,20 @@ void onPortalParamsSaved() { ui::radar::saveMilesFromPortal(s_param_miles.getValue()); ui::radar::saveRunwaysFromPortal(s_param_runways.getValue()); + // 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) { @@ -117,6 +178,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 +263,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; }