Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<chip-id>`. 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).

Expand Down Expand Up @@ -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:** `<prefix>/<component>/<node>/<object>/config` (retained), where `<node>` is
the base topic with `/` replaced by `_`, e.g. `homeassistant/select/planeradar_a1b2c3/range/config`.
- **Topics:** state on `<base>/state/<key>`, commands on `<base>/cmd/<key>`, availability on
`<base>/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

```
Expand All @@ -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/
Expand Down Expand Up @@ -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.
26 changes: 26 additions & 0 deletions include/config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 17 additions & 0 deletions include/services/adsb_client.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions include/services/mqtt_client.h
Original file line number Diff line number Diff line change
@@ -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
69 changes: 69 additions & 0 deletions include/services/mqtt_config.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#pragma once

#include <cstddef>
#include <cstdint>

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/<mac6>
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<uint32_t>((efuse_mac >> 24) & 0xFF) << 16) |
(static_cast<uint32_t>((efuse_mac >> 32) & 0xFF) << 8) |
static_cast<uint32_t>((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
Loading