diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff2e373..6d5cc17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,3 +43,4 @@ jobs: python-version: '3.11' - run: python -m pip install --upgrade platformio==6.1.19 - run: ./scripts/test.sh compile --platform ${{ matrix.platform }} + - run: ./scripts/test.sh examples --platform ${{ matrix.platform }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d246ff0..00d3392 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,8 +18,10 @@ jobs: python-version: '3.11' - run: python -m pip install --upgrade platformio==6.1.19 - run: ./scripts/test.sh compile --platform esp8266 + - run: ./scripts/test.sh examples --platform esp8266 - run: pio test -e native --filter test_native_core - run: ./scripts/test.sh compile --platform esp32 + - run: ./scripts/test.sh examples --platform esp32 - run: ./scripts/check-docs.sh - run: ./scripts/prepare-release.sh "$GITHUB_REF_NAME" - run: ./scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md" diff --git a/README.md b/README.md index f2b4b39..a065b1a 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,7 @@ [![](https://img.shields.io/github/v/release/alexhopeoconnor/arduino-home-assistant?label=Version)](https://github.com/alexhopeoconnor/arduino-home-assistant/releases) [![](https://img.shields.io/badge/Documentation-40BC13)](docs/README.md) -ArduinoHA is the maintained MQTT-discovery library behind compact Arduino, ESP8266, and ESP32 integrations with Home Assistant. It uses Arduino's standard network `Client` API and is continuously compile-tested on ESP8266 and ESP32. Device discovery requires Home Assistant 2024.11.0 or newer; single-component discovery remains supported. - -## Why use it - -- **Home Assistant discovery:** entities appear automatically from retained MQTT discovery payloads. -- **Two-way entities:** report local state and receive Home Assistant commands with a small, explicit API. -- **One physical device:** group multiple entities, metadata, shared availability, and MQTT Last Will under `HADevice`. -- **Control the footprint:** compile out entity implementations a firmware does not use. -- **Two discovery shapes:** start with one payload per entity or opt into a single device-discovery payload. +ArduinoHA lets an Arduino, ESP8266, or ESP32 application publish MQTT discovery data that Home Assistant understands. Give it a connected Arduino `Client`, declare entities, and Home Assistant creates the controls and telemetry automatically. ## Start with a sensor @@ -29,9 +21,7 @@ void setup() { WiFi.macAddress(mac); device.setUniqueId(mac, sizeof(mac)); - WiFi.begin("SSID", "password"); - while (WiFi.status() != WL_CONNECTED) delay(500); - + // Your application connects Wi-Fi before MQTT begins. temperature.setName("Temperature"); temperature.setUnitOfMeasurement("°C"); mqtt.begin("mqtt.local", "mqtt_user", "mqtt_password"); @@ -43,7 +33,25 @@ void loop() { } ``` -Read [Getting started](docs/getting-started.md) before copying this into production: it explains object lifetime, MQTT lifecycle, ESP32 includes, and install routes. +Build [ESP Sensor](examples/01-esp-sensor/) for a complete ESP8266/ESP32 project. It clearly marks the network and broker values you must provide without committing credentials. + +## What you can build + +- **Automatic Home Assistant discovery:** retained MQTT discovery messages create entities without hand-written Home Assistant YAML. +- **Two-way controls:** sensors publish state while switches, lights, covers, and other writable entities receive explicit callbacks. +- **One physical device:** `HADevice` groups metadata, multiple entities, shared availability, and MQTT Last Will. +- **Device discovery:** publish one compact discovery document for a new multi-entity device, or keep traditional per-entity discovery. +- **Small footprint:** exclude entity implementations your firmware does not use. + +## Choose an example + +| Example | Learn how to… | +| --- | --- | +| [ESP Sensor](examples/01-esp-sensor/) | connect an ESP application and publish changing numeric telemetry | +| [Switch Callback](examples/02-switch-callback/) | reflect Home Assistant commands in a physical output and report state back | +| [Multi-entity Device](examples/03-multi-entity-device/) | group controls and telemetry with shared availability and Last Will | +| [Device Discovery](examples/04-device-discovery/) | publish one Home Assistant device-discovery document for a new device | +| [Entity recipes](examples/README.md#entity-recipes) | find the existing focused Arduino sketches for each supported entity | ## Install @@ -52,29 +60,6 @@ lib_deps = home-assistant-integration=https://github.com/alexhopeoconnor/arduino-home-assistant.git#v3.1.0 ``` -PlatformIO clones the Git repository and checks out the ref after `#`; that ref is a release tag, not a GitHub Release asset. Arduino IDE is supported through the included [`library.properties`](library.properties); see [Getting started](docs/getting-started.md#install-the-library). +PlatformIO clones the Git repository and checks out the ref after `#`; that ref is a release tag, not a GitHub Release asset. Arduino IDE is supported through the included [`library.properties`](library.properties). -## Documentation - -The [documentation map](docs/README.md) is the starting point: - -- [Getting started](docs/getting-started.md): connection lifecycle and minimal sketches. -- [Device and discovery](docs/device-and-discovery.md): device metadata, discovery modes, and runtime refresh. -- [MQTT usage](docs/mqtt-usage.md): callbacks, availability, custom MQTT, logging, and footprint flags. -- [Entity guide](docs/entities.md): supported entity types and the best matching example. -- [Examples](examples/README.md): curated entry points and the full example index. - -## Development and releases - -```bash -./scripts/bump-version.sh vMAJOR.MINOR.PATCH -# Replace the generated CHANGELOG TODO with the release summary. -./scripts/test.sh compile --platform esp8266 -./scripts/test.sh compile --platform esp32 -./scripts/check-docs.sh -./scripts/prepare-release.sh vMAJOR.MINOR.PATCH --tag -``` - -The release preflight validates both package manifests and the matching changelog section. A pushed tag repeats the board-free compile checks and creates a GitHub Release from that section; it does not publish to the PlatformIO Registry or deploy firmware. - -See the [changelog](CHANGELOG.md) and [licence](LICENSE). +See [getting started](docs/getting-started.md), the [documentation map](docs/README.md), [examples](examples/README.md), [release history](CHANGELOG.md), and [licence](LICENSE). diff --git a/docs/README.md b/docs/README.md index 17ed728..b407e5f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,14 +1,12 @@ -# Documentation - -User-facing notes for this library. API details live in the headers under [`src/`](../src/). +# ArduinoHA documentation | Topic | What it covers | | ----- | ---------------- | -| [Getting started](getting-started.md) | Prerequisites, installing the library, minimal sketches | -| [Device & discovery](device-and-discovery.md) | `HADevice`, MQTT connection, discovery prefixes, entity IDs | -| [MQTT usage](mqtt-usage.md) | Callbacks, custom topics, availability, compiler flags | -| [Entities](entities.md) | Supported Home Assistant entity classes and example selection | -| [Examples](../examples/README.md) | Curated paths through the standalone sketches | +| [Getting started](getting-started.md) | Connecting a `Client`, installing the library, and the first MQTT lifecycle | +| [Device & discovery](device-and-discovery.md) | `HADevice`, discovery modes, metadata, identifiers, and migration | +| [MQTT usage](mqtt-usage.md) | Callbacks, custom topics, availability, logging, and footprint flags | +| [Entities](entities.md) | Supported Home Assistant entity classes and the best matching example | +| [Examples](../examples/README.md) | Guided PlatformIO projects and focused entity recipes | +| [Compatibility baseline](compatibility.md) | Supported Home Assistant capabilities and fork-specific compatibility notes | Class-level API details live in headers under [`src/`](../src/). Return to the [project overview](../README.md). -| [Compatibility baseline](compatibility.md) | Audited upstream/HA targets and deliberate exclusions | diff --git a/docs/mqtt-usage.md b/docs/mqtt-usage.md index 20f18dd..651a937 100644 --- a/docs/mqtt-usage.md +++ b/docs/mqtt-usage.md @@ -140,9 +140,9 @@ public: }; ``` -`arduinoHALog(...)` and `arduinoHALogf(...)` support subsystem-tagged messages such as `mqtt`, `discovery`, `availability`, `serializer`, `entity`, and `device`. `DeviceFramework` installs a structured sink so ArduinoHA messages follow the same main log stream as the rest of the firmware. +`arduinoHALog(...)` and `arduinoHALogf(...)` support subsystem-tagged messages such as `mqtt`, `discovery`, `availability`, `serializer`, `entity`, and `device`. Install a sink when you want those messages to join your application’s normal serial or structured log stream. -Optional transport context for diagnostics (registered automatically by **DeviceFramework**): **`arduinoHASetNetworkStatusFn`** (for example returning `WiFi.status()` so publish failure lines can include `wifi=`). +Applications that use Wi-Fi can register **`arduinoHASetNetworkStatusFn`** with a callback returning `WiFi.status()`. Publish failure diagnostics can then include the current Wi-Fi status without coupling ArduinoHA to a particular network library. **Exclude unused device types** (saves flash from vtables), e.g.: diff --git a/examples/01-esp-sensor/README.md b/examples/01-esp-sensor/README.md new file mode 100644 index 0000000..952951e --- /dev/null +++ b/examples/01-esp-sensor/README.md @@ -0,0 +1,9 @@ +# ESP Sensor + +This is the first complete ArduinoHA project for an ESP8266 D1 mini or ESP32 development board. It connects Wi-Fi, derives a stable device ID from the board MAC address, and publishes a changing **Uptime** sensor to Home Assistant. + +Before uploading, replace the `EXAMPLE_*` values in `include/ExampleNetwork.h`, or provide them as PlatformIO build flags. The header contains safe placeholders and must not contain real credentials when committed. + +After MQTT connects, Home Assistant discovers **ArduinoHA sensor example** and its Uptime entity. Rebooting the board keeps the same Home Assistant identity because its MAC address is stable. + +See [getting started](../../docs/getting-started.md) and the shared [examples guide](../README.md). diff --git a/examples/01-esp-sensor/include/ExampleNetwork.h b/examples/01-esp-sensor/include/ExampleNetwork.h new file mode 100644 index 0000000..890a067 --- /dev/null +++ b/examples/01-esp-sensor/include/ExampleNetwork.h @@ -0,0 +1,37 @@ +#pragma once +#if defined(ESP32) +#include +#include +#else +#include +#endif + +#ifndef EXAMPLE_WIFI_SSID +#define EXAMPLE_WIFI_SSID "replace-with-wifi-name" +#endif +#ifndef EXAMPLE_WIFI_PASSWORD +#define EXAMPLE_WIFI_PASSWORD "replace-with-wifi-password" +#endif +#ifndef EXAMPLE_MQTT_HOST +#define EXAMPLE_MQTT_HOST "replace-with-mqtt-host" +#endif +#ifndef EXAMPLE_MQTT_USER +#define EXAMPLE_MQTT_USER "" +#endif +#ifndef EXAMPLE_MQTT_PASSWORD +#define EXAMPLE_MQTT_PASSWORD "" +#endif + +inline void connectExampleWiFi() { + WiFi.mode(WIFI_STA); + WiFi.begin(EXAMPLE_WIFI_SSID, EXAMPLE_WIFI_PASSWORD); + while (WiFi.status() != WL_CONNECTED) { + delay(250); + } +} + +inline void setExampleUniqueId(HADevice& device) { + uint8_t mac[6]; + WiFi.macAddress(mac); + device.setUniqueId(mac, sizeof(mac)); +} diff --git a/examples/01-esp-sensor/platformio.ini b/examples/01-esp-sensor/platformio.ini new file mode 100644 index 0000000..73063ca --- /dev/null +++ b/examples/01-esp-sensor/platformio.ini @@ -0,0 +1,24 @@ +[platformio] +default_envs = esp8266 + +[common] +framework = arduino +lib_ldf_mode = deep+ +lib_deps = + home-assistant-integration=symlink://../.. + +[env:esp8266] +extends = common +platform = espressif8266 +board = d1_mini +platform_packages = + platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3 + +[env:esp32] +extends = common +platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip +board = esp32dev +build_unflags = -std=gnu++11 +build_flags = + -std=gnu++14 + -I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src diff --git a/examples/01-esp-sensor/src/main.cpp b/examples/01-esp-sensor/src/main.cpp new file mode 100644 index 0000000..bd67ffd --- /dev/null +++ b/examples/01-esp-sensor/src/main.cpp @@ -0,0 +1,30 @@ +#include +#include +#include "ExampleNetwork.h" + +WiFiClient client; +HADevice device; +HAMqtt mqtt(client, device); +HASensorNumber uptime("uptime"); + +void setup() { + Serial.begin(115200); + connectExampleWiFi(); + setExampleUniqueId(device); + + device.setName("ArduinoHA sensor example"); + device.setSoftwareVersion("1.0.0"); + uptime.setName("Uptime"); + uptime.setUnitOfMeasurement("s"); + mqtt.begin(EXAMPLE_MQTT_HOST, EXAMPLE_MQTT_USER, EXAMPLE_MQTT_PASSWORD); +} + +void loop() { + mqtt.loop(); + + static unsigned long lastUpdate = 0; + if (millis() - lastUpdate >= 1000) { + uptime.setValue(static_cast(millis() / 1000)); + lastUpdate = millis(); + } +} diff --git a/examples/02-switch-callback/README.md b/examples/02-switch-callback/README.md new file mode 100644 index 0000000..328f141 --- /dev/null +++ b/examples/02-switch-callback/README.md @@ -0,0 +1,9 @@ +# Switch Callback + +This example creates one writable Home Assistant switch. When Home Assistant sends a command, `onOutputCommand()` changes the local output and immediately reports the resulting state back through the supplied `HASwitch`. + +The ESP8266 build uses `LED_BUILTIN`; the ESP32 build uses GPIO 2. Confirm your board’s LED polarity before treating that output as a real load. + +Set the Wi-Fi and MQTT placeholders in `include/ExampleNetwork.h`, flash the board, then toggle **Example output** in Home Assistant. + +See [MQTT usage](../../docs/mqtt-usage.md) and the shared [examples guide](../README.md). diff --git a/examples/02-switch-callback/include/ExampleNetwork.h b/examples/02-switch-callback/include/ExampleNetwork.h new file mode 100644 index 0000000..890a067 --- /dev/null +++ b/examples/02-switch-callback/include/ExampleNetwork.h @@ -0,0 +1,37 @@ +#pragma once +#if defined(ESP32) +#include +#include +#else +#include +#endif + +#ifndef EXAMPLE_WIFI_SSID +#define EXAMPLE_WIFI_SSID "replace-with-wifi-name" +#endif +#ifndef EXAMPLE_WIFI_PASSWORD +#define EXAMPLE_WIFI_PASSWORD "replace-with-wifi-password" +#endif +#ifndef EXAMPLE_MQTT_HOST +#define EXAMPLE_MQTT_HOST "replace-with-mqtt-host" +#endif +#ifndef EXAMPLE_MQTT_USER +#define EXAMPLE_MQTT_USER "" +#endif +#ifndef EXAMPLE_MQTT_PASSWORD +#define EXAMPLE_MQTT_PASSWORD "" +#endif + +inline void connectExampleWiFi() { + WiFi.mode(WIFI_STA); + WiFi.begin(EXAMPLE_WIFI_SSID, EXAMPLE_WIFI_PASSWORD); + while (WiFi.status() != WL_CONNECTED) { + delay(250); + } +} + +inline void setExampleUniqueId(HADevice& device) { + uint8_t mac[6]; + WiFi.macAddress(mac); + device.setUniqueId(mac, sizeof(mac)); +} diff --git a/examples/02-switch-callback/platformio.ini b/examples/02-switch-callback/platformio.ini new file mode 100644 index 0000000..73063ca --- /dev/null +++ b/examples/02-switch-callback/platformio.ini @@ -0,0 +1,24 @@ +[platformio] +default_envs = esp8266 + +[common] +framework = arduino +lib_ldf_mode = deep+ +lib_deps = + home-assistant-integration=symlink://../.. + +[env:esp8266] +extends = common +platform = espressif8266 +board = d1_mini +platform_packages = + platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3 + +[env:esp32] +extends = common +platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip +board = esp32dev +build_unflags = -std=gnu++11 +build_flags = + -std=gnu++14 + -I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src diff --git a/examples/02-switch-callback/src/main.cpp b/examples/02-switch-callback/src/main.cpp new file mode 100644 index 0000000..d0bb69a --- /dev/null +++ b/examples/02-switch-callback/src/main.cpp @@ -0,0 +1,36 @@ +#include +#include +#include "ExampleNetwork.h" + +#if defined(ESP32) +constexpr uint8_t kOutputPin = 2; +#else +constexpr uint8_t kOutputPin = LED_BUILTIN; +#endif + +WiFiClient client; +HADevice device; +HAMqtt mqtt(client, device); +HASwitch output("output"); + +void onOutputCommand(bool state, HASwitch* sender) { + digitalWrite(kOutputPin, state ? HIGH : LOW); + sender->setState(state); +} + +void setup() { + Serial.begin(115200); + pinMode(kOutputPin, OUTPUT); + digitalWrite(kOutputPin, LOW); + connectExampleWiFi(); + setExampleUniqueId(device); + + device.setName("ArduinoHA switch example"); + output.setName("Example output"); + output.onCommand(onOutputCommand); + mqtt.begin(EXAMPLE_MQTT_HOST, EXAMPLE_MQTT_USER, EXAMPLE_MQTT_PASSWORD); +} + +void loop() { + mqtt.loop(); +} diff --git a/examples/03-multi-entity-device/README.md b/examples/03-multi-entity-device/README.md new file mode 100644 index 0000000..0c1b73e --- /dev/null +++ b/examples/03-multi-entity-device/README.md @@ -0,0 +1,9 @@ +# Multi-entity Device + +This example groups an Uptime sensor and writable output under one `HADevice`. It adds common metadata, one shared availability topic, and MQTT Last Will so Home Assistant marks the complete device unavailable if its network connection disappears. + +Configure `include/ExampleNetwork.h`, flash the selected target, and inspect the device page in Home Assistant. Both entities belong to **ArduinoHA multi-entity example** and share its availability state. + +Use this shape when one physical board exposes several related controls or sensors. + +See [device and discovery](../../docs/device-and-discovery.md) and [MQTT usage](../../docs/mqtt-usage.md). diff --git a/examples/03-multi-entity-device/include/ExampleNetwork.h b/examples/03-multi-entity-device/include/ExampleNetwork.h new file mode 100644 index 0000000..890a067 --- /dev/null +++ b/examples/03-multi-entity-device/include/ExampleNetwork.h @@ -0,0 +1,37 @@ +#pragma once +#if defined(ESP32) +#include +#include +#else +#include +#endif + +#ifndef EXAMPLE_WIFI_SSID +#define EXAMPLE_WIFI_SSID "replace-with-wifi-name" +#endif +#ifndef EXAMPLE_WIFI_PASSWORD +#define EXAMPLE_WIFI_PASSWORD "replace-with-wifi-password" +#endif +#ifndef EXAMPLE_MQTT_HOST +#define EXAMPLE_MQTT_HOST "replace-with-mqtt-host" +#endif +#ifndef EXAMPLE_MQTT_USER +#define EXAMPLE_MQTT_USER "" +#endif +#ifndef EXAMPLE_MQTT_PASSWORD +#define EXAMPLE_MQTT_PASSWORD "" +#endif + +inline void connectExampleWiFi() { + WiFi.mode(WIFI_STA); + WiFi.begin(EXAMPLE_WIFI_SSID, EXAMPLE_WIFI_PASSWORD); + while (WiFi.status() != WL_CONNECTED) { + delay(250); + } +} + +inline void setExampleUniqueId(HADevice& device) { + uint8_t mac[6]; + WiFi.macAddress(mac); + device.setUniqueId(mac, sizeof(mac)); +} diff --git a/examples/03-multi-entity-device/platformio.ini b/examples/03-multi-entity-device/platformio.ini new file mode 100644 index 0000000..73063ca --- /dev/null +++ b/examples/03-multi-entity-device/platformio.ini @@ -0,0 +1,24 @@ +[platformio] +default_envs = esp8266 + +[common] +framework = arduino +lib_ldf_mode = deep+ +lib_deps = + home-assistant-integration=symlink://../.. + +[env:esp8266] +extends = common +platform = espressif8266 +board = d1_mini +platform_packages = + platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3 + +[env:esp32] +extends = common +platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip +board = esp32dev +build_unflags = -std=gnu++11 +build_flags = + -std=gnu++14 + -I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src diff --git a/examples/03-multi-entity-device/src/main.cpp b/examples/03-multi-entity-device/src/main.cpp new file mode 100644 index 0000000..b4e196e --- /dev/null +++ b/examples/03-multi-entity-device/src/main.cpp @@ -0,0 +1,48 @@ +#include +#include +#include "ExampleNetwork.h" + +#if defined(ESP32) +constexpr uint8_t kOutputPin = 2; +#else +constexpr uint8_t kOutputPin = LED_BUILTIN; +#endif + +WiFiClient client; +HADevice device; +HAMqtt mqtt(client, device); +HASensorNumber uptime("uptime"); +HASwitch output("output"); + +void onOutputCommand(bool state, HASwitch* sender) { + digitalWrite(kOutputPin, state ? HIGH : LOW); + sender->setState(state); +} + +void setup() { + Serial.begin(115200); + pinMode(kOutputPin, OUTPUT); + connectExampleWiFi(); + setExampleUniqueId(device); + + device.setName("ArduinoHA multi-entity example"); + device.setManufacturer("Example Devices"); + device.setSoftwareVersion("1.0.0"); + device.enableSharedAvailability(); + device.enableLastWill(); + + uptime.setName("Uptime"); + uptime.setUnitOfMeasurement("s"); + output.setName("Example output"); + output.onCommand(onOutputCommand); + mqtt.begin(EXAMPLE_MQTT_HOST, EXAMPLE_MQTT_USER, EXAMPLE_MQTT_PASSWORD); +} + +void loop() { + mqtt.loop(); + static unsigned long lastUpdate = 0; + if (millis() - lastUpdate >= 1000) { + uptime.setValue(static_cast(millis() / 1000)); + lastUpdate = millis(); + } +} diff --git a/examples/04-device-discovery/README.md b/examples/04-device-discovery/README.md new file mode 100644 index 0000000..6370505 --- /dev/null +++ b/examples/04-device-discovery/README.md @@ -0,0 +1,9 @@ +# Device Discovery + +This example opts into Home Assistant MQTT device discovery. Instead of one retained discovery document per entity, ArduinoHA publishes one device document containing the component definitions and richer board metadata. + +Use device discovery for a **new** device on Home Assistant 2024.11.0 or newer. Do not enable it on a device that has already published traditional single-component discovery without following the documented migration procedure. + +After configuring `include/ExampleNetwork.h` and flashing the board, Home Assistant discovers **ArduinoHA device discovery example** and its Uptime sensor. + +See [device discovery](../../docs/device-and-discovery.md#single-component-vs-device-discovery) and the shared [examples guide](../README.md). diff --git a/examples/04-device-discovery/include/ExampleNetwork.h b/examples/04-device-discovery/include/ExampleNetwork.h new file mode 100644 index 0000000..890a067 --- /dev/null +++ b/examples/04-device-discovery/include/ExampleNetwork.h @@ -0,0 +1,37 @@ +#pragma once +#if defined(ESP32) +#include +#include +#else +#include +#endif + +#ifndef EXAMPLE_WIFI_SSID +#define EXAMPLE_WIFI_SSID "replace-with-wifi-name" +#endif +#ifndef EXAMPLE_WIFI_PASSWORD +#define EXAMPLE_WIFI_PASSWORD "replace-with-wifi-password" +#endif +#ifndef EXAMPLE_MQTT_HOST +#define EXAMPLE_MQTT_HOST "replace-with-mqtt-host" +#endif +#ifndef EXAMPLE_MQTT_USER +#define EXAMPLE_MQTT_USER "" +#endif +#ifndef EXAMPLE_MQTT_PASSWORD +#define EXAMPLE_MQTT_PASSWORD "" +#endif + +inline void connectExampleWiFi() { + WiFi.mode(WIFI_STA); + WiFi.begin(EXAMPLE_WIFI_SSID, EXAMPLE_WIFI_PASSWORD); + while (WiFi.status() != WL_CONNECTED) { + delay(250); + } +} + +inline void setExampleUniqueId(HADevice& device) { + uint8_t mac[6]; + WiFi.macAddress(mac); + device.setUniqueId(mac, sizeof(mac)); +} diff --git a/examples/04-device-discovery/platformio.ini b/examples/04-device-discovery/platformio.ini new file mode 100644 index 0000000..73063ca --- /dev/null +++ b/examples/04-device-discovery/platformio.ini @@ -0,0 +1,24 @@ +[platformio] +default_envs = esp8266 + +[common] +framework = arduino +lib_ldf_mode = deep+ +lib_deps = + home-assistant-integration=symlink://../.. + +[env:esp8266] +extends = common +platform = espressif8266 +board = d1_mini +platform_packages = + platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3 + +[env:esp32] +extends = common +platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip +board = esp32dev +build_unflags = -std=gnu++11 +build_flags = + -std=gnu++14 + -I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src diff --git a/examples/04-device-discovery/src/main.cpp b/examples/04-device-discovery/src/main.cpp new file mode 100644 index 0000000..9ce3429 --- /dev/null +++ b/examples/04-device-discovery/src/main.cpp @@ -0,0 +1,33 @@ +#include +#include +#include "ExampleNetwork.h" + +WiFiClient client; +HADevice device; +HAMqtt mqtt(client, device); +HASensorNumber uptime("uptime"); + +void setup() { + Serial.begin(115200); + connectExampleWiFi(); + setExampleUniqueId(device); + + device.setName("ArduinoHA device discovery example"); + device.setManufacturer("Example Devices"); + device.setModel("ESP example"); + device.setSoftwareVersion("1.0.0"); + uptime.setName("Uptime"); + uptime.setUnitOfMeasurement("s"); + + mqtt.enableDeviceDiscovery(); + mqtt.begin(EXAMPLE_MQTT_HOST, EXAMPLE_MQTT_USER, EXAMPLE_MQTT_PASSWORD); +} + +void loop() { + mqtt.loop(); + static unsigned long lastUpdate = 0; + if (millis() - lastUpdate >= 1000) { + uptime.setValue(static_cast(millis() / 1000)); + lastUpdate = millis(); + } +} diff --git a/examples/README.md b/examples/README.md index fc7485d..94fbac0 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,19 +1,24 @@ # ArduinoHA examples -Each directory is an Arduino sketch. Start with the network example matching your board, then pick an entity example from the table. Copy credentials into your local development configuration; do not commit them in a sketch. +Start with a guided PlatformIO project when you are new to the library. Each one builds for ESP8266 and ESP32, contains safe placeholder credentials, and explains the Home Assistant result you should see. -| Starting point | Use it for | +```bash +pio run -d examples/01-esp-sensor -e esp8266 +pio run -d examples/01-esp-sensor -e esp8266 -t upload +``` + +| Guided example | What it demonstrates | | --- | --- | -| [nodemcu](nodemcu/nodemcu.ino) | Basic ESP8266 Wi-Fi and MQTT connection | -| [nano33iot](nano33iot/nano33iot.ino) | Basic Arduino Nano 33 IoT connection | -| [mqtt-with-credentials](mqtt-with-credentials/mqtt-with-credentials.ino) | MQTT authentication | -| [mqtt-advanced](mqtt-advanced/mqtt-advanced.ino) | Custom MQTT subscriptions and publishing | -| [availability](availability/availability.ino) | Per-entity availability | -| [advanced-availability](advanced-availability/advanced-availability.ino) | Shared availability and MQTT Last Will | +| [ESP Sensor](01-esp-sensor/) | Wi-Fi application wiring, a unique device ID, and changing numeric telemetry | +| [Switch Callback](02-switch-callback/) | A Home Assistant command callback and local state acknowledgement | +| [Multi-entity Device](03-multi-entity-device/) | Multiple entities, shared availability, and MQTT Last Will | +| [Device Discovery](04-device-discovery/) | One device-discovery document for a newly deployed device | -## Entity examples +## Entity recipes -| Example | Home Assistant behaviour | +The original focused sketches remain useful as short API recipes. They are intentionally transport-specific Arduino sketches, rather than full product firmware. + +| Recipe | Home Assistant behaviour | | --- | --- | | [binary-sensor](binary-sensor/binary-sensor.ino) | Door/contact-style binary state | | [button](button/button.ino) | Press action | @@ -34,6 +39,4 @@ Each directory is an Arduino sketch. Start with the network example matching you | [sensor-integer](sensor-integer/sensor-integer.ino) | Integer uptime measurement | | [tag-scanner](tag-scanner/tag-scanner.ino) | RFID tag reporting | -The examples are intentionally small, not production firmware frameworks. For reusable Wi-Fi configuration, MQTT wiring, profiles, OTA, and migrations, use [DeviceFramework](https://github.com/alexhopeoconnor/DeviceFramework) in a consuming firmware. - See the [entity guide](../docs/entities.md) and [project overview](../README.md). diff --git a/scripts/check-docs.sh b/scripts/check-docs.sh index ff0b15c..102aedf 100755 --- a/scripts/check-docs.sh +++ b/scripts/check-docs.sh @@ -24,4 +24,14 @@ while IFS= read -r -d '' markdown; do done < <(sed -nE 's/.*\]\(([^ )]+)( "[^"]*")?\).*/\1/p' "$markdown") done < <(find "$root" -path "$root/.git" -prune -o -name '*.md' -type f -print0) +while IFS= read -r example; do + for required in README.md platformio.ini; do + [[ -f "$example/$required" ]] || { echo "Incomplete guided example: ${example#$root/} is missing $required" >&2; exit 1; } + done + find "$example" -maxdepth 2 -type f \( -name '*.ino' -o -name '*.cpp' \) -print -quit | grep -q . || { + echo "Incomplete guided example: ${example#$root/} has no sketch source" >&2 + exit 1 + } +done < <(find "$root/examples" -mindepth 1 -maxdepth 1 -type d -name '[0-9][0-9]-*' -print | sort) + echo "Documentation links and required files passed" diff --git a/scripts/test.sh b/scripts/test.sh index 5b40b86..201ef47 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -2,17 +2,31 @@ set -euo pipefail usage() { - echo "Usage: $0 compile --platform esp8266|esp32" >&2 + echo "Usage: $0 compile|examples --platform esp8266|esp32" >&2 exit 2 } -[[ "${1:-}" == "compile" && "${2:-}" == "--platform" && $# -eq 3 ]] || usage - +[[ $# -eq 3 && ( "${1:-}" == "compile" || "${1:-}" == "examples" ) && "${2:-}" == "--platform" ]] || usage case "${3:-}" in esp8266|esp32) environment="${3}" ;; *) usage ;; esac root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -pio test -d "$root" -e "$environment" --without-uploading --without-testing -echo "ArduinoHA compile check passed for ${3}" +case "$1" in + compile) + pio test -d "$root" -e "$environment" --without-uploading --without-testing + echo "ArduinoHA compile check passed for $environment" + ;; + examples) + mapfile -t examples < <(find "$root/examples" -mindepth 1 -maxdepth 1 -type d -name '[0-9][0-9]-*' -print | sort) + if (( ${#examples[@]} == 0 )); then + echo "No example projects found" >&2 + exit 1 + fi + for example in "${examples[@]}"; do + pio run -d "$example" -e "$environment" buildDeviceDiscoverySerializer(); + } + + HASerializer* serializer = new (std::nothrow) HASerializer(deviceType, 1); + if (serializer) { + serializer->set( + AHATOFSTR(HAPlatformProperty), + deviceType->componentName(), + HASerializer::ProgmemPropertyValue + ); + } + return serializer; +} + bool HAMqtt::publishDeviceDiscoveryPayload(HABaseDeviceType* removalType) { const char* deviceUniqueId = _device.getUniqueId(); @@ -986,7 +1072,6 @@ bool HAMqtt::publishDeviceDiscoveryPayload(HABaseDeviceType* removalType) } HABaseDeviceType* componentTypes[_devicesTypesNb]; - HASerializer* componentSerializers[_devicesTypesNb]; uint8_t componentSerializerCount = 0; uint32_t componentsPayloadLength = 2; // {} @@ -999,9 +1084,6 @@ bool HAMqtt::publishDeviceDiscoveryPayload(HABaseDeviceType* removalType) const char* uniqueId = deviceType->uniqueId(); if (!uniqueId || !HAJson::isValidDiscoveryTopicToken(uniqueId)) { arduinoHALog(ArduinoHALogLevel::Error, kDiscovery, F("device discovery rejected invalid component ID")); - for (uint8_t j = 0; j < componentSerializerCount; j++) { - delete componentSerializers[j]; - } return false; } @@ -1009,24 +1091,9 @@ bool HAMqtt::publishDeviceDiscoveryPayload(HABaseDeviceType* removalType) continue; } - HASerializer* serializer = nullptr; - if (deviceType == removalType) { - serializer = new (std::nothrow) HASerializer(deviceType, 1); - if (serializer) { - serializer->set( - AHATOFSTR(HAPlatformProperty), - deviceType->componentName(), - HASerializer::ProgmemPropertyValue - ); - } - } else { - serializer = deviceType->buildDeviceDiscoverySerializer(); - } + HASerializer* serializer = buildDeviceDiscoveryComponentSerializer(deviceType, removalType); if (!serializer) { arduinoHALog(ArduinoHALogLevel::Error, kDiscovery, F("device discovery component serializer unavailable")); - for (uint8_t j = 0; j < componentSerializerCount; j++) { - delete componentSerializers[j]; - } return false; } @@ -1039,9 +1106,6 @@ bool HAMqtt::publishDeviceDiscoveryPayload(HABaseDeviceType* removalType) ) { delete serializer; arduinoHALog(ArduinoHALogLevel::Error, kDiscovery, F("device discovery component serializer invalid or too large")); - for (uint8_t j = 0; j < componentSerializerCount; j++) { - delete componentSerializers[j]; - } return false; } @@ -1051,14 +1115,11 @@ bool HAMqtt::publishDeviceDiscoveryPayload(HABaseDeviceType* removalType) componentsPayloadLength += componentKeySize + 1 + serializerSize; if (componentsPayloadLength > UINT16_MAX) { delete serializer; - for (uint8_t j = 0; j < componentSerializerCount; j++) { - delete componentSerializers[j]; - } return false; } - componentTypes[componentSerializerCount] = deviceType; - componentSerializers[componentSerializerCount++] = serializer; + componentTypes[componentSerializerCount++] = deviceType; + delete serializer; } if (componentSerializerCount == 0) { @@ -1076,9 +1137,6 @@ bool HAMqtt::publishDeviceDiscoveryPayload(HABaseDeviceType* removalType) } const uint16_t originSerializerSize = originSerializer.calculateSize(); if (originSerializerSize == 0) { - for (uint8_t i = 0; i < componentSerializerCount; i++) { - delete componentSerializers[i]; - } return false; } @@ -1088,9 +1146,6 @@ bool HAMqtt::publishDeviceDiscoveryPayload(HABaseDeviceType* removalType) strlen(deviceUniqueId) + 1 + strlen_P(HAConfigTopic) + 1; if (topicLength == 0 || topicLength > UINT16_MAX) { - for (uint8_t i = 0; i < componentSerializerCount; i++) { - delete componentSerializers[i]; - } return false; } @@ -1112,9 +1167,6 @@ bool HAMqtt::publishDeviceDiscoveryPayload(HABaseDeviceType* removalType) componentsPayloadLength + strlen_P(HASerializerJsonDataSuffix); if (payloadLength > UINT16_MAX) { - for (uint8_t i = 0; i < componentSerializerCount; i++) { - delete componentSerializers[i]; - } return false; } @@ -1137,9 +1189,6 @@ bool HAMqtt::publishDeviceDiscoveryPayload(HABaseDeviceType* removalType) F(" len=") + String(payloadLength) + formatDirectPublishFailureDiagnostics(discConnBefore, discPsBefore) ); - for (uint8_t i = 0; i < componentSerializerCount; i++) { - delete componentSerializers[i]; - } return false; } @@ -1166,23 +1215,41 @@ bool HAMqtt::publishDeviceDiscoveryPayload(HABaseDeviceType* removalType) } if (written) { - const char* componentId = componentTypes[i]->uniqueId(); + HABaseDeviceType* componentType = componentTypes[i]; + HASerializer* serializer = + buildDeviceDiscoveryComponentSerializer(componentType, removalType); + if (!serializer) { + written = false; + continue; + } + + const char* componentId = componentType->uniqueId(); const char quote = '"'; const char colon = ':'; written = writePayload("e, 1) && writePayload(componentId, strlen(componentId)) && writePayload("e, 1) && writePayload(&colon, 1) && - componentSerializers[i]->flush(); + serializer->flush(); + delete serializer; } - delete componentSerializers[i]; } - if (written) { - written = writePayload(AHATOFSTR(HASerializerJsonDataSuffix)) && - writePayload(AHATOFSTR(HASerializerJsonDataSuffix)); + if (!written) { + abortDirectPublish(); + arduinoHALog(ArduinoHALogLevel::Warn, kDiscovery, F("device discovery stream aborted")); + return false; } - const bool published = endPublish() && written; + + written = writePayload(AHATOFSTR(HASerializerJsonDataSuffix)) && + writePayload(AHATOFSTR(HASerializerJsonDataSuffix)); + if (!written) { + abortDirectPublish(); + arduinoHALog(ArduinoHALogLevel::Warn, kDiscovery, F("device discovery stream aborted")); + return false; + } + + const bool published = endPublish(); if (!published) { arduinoHALog(ArduinoHALogLevel::Warn, kDiscovery, F("device discovery publish failed")); } diff --git a/src/HAMqtt.h b/src/HAMqtt.h index eece6e6..5572b32 100644 --- a/src/HAMqtt.h +++ b/src/HAMqtt.h @@ -25,6 +25,7 @@ class PubSubClientMock; class HADevice; class HABaseDeviceType; +class HASerializer; #if defined(ARDUINO_API_VERSION) using namespace arduino; @@ -609,6 +610,11 @@ private: bool publishDeviceDiscoveryPayload(HABaseDeviceType* removalType = nullptr); + HASerializer* buildDeviceDiscoveryComponentSerializer( + HABaseDeviceType* deviceType, + HABaseDeviceType* removalType + ); + bool clearDeviceDiscoveryConfig(); bool publishDeviceDiscoveryMigrationMarker(HABaseDeviceType* deviceType); @@ -687,6 +693,15 @@ private: */ String formatDirectPublishFailureDiagnostics(bool hamqttConnectedBefore, int pubsubStateBefore) const; + // Buffer direct MQTT streaming writes so serializers do not issue hundreds + // of tiny TCP writes while a PubSubClient publish is open. + static const uint16_t DirectPublishBufferSize = 128; + bool flushDirectPublishBuffer(); + bool appendDirectPublishPayload(const uint8_t* data, uint16_t length); + bool appendDirectPublishProgmemPayload(const __FlashStringHelper* src); + void clearDirectPublishBuffer(); + void abortDirectPublish(); + #ifdef ARDUINOHA_TEST PubSubClientMock* _mqtt; #else @@ -697,6 +712,10 @@ private: PubSubClient* _mqtt; #endif + uint8_t _directPublishBuffer[DirectPublishBufferSize]; + uint16_t _directPublishBufferLength; + bool _directPublishActive; + /// Instance of the HADevice passed to the constructor. const HADevice& _device; diff --git a/src/utils/HASerializer.cpp b/src/utils/HASerializer.cpp index ffb6f90..41ca64d 100644 --- a/src/utils/HASerializer.cpp +++ b/src/utils/HASerializer.cpp @@ -273,7 +273,7 @@ HASerializer::HASerializer( _deviceType(deviceType), _entriesNb(0), _maxEntriesNb(maxEntriesNb), - _entries(new SerializerEntry[maxEntriesNb]) + _entries(new (std::nothrow) SerializerEntry[maxEntriesNb]) { } @@ -340,6 +340,10 @@ void HASerializer::topic(const __FlashStringHelper* topic) HASerializer::SerializerEntry* HASerializer::addEntry() { + if (!_entries) { + return nullptr; + } + if (_entriesNb >= _maxEntriesNb) { if (_maxEntriesNb == UINT8_MAX) { return nullptr; @@ -370,6 +374,10 @@ HASerializer::SerializerEntry* HASerializer::addEntry() uint16_t HASerializer::calculateSize() const { + if (!_entries) { + return 0; + } + uint32_t size = strlen_P(HASerializerJsonDataPrefix) + strlen_P(HASerializerJsonDataSuffix); diff --git a/tests/ha-contract/README.md b/tests/ha-contract/README.md index 3906785..03a370a 100644 --- a/tests/ha-contract/README.md +++ b/tests/ha-contract/README.md @@ -31,7 +31,16 @@ HA_VERSION=2024.11.3 docker compose -f tests/ha-contract/compose.yaml down -v ``` Use `HA_VERSION=stable` and `HA_VERSION=dev` for the current supported and -development Home Assistant images. The test uses only ephemeral named volumes; +development Home Assistant images. + +The checked-in GitHub workflow runs the baseline, stable, and development images +as a compatibility audit. When it is on the repository's default branch, GitHub +exposes it for manual dispatch and the weekly schedule. It is not a tag-release +gate. + +For a local development-image test, add `CONTRACT_EXPECT_DISABLED_CLEANUP=1` to +both `docker compose run ... tests` commands. That lane checks that initially +disabled device components are cleaned. The test uses only ephemeral named volumes; `down -v` removes its broker data, Home Assistant config, owner token, and registry state.