Compare commits

..
8 Commits
Author SHA1 Message Date
alex ea3f41b3f9 release: prepare ArduinoHA v3.2.0 2026-09-07 20:46:00 +10:00
alex 0dbf0ecb75 test: rename HA MQTT test harness 2026-09-07 19:29:21 +10:00
alex 50cf1c36f0 test: update MQTT contract support dependencies 2026-09-07 08:12:18 +10:00
alex bf18695c13 test: extract reusable HA MQTT contract testkit 2026-09-06 20:02:03 +10:00
alex e11fc4ace1 Merge pull request #1 from alexhopeoconnor/maintenance/ha-mqtt-compatibility
feat: merge Home Assistant MQTT compatibility release
2026-09-06 08:50:38 +10:00
alex ac08bf8431 style: remove trailing blank lines 2026-09-06 08:46:07 +10:00
alex 35b375b497 feat: improve MQTT discovery publishing and examples 2026-09-04 09:52:20 +10:00
alex 7f28604887 Stabilize Home Assistant contract CI 2026-09-02 07:48:48 +10:00
50 changed files with 1536 additions and 640 deletions
+1
View File
@@ -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 }}
@@ -1,4 +1,4 @@
name: Home Assistant MQTT contract
name: Home Assistant MQTT test harness
on:
workflow_dispatch:
@@ -9,15 +9,22 @@ permissions:
contents: read
jobs:
contract:
test_harness:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
home_assistant: ["2024.11.3", stable, dev]
include:
- home_assistant: "2024.11.3"
expect_disabled_cleanup: "0"
- home_assistant: stable
expect_disabled_cleanup: "0"
- home_assistant: dev
expect_disabled_cleanup: "1"
env:
HA_VERSION: ${{ matrix.home_assistant }}
COMPOSE_FILE: tests/ha-contract/compose.yaml
TEST_HARNESS_EXPECT_DISABLED_CLEANUP: ${{ matrix.expect_disabled_cleanup }}
COMPOSE_FILE: tests/ha-test-harness/compose.yaml
steps:
- uses: actions/checkout@v4
- name: Start broker and Home Assistant
@@ -27,10 +34,10 @@ jobs:
- name: Restart Home Assistant from retained data
run: docker compose restart homeassistant
- name: Check retained discovery after restart
run: docker compose run --rm -e CONTRACT_MODE=retained-restart tests
run: docker compose run --rm -e TEST_HARNESS_MODE=retained-restart tests
- name: Collect Home Assistant logs on failure
if: failure()
run: docker compose logs --no-color homeassistant mqtt
- name: Remove contract volumes
- name: Remove test harness volumes
if: always()
run: docker compose down -v
+11 -7
View File
@@ -9,15 +9,18 @@ permissions:
contents: write
jobs:
ha-contract:
test_harness:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
home_assistant: ["2024.11.3", stable, dev]
include:
- home_assistant: "2024.11.3"
- home_assistant: stable
env:
HA_VERSION: ${{ matrix.home_assistant }}
COMPOSE_FILE: tests/ha-contract/compose.yaml
TEST_HARNESS_EXPECT_DISABLED_CLEANUP: "0"
COMPOSE_FILE: tests/ha-test-harness/compose.yaml
steps:
- uses: actions/checkout@v4
- name: Start broker and Home Assistant
@@ -27,17 +30,16 @@ jobs:
- name: Restart Home Assistant from retained data
run: docker compose restart homeassistant
- name: Check retained discovery after restart
run: docker compose run --rm -e CONTRACT_MODE=retained-restart tests
run: docker compose run --rm -e TEST_HARNESS_MODE=retained-restart tests
- name: Collect Home Assistant logs on failure
if: failure()
run: docker compose logs --no-color homeassistant mqtt
- name: Remove contract volumes
- name: Remove test harness volumes
if: always()
run: docker compose down -v
publish:
needs: ha-contract
runs-on: ubuntu-latest
needs: test_harness
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
@@ -45,8 +47,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"
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## 3.2.0
**Discovery publication and resilience:**
- Batch direct MQTT discovery writes into bounded 128-byte chunks, avoiding hundreds of tiny TCP writes while preserving streaming payload generation.
- Treat allocation or streaming failure as a failed discovery publication, discard the unfinished payload, and reconnect before the next retained update.
- Allocate device-discovery component serializers only for sizing and emission, reducing peak heap use; make serializer allocation failure safe.
**Compatibility, tests, and documentation:**
- Add the reusable, Docker-based HA/MQTT testkit and run ArduinoHA migration and retained-restart checks against Home Assistant 2024.11.3, stable, and dev.
- Make the fixed baseline and current stable test-harness lanes release gates; keep the rolling dev lane scheduled/manual.
- Add maintained, credential-free PlatformIO ESP8266/ESP32 example projects and compile them in CI.
## 3.1.0
**Home Assistant MQTT compatibility:**
+24 -39
View File
@@ -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,38 +33,33 @@ 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
```ini
lib_deps =
home-assistant-integration=https://github.com/alexhopeoconnor/arduino-home-assistant.git#v3.1.0
home-assistant-integration=https://github.com/alexhopeoconnor/arduino-home-assistant.git#v3.2.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).
+7 -9
View File
@@ -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 |
+3 -4
View File
@@ -1,7 +1,7 @@
# Compatibility baseline
This maintenance line begins from fork commit `84cc0037b1c0` (release `v3.0.2`).
It intentionally tracks Home Assistant's MQTT discovery contract without merging
It intentionally tracks Home Assistant's MQTT discovery test harness without merging
upstream development wholesale.
| Reference | Audited revision / target |
@@ -10,7 +10,7 @@ upstream development wholesale.
| Upstream main | `1d333ab229b2` (`v2.1.0`) |
| Upstream develop | `a7039fad810b` (unreleased WIP 2.2.0) |
| Device discovery minimum | Home Assistant `2024.11.0` |
| Contract matrix | `2024.11.3`, current `stable`, current `dev` |
| Test harness matrix | `2024.11.3`, current `stable`, current `dev` |
The only imported upstream code fix is the four missing-device-ID guards from
upstream commit `9c9d074`. Device discovery migration, JSON validation,
@@ -37,5 +37,4 @@ git diff --stat main...upstream/develop
Port only independently reviewed changes with regression tests; treat open
upstream pull requests as proposals, not release inputs. Run the native,
board-compile, and HA contract gates before publishing a new release.
board-compile, and HA test harness gates before publishing a new release.
+1 -1
View File
@@ -11,7 +11,7 @@ ArduinoHA talks to Home Assistant over **MQTT** (TCP). You need an MQTT broker r
```ini
lib_deps =
home-assistant-integration=https://github.com/alexhopeoconnor/arduino-home-assistant.git#v3.1.0
home-assistant-integration=https://github.com/alexhopeoconnor/arduino-home-assistant.git#v3.2.0
```
**Arduino IDE:** this fork is not indexed by Library Manager. Download the
+2 -2
View File
@@ -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.:
+9
View File
@@ -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).
@@ -0,0 +1,37 @@
#pragma once
#if defined(ESP32)
#include <Network.h>
#include <WiFi.h>
#else
#include <ESP8266WiFi.h>
#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));
}
+24
View File
@@ -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
+30
View File
@@ -0,0 +1,30 @@
#include <Arduino.h>
#include <ArduinoHA.h>
#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<uint32_t>(millis() / 1000));
lastUpdate = millis();
}
}
+9
View File
@@ -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).
@@ -0,0 +1,37 @@
#pragma once
#if defined(ESP32)
#include <Network.h>
#include <WiFi.h>
#else
#include <ESP8266WiFi.h>
#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));
}
@@ -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
+36
View File
@@ -0,0 +1,36 @@
#include <Arduino.h>
#include <ArduinoHA.h>
#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();
}
@@ -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).
@@ -0,0 +1,37 @@
#pragma once
#if defined(ESP32)
#include <Network.h>
#include <WiFi.h>
#else
#include <ESP8266WiFi.h>
#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));
}
@@ -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
@@ -0,0 +1,48 @@
#include <Arduino.h>
#include <ArduinoHA.h>
#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<uint32_t>(millis() / 1000));
lastUpdate = millis();
}
}
+9
View File
@@ -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).
@@ -0,0 +1,37 @@
#pragma once
#if defined(ESP32)
#include <Network.h>
#include <WiFi.h>
#else
#include <ESP8266WiFi.h>
#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));
}
@@ -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
+33
View File
@@ -0,0 +1,33 @@
#include <Arduino.h>
#include <ArduinoHA.h>
#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<uint32_t>(millis() / 1000));
lastUpdate = millis();
}
}
+15 -12
View File
@@ -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).
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "home-assistant-integration",
"version": "3.1.0",
"version": "3.2.0",
"description": "Maintained Home Assistant MQTT discovery and entity integration for Arduino and ESP devices.",
"keywords": [
"mqtt",
+1 -1
View File
@@ -1,5 +1,5 @@
name=home-assistant-integration
version=3.1.0
version=3.2.0
author=Dawid Chyrzynski <dev@chyrzynski.pl>, Alex Hope-O'Connor <alex.hope.oconnor@pomonaqld.au>
maintainer=Alex Hope-O'Connor <alex.hope.oconnor@pomonaqld.au>
sentence=Home Assistant MQTT integration for Arduino
+10
View File
@@ -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"
+19 -5
View File
@@ -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" </dev/null
done
echo "ArduinoHA examples compile check passed for $environment"
;;
esac
+1 -1
View File
@@ -32,7 +32,7 @@
#endif
// Current library version used in discovery origin metadata.
#define ARDUINOHA_LIBRARY_VERSION "3.1.0"
#define ARDUINOHA_LIBRARY_VERSION "3.2.0"
#if defined(ARDUINOHA_DEBUG)
#include <Arduino.h>
+152 -85
View File
@@ -80,6 +80,8 @@ HAMqtt::HAMqtt(
uint8_t maxDevicesTypesNb
) :
_mqtt(pubSub),
_directPublishBufferLength(0),
_directPublishActive(false),
HAMQTT_INIT
{
_instance = this;
@@ -93,6 +95,8 @@ HAMqtt::HAMqtt(
) :
_mqttStorage(netClient),
_mqtt(&_mqttStorage),
_directPublishBufferLength(0),
_directPublishActive(false),
HAMQTT_INIT
{
_instance = this;
@@ -276,18 +280,6 @@ void HAMqtt::loop()
if (!result) {
_lastDisconnectReason = DiagnosticDisconnectReason::LoopReturnedFalse;
const uint32_t now = millis();
arduinoHALog(
ArduinoHALogLevel::Warn,
kMqtt,
String(F("loop returned false pubsub=")) + String(rawState) +
F(" deferred=") + String(_deferredCount) +
F(" depth=") + String(_messageDispatchDepth) +
F(" sinceLastMsgMs=") +
String(_lastMessageAt ? (now - _lastMessageAt) : static_cast<uint32_t>(0)) +
F(" sinceLastPubMs=") +
String(_lastPublishAt ? (now - _lastPublishAt) : static_cast<uint32_t>(0))
);
connectToServer();
}
@@ -415,36 +407,16 @@ bool HAMqtt::publish(const char* topic, const char* payload, bool retained)
);
}
const bool connBeforePub = isConnected();
const int psBeforePub = getPubSubState();
if (!_mqtt->beginPublish(topic, payloadLength, retained)) {
arduinoHALog(
ArduinoHALogLevel::Warn,
kMqtt,
String(F("beginPublish failed topic=")) + topic + F(" len=") + String(payloadLength) +
formatDirectPublishFailureDiagnostics(connBeforePub, psBeforePub)
);
if (!beginPublish(topic, payloadLength, retained)) {
return false;
}
const bool written = writePayload(
reinterpret_cast<const uint8_t*>(payload),
payloadLength
);
const bool connBeforeEnd = isConnected();
const int psBeforeEnd = getPubSubState();
const bool ended = _mqtt->endPublish();
const bool ok = written && ended;
if (!ok) {
arduinoHALog(
ArduinoHALogLevel::Warn,
kMqtt,
String(written ? F("endPublish failed topic=") : F("payload write failed topic=")) + topic +
formatDirectPublishFailureDiagnostics(connBeforeEnd, psBeforeEnd)
);
} else {
_lastPublishAt = millis();
}
return ok;
const bool ended = endPublish();
return written && ended;
}
bool HAMqtt::beginPublish(
@@ -465,6 +437,10 @@ bool HAMqtt::beginPublish(
}
if (!isProcessingMessage()) {
if (_directPublishActive) {
return false;
}
const bool connBefore = isConnected();
const int psBefore = getPubSubState();
const bool ok = _mqtt->beginPublish(topic, payloadLength, retained);
@@ -475,8 +451,12 @@ bool HAMqtt::beginPublish(
String(F("beginPublish failed topic=")) + topic + F(" len=") + String(payloadLength) +
formatDirectPublishFailureDiagnostics(connBefore, psBefore)
);
return false;
}
return ok;
clearDirectPublishBuffer();
_directPublishActive = true;
return true;
}
if (_deferredBuilder.active) {
@@ -530,6 +510,10 @@ bool HAMqtt::writePayload(const uint8_t* data, const uint16_t length)
return true;
}
if (_directPublishActive) {
return appendDirectPublishPayload(data, length);
}
return _mqtt->write(data, length) == length;
}
@@ -556,6 +540,10 @@ bool HAMqtt::writePayload(const __FlashStringHelper* src)
return true;
}
if (_directPublishActive) {
return appendDirectPublishProgmemPayload(src);
}
const uint16_t length = static_cast<uint16_t>(strlen_P(reinterpret_cast<PGM_P>(src)));
return _mqtt->print(src) == length;
}
@@ -563,9 +551,17 @@ bool HAMqtt::writePayload(const __FlashStringHelper* src)
bool HAMqtt::endPublish()
{
if (!isProcessingMessage()) {
if (!_directPublishActive) {
return false;
}
const bool payloadFlushed = flushDirectPublishBuffer();
const bool connBefore = isConnected();
const int psBefore = getPubSubState();
const bool ok = _mqtt->endPublish();
const bool ended = _mqtt->endPublish();
clearDirectPublishBuffer();
_directPublishActive = false;
const bool ok = payloadFlushed && ended;
if (ok) {
_lastPublishAt = millis();
} else {
@@ -603,6 +599,72 @@ bool HAMqtt::endPublish()
return ok;
}
bool HAMqtt::flushDirectPublishBuffer()
{
if (_directPublishBufferLength == 0) {
return true;
}
const uint16_t length = _directPublishBufferLength;
_directPublishBufferLength = 0;
return _mqtt->write(_directPublishBuffer, length) == length;
}
bool HAMqtt::appendDirectPublishPayload(const uint8_t* data, uint16_t length)
{
while (length > 0) {
if (_directPublishBufferLength == DirectPublishBufferSize &&
!flushDirectPublishBuffer()) {
return false;
}
const uint16_t available =
static_cast<uint16_t>(DirectPublishBufferSize - _directPublishBufferLength);
const uint16_t copied = length < available ? length : available;
memcpy(_directPublishBuffer + _directPublishBufferLength, data, copied);
_directPublishBufferLength = static_cast<uint16_t>(_directPublishBufferLength + copied);
data += copied;
length = static_cast<uint16_t>(length - copied);
}
return true;
}
bool HAMqtt::appendDirectPublishProgmemPayload(const __FlashStringHelper* src)
{
PGM_P data = reinterpret_cast<PGM_P>(src);
uint16_t remaining = static_cast<uint16_t>(strlen_P(data));
uint16_t offset = 0;
while (remaining > 0) {
if (_directPublishBufferLength == DirectPublishBufferSize &&
!flushDirectPublishBuffer()) {
return false;
}
const uint16_t available =
static_cast<uint16_t>(DirectPublishBufferSize - _directPublishBufferLength);
const uint16_t copied = remaining < available ? remaining : available;
memcpy_P(_directPublishBuffer + _directPublishBufferLength, data + offset, copied);
_directPublishBufferLength = static_cast<uint16_t>(_directPublishBufferLength + copied);
offset = static_cast<uint16_t>(offset + copied);
remaining = static_cast<uint16_t>(remaining - copied);
}
return true;
}
void HAMqtt::clearDirectPublishBuffer()
{
_directPublishBufferLength = 0;
}
void HAMqtt::abortDirectPublish()
{
clearDirectPublishBuffer();
_directPublishActive = false;
_mqtt->disconnect();
}
bool HAMqtt::subscribe(const char* topic)
{
arduinoHALog(ArduinoHALogLevel::Debug, kMqtt, String(F("subscribe ")) + (topic ? topic : ""));
@@ -945,6 +1007,10 @@ void HAMqtt::onConnectedLogic()
publishDeviceDiscovery();
}
if (!isConnected()) {
return;
}
if (_connectedCallback) {
_connectedCallback();
}
@@ -965,6 +1031,26 @@ bool HAMqtt::publishDeviceDiscovery()
return publishDeviceDiscoveryPayload();
}
HASerializer* HAMqtt::buildDeviceDiscoveryComponentSerializer(
HABaseDeviceType* deviceType,
HABaseDeviceType* removalType
)
{
if (deviceType != removalType) {
return deviceType->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(&quote, 1) &&
writePayload(componentId, strlen(componentId)) &&
writePayload(&quote, 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"));
}
+19
View File
@@ -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;
+9 -1
View File
@@ -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);
@@ -0,0 +1,18 @@
# HA/MQTT test harness testkit
This small Python package supplies generic test transport primitives for a
disposable Home Assistant + MQTT test harness environment:
- Home Assistant onboarding, MQTT config-entry setup, WebSocket registry access,
REST state/service helpers, and readiness waiting;
- retained MQTT publishing and discovery observation; and
- JSON/JSONL artifact output plus bounded retry helpers.
It deliberately contains no ArduinoHA discovery fixture, DeviceFramework
import, entity expectation, or release policy. ArduinoHA owns its migration
fixture in `tests/ha-test-harness`; DeviceFramework owns its hardware fixture and
Docker adapter in its own repository. Other projects can reuse this package by
providing their own retained MQTT messages and assertions.
The package is copied into a test container as a local build context and is not
part of the Arduino firmware library export.
@@ -0,0 +1,17 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "ha-mqtt-test-harness-testkit"
version = "0.1.0"
description = "Generic Home Assistant MQTT test-harness support"
requires-python = ">=3.11"
dependencies = [
"paho-mqtt==2.1.0",
"requests==2.34.2",
"websocket-client==1.9.2",
]
[tool.setuptools.packages.find]
where = ["src"]
@@ -0,0 +1,21 @@
"""Generic Home Assistant MQTT test harness-test support.
This package deliberately knows nothing about ArduinoHA, DeviceFramework, or
any particular discovery schema. Consumers own their fixtures and assertions;
the package owns only HA/MQTT transport, readiness, and artifact primitives.
"""
from .artifacts import write_json_artifact, write_json_lines
from .home_assistant import HomeAssistantClient
from .mqtt import MqttObserver, RetainedPublisher
from .wait import TestHarnessError, wait_until
__all__ = [
"TestHarnessError",
"HomeAssistantClient",
"MqttObserver",
"RetainedPublisher",
"wait_until",
"write_json_artifact",
"write_json_lines",
]
@@ -0,0 +1,30 @@
"""Small artifact writers which keep container tests independent of host tools."""
from __future__ import annotations
import json
import pathlib
from collections.abc import Iterable
from typing import Any
def _path(root: str | pathlib.Path, name: str) -> pathlib.Path:
path = pathlib.Path(root) / name
path.parent.mkdir(parents=True, exist_ok=True)
return path
def write_json_artifact(root: str | pathlib.Path, name: str, value: Any) -> pathlib.Path:
"""Write a deterministic UTF-8 JSON artifact and return its path."""
path = _path(root, name)
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
return path
def write_json_lines(root: str | pathlib.Path, name: str, values: Iterable[Any]) -> pathlib.Path:
"""Write a deterministic JSONL artifact and return its path."""
path = _path(root, name)
with path.open("w", encoding="utf-8") as output:
for value in values:
output.write(json.dumps(value, sort_keys=True) + "\n")
return path
@@ -0,0 +1,245 @@
"""Home Assistant onboarding, MQTT setup, registry, and service helpers."""
from __future__ import annotations
import json
import pathlib
import time
from typing import Any
import requests
import websocket
from .wait import TestHarnessError, wait_until
class HomeAssistantClient:
"""Authenticated HA API client with a deliberately small test harness surface."""
def __init__(self, base_url: str, token: str):
self.base_url = base_url.rstrip("/")
self.token = token
self._socket: websocket.WebSocket | None = None
self._next_id = 1
@property
def _headers(self) -> dict[str, str]:
return {"Authorization": f"Bearer {self.token}"}
@classmethod
def bootstrap(
cls,
base_url: str,
state_dir: str | pathlib.Path,
*,
owner_name: str = "HA MQTT Test Harness Owner",
username: str = "ha-mqtt-test-harness",
password: str = "ha-mqtt-test-harness-password",
) -> "HomeAssistantClient":
"""Create or load an isolated owner token for a disposable HA volume."""
base_url = base_url.rstrip("/")
state = pathlib.Path(state_dir)
token_file = state / "ha-token"
cls.wait_until_ready(base_url)
if token_file.exists():
return cls(base_url, token_file.read_text(encoding="utf-8").strip())
client_id = "http://ha-mqtt-test-harness.local/"
user = {
"client_id": client_id,
"name": owner_name,
"username": username,
"password": password,
"language": "en",
}
created = cls._response_json(
requests.post(f"{base_url}/api/onboarding/users", json=user, timeout=10),
"Home Assistant onboarding user creation",
)
auth_code = created.get("auth_code")
if not auth_code:
raise TestHarnessError("Home Assistant onboarding did not return an auth_code")
token_response = cls._response_json(
requests.post(
f"{base_url}/auth/token",
data={"client_id": client_id, "grant_type": "authorization_code", "code": auth_code},
timeout=10,
),
"Home Assistant token exchange",
)
token = token_response.get("access_token")
if not token:
raise TestHarnessError("Home Assistant token exchange did not return an access_token")
client = cls(base_url, token)
for path, payload in (
("/api/onboarding/core_config", {}),
("/api/onboarding/analytics", {"preferences": {}}),
):
response = requests.post(f"{base_url}{path}", headers=client._headers, json=payload, timeout=10)
if response.status_code not in (200, 201, 400, 404):
raise TestHarnessError(
f"Home Assistant onboarding step {path} failed: {response.status_code} {response.text}"
)
state.mkdir(parents=True, exist_ok=True)
token_file.write_text(token, encoding="utf-8")
return client
@classmethod
def from_state(cls, base_url: str, state_dir: str | pathlib.Path) -> "HomeAssistantClient":
token_file = pathlib.Path(state_dir) / "ha-token"
if not token_file.exists():
raise TestHarnessError("Home Assistant token has not been bootstrapped")
return cls(base_url, token_file.read_text(encoding="utf-8").strip())
@staticmethod
def _response_json(response: requests.Response, context: str) -> dict[str, Any]:
if not response.ok:
raise TestHarnessError(f"{context} failed ({response.status_code}): {response.text}")
try:
value = response.json()
except ValueError as error:
raise TestHarnessError(f"{context} returned invalid JSON: {error}") from error
if not isinstance(value, dict):
raise TestHarnessError(f"{context} returned unexpected JSON: {value}")
return value
@classmethod
def wait_until_ready(cls, base_url: str, *, timeout: float = 180) -> None:
def ready() -> bool:
response = requests.get(f"{base_url.rstrip('/')}/api/", timeout=5)
return response.status_code in (200, 401)
wait_until("Home Assistant HTTP API", ready, timeout=timeout)
def configure_mqtt(self, host: str, port: int) -> None:
"""Create HA's MQTT config entry, negotiating current/older form schemas."""
flow = self._response_json(
requests.post(
f"{self.base_url}/api/config/config_entries/flow",
headers=self._headers,
json={"handler": "mqtt"},
timeout=15,
),
"MQTT config-entry flow creation",
)
if flow.get("type") == "create_entry":
return
if flow.get("type") != "form" or not flow.get("flow_id"):
raise TestHarnessError(f"unexpected MQTT config-entry flow result: {flow}")
user_input: dict[str, Any] = {"broker": host, "port": port}
schema_names = {
field.get("name") for field in flow.get("data_schema", []) if isinstance(field, dict)
}
if "other_settings" in schema_names:
user_input["other_settings"] = {
"set_client_cert": False,
"set_ca_cert": "off",
"transport": "tcp",
}
configured = self._response_json(
requests.post(
f"{self.base_url}/api/config/config_entries/flow/{flow['flow_id']}",
headers=self._headers,
json=user_input,
timeout=15,
),
"MQTT config-entry flow configuration",
)
if configured.get("type") != "create_entry":
raise TestHarnessError(f"MQTT config-entry flow did not create an entry: {configured}")
time.sleep(3)
def _ensure_socket(self) -> websocket.WebSocket:
if self._socket is not None:
return self._socket
scheme = "wss" if self.base_url.startswith("https://") else "ws"
address = self.base_url.split("://", 1)[1]
socket = websocket.create_connection(f"{scheme}://{address}/api/websocket", timeout=15)
required = json.loads(socket.recv())
if required.get("type") != "auth_required":
socket.close()
raise TestHarnessError(f"unexpected Home Assistant WebSocket greeting: {required}")
socket.send(json.dumps({"type": "auth", "access_token": self.token}))
authenticated = json.loads(socket.recv())
if authenticated.get("type") != "auth_ok":
socket.close()
raise TestHarnessError(f"Home Assistant WebSocket authentication failed: {authenticated}")
self._socket = socket
return socket
def close(self) -> None:
if self._socket is not None:
self._socket.close()
self._socket = None
def call(self, message_type: str, **kwargs: Any) -> Any:
socket = self._ensure_socket()
message_id = self._next_id
self._next_id += 1
socket.send(json.dumps({"id": message_id, "type": message_type, **kwargs}))
while True:
result = json.loads(socket.recv())
if result.get("id") != message_id:
continue
if not result.get("success"):
raise TestHarnessError(f"WebSocket {message_type} failed: {result}")
return result.get("result")
def entity_registry(self) -> list[dict[str, Any]]:
result = self.call("config/entity_registry/list")
if not isinstance(result, list):
raise TestHarnessError(f"entity registry returned unexpected value: {result}")
return result
# A concise compatibility spelling for test harness suites; this remains schema-neutral.
def registry_entries(self) -> list[dict[str, Any]]:
return self.entity_registry()
def device_registry(self) -> list[dict[str, Any]]:
result = self.call("config/device_registry/list")
if not isinstance(result, list):
raise TestHarnessError(f"device registry returned unexpected value: {result}")
return result
def entities_for_device(self, device_id: str) -> list[dict[str, Any]]:
return [entry for entry in self.entity_registry() if entry.get("device_id") == device_id]
def wait_for_entity(self, unique_id: str, *, timeout: float = 60) -> dict[str, Any]:
def find() -> dict[str, Any] | None:
matches = [entry for entry in self.entity_registry() if entry.get("unique_id") == unique_id]
if len(matches) > 1:
raise TestHarnessError(f"duplicate entity-registry entries for {unique_id}: {matches}")
return matches[0] if matches else None
return wait_until(f"entity registry entry {unique_id}", find, timeout=timeout)
def state(self, entity_id: str) -> dict[str, Any] | None:
response = requests.get(f"{self.base_url}/api/states/{entity_id}", headers=self._headers, timeout=10)
if response.status_code == 404:
return None
return self._response_json(response, f"state lookup for {entity_id}")
def wait_for_state(self, entity_id: str, state: str, *, timeout: float = 60) -> dict[str, Any]:
return wait_until(
f"Home Assistant state {entity_id}={state}",
lambda: value if (value := self.state(entity_id)) and value.get("state") == state else None,
timeout=timeout,
)
def call_service(self, domain: str, service: str, data: dict[str, Any]) -> list[dict[str, Any]]:
response = requests.post(
f"{self.base_url}/api/services/{domain}/{service}",
headers=self._headers,
json=data,
timeout=15,
)
if not response.ok:
raise TestHarnessError(f"service {domain}.{service} failed ({response.status_code}): {response.text}")
try:
result = response.json()
except ValueError as error:
raise TestHarnessError(f"service {domain}.{service} returned invalid JSON: {error}") from error
if not isinstance(result, list):
raise TestHarnessError(f"service {domain}.{service} returned unexpected JSON: {result}")
return result
@@ -0,0 +1,122 @@
"""MQTT publishing and observation helpers for retained-discovery test harnesses."""
from __future__ import annotations
import threading
import time
import uuid
from collections.abc import Iterable
from typing import Any
import paho.mqtt.client as mqtt
from .wait import TestHarnessError, wait_until
class _MqttClient:
def __init__(self, host: str, port: int, *, client_prefix: str):
self._connected = threading.Event()
self._client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2,
client_id=f"{client_prefix}-{uuid.uuid4()}",
)
self._client.on_connect = self._on_connect
self._client.connect(host, port, keepalive=15)
self._client.loop_start()
if not self._connected.wait(timeout=15):
self.close()
raise TestHarnessError(f"MQTT client {client_prefix} did not connect within 15 seconds")
def _on_connect(self, _client: mqtt.Client, _userdata: Any, _flags: Any, reason_code: Any, _properties: Any) -> None:
if reason_code == 0:
self._connected.set()
def close(self) -> None:
self._client.loop_stop()
self._client.disconnect()
class RetainedPublisher(_MqttClient):
"""A QoS-1 retained publisher used by schema/migration fixtures."""
def __init__(self, host: str, port: int):
super().__init__(host, port, client_prefix="test-harness-publisher")
def publish(self, topic: str, payload: str, *, retain: bool = True) -> None:
info = self._client.publish(topic, payload, qos=1, retain=retain)
info.wait_for_publish(timeout=10)
if not info.is_published():
raise TestHarnessError(f"MQTT publish timed out for {topic}")
def retained_payload(self, topic: str, *, timeout: float = 30) -> str:
received: list[str] = []
def on_message(_client: mqtt.Client, _userdata: Any, message: mqtt.MQTTMessage) -> None:
if message.topic == topic:
received.append(message.payload.decode("utf-8"))
self._client.on_message = on_message
self._client.subscribe(topic, qos=1)
try:
return wait_until(
f"retained MQTT message {topic}",
lambda: received[0] if received else None,
timeout=timeout,
)
finally:
self._client.unsubscribe(topic)
self._client.on_message = None
class MqttObserver(_MqttClient):
"""Observe MQTT traffic without imposing a discovery-schema interpretation."""
def __init__(self, host: str, port: int, topics: Iterable[str] = ("homeassistant/#",)):
self._messages: list[dict[str, Any]] = []
self._lock = threading.Lock()
self._subscription_ack = threading.Event()
super().__init__(host, port, client_prefix="test-harness-observer")
self._client.on_message = self._on_message
self._client.on_subscribe = self._on_subscribe
for topic in topics:
result, _mid = self._client.subscribe(topic, qos=1)
if result != mqtt.MQTT_ERR_SUCCESS:
self.close()
raise TestHarnessError(f"unable to subscribe to MQTT topic {topic}")
if not self._subscription_ack.wait(timeout=15):
self.close()
raise TestHarnessError(f"MQTT subscription to {topic} was not acknowledged within 15 seconds")
self._subscription_ack.clear()
def _on_subscribe(
self,
_client: mqtt.Client,
_userdata: Any,
_mid: int,
_reason_codes: Any,
_properties: Any,
) -> None:
self._subscription_ack.set()
def _on_message(self, _client: mqtt.Client, _userdata: Any, message: mqtt.MQTTMessage) -> None:
with self._lock:
self._messages.append(
{
"topic": message.topic,
"payload": message.payload.decode("utf-8", errors="replace"),
"qos": message.qos,
"retain": message.retain,
"received_at": time.time(),
}
)
def messages(self) -> list[dict[str, Any]]:
with self._lock:
return list(self._messages)
def wait_for_topic(self, topic: str, *, timeout: float = 60) -> dict[str, Any]:
return wait_until(
f"MQTT topic {topic}",
lambda: next((entry for entry in reversed(self.messages()) if entry["topic"] == topic), None),
timeout=timeout,
)
@@ -0,0 +1,36 @@
"""Bounded wait helpers shared by integration test harnesses."""
from __future__ import annotations
import time
from collections.abc import Callable
from typing import TypeVar
class TestHarnessError(RuntimeError):
"""An external Home Assistant or MQTT test harness could not be satisfied."""
T = TypeVar("T")
def wait_until(
description: str,
predicate: Callable[[], T | None | bool],
*,
timeout: float = 90,
interval: float = 1,
) -> T:
"""Return the first truthy predicate result or raise a useful timeout."""
deadline = time.monotonic() + timeout
last_error: Exception | None = None
while time.monotonic() < deadline:
try:
result = predicate()
if result:
return result # type: ignore[return-value]
except Exception as error: # Services are expected to be starting.
last_error = error
time.sleep(interval)
suffix = f" (last error: {last_error})" if last_error else ""
raise TestHarnessError(f"timed out waiting for {description}{suffix}")
-8
View File
@@ -1,8 +0,0 @@
FROM python:3.12-slim
WORKDIR /tests
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY test_contract.py .
CMD ["python", "test_contract.py"]
-441
View File
@@ -1,441 +0,0 @@
"""Home Assistant MQTT discovery contract checks.
The test intentionally uses retained MQTT messages, like a deployed firmware
node. It inspects HA's entity registry over the authenticated WebSocket API so
the migration assertion is about HA's persistent identity, not just payload
shape.
"""
import json
import os
import pathlib
import time
import uuid
import paho.mqtt.client as mqtt
import requests
import websocket
HA_URL = os.environ.get("HA_URL", "http://homeassistant:8123").rstrip("/")
MQTT_HOST = os.environ.get("MQTT_HOST", "mqtt")
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
MODE = os.environ.get("CONTRACT_MODE", "migration")
STATE = pathlib.Path("/state")
TOKEN_FILE = STATE / "ha-token"
DEVICE_ID = "contract_device"
class ContractFailure(RuntimeError):
pass
def fail(message):
raise ContractFailure(message)
def wait_until(description, predicate, timeout=90, interval=1):
deadline = time.monotonic() + timeout
last_error = None
while time.monotonic() < deadline:
try:
result = predicate()
if result:
return result
except Exception as error: # HA is expected to be starting initially.
last_error = error
time.sleep(interval)
suffix = f" (last error: {last_error})" if last_error else ""
fail(f"timed out waiting for {description}{suffix}")
def wait_for_home_assistant():
def ready():
response = requests.get(f"{HA_URL}/api/", timeout=5)
return response.status_code in (200, 401)
wait_until("Home Assistant HTTP API", ready, timeout=180)
def response_json(response, context):
if not response.ok:
fail(f"{context} failed ({response.status_code}): {response.text}")
try:
return response.json()
except ValueError as error:
fail(f"{context} returned invalid JSON: {error}")
def onboarding_token():
"""Create an ephemeral owner token once and share it across restart checks."""
if TOKEN_FILE.exists():
return TOKEN_FILE.read_text(encoding="utf-8").strip()
client_id = "http://contract-tests.local/"
user = {
"client_id": client_id,
"name": "Contract Test Owner",
"username": "contract-owner",
"password": "contract-test-password",
"language": "en",
}
response = requests.post(f"{HA_URL}/api/onboarding/users", json=user, timeout=10)
created = response_json(response, "Home Assistant onboarding user creation")
auth_code = created.get("auth_code")
if not auth_code:
fail("Home Assistant onboarding did not return an auth_code")
token_response = requests.post(
f"{HA_URL}/auth/token",
data={
"client_id": client_id,
"grant_type": "authorization_code",
"code": auth_code,
},
timeout=10,
)
token = response_json(token_response, "Home Assistant token exchange").get("access_token")
if not token:
fail("Home Assistant token exchange did not return an access_token")
headers = {"Authorization": f"Bearer {token}"}
# These operations are idempotent across HA versions that still expose them.
for path, payload in (
("/api/onboarding/core_config", {}),
("/api/onboarding/analytics", {"preferences": {}}),
):
response = requests.post(f"{HA_URL}{path}", headers=headers, json=payload, timeout=10)
if response.status_code not in (200, 201, 400, 404):
fail(f"Home Assistant onboarding step {path} failed: {response.status_code} {response.text}")
STATE.mkdir(parents=True, exist_ok=True)
TOKEN_FILE.write_text(token, encoding="utf-8")
return token
class HAWebSocket:
def __init__(self, token):
scheme = "wss" if HA_URL.startswith("https://") else "ws"
address = HA_URL.split("://", 1)[1]
self._socket = websocket.create_connection(f"{scheme}://{address}/api/websocket", timeout=15)
required = json.loads(self._socket.recv())
if required.get("type") != "auth_required":
fail(f"unexpected Home Assistant WebSocket greeting: {required}")
self._socket.send(json.dumps({"type": "auth", "access_token": token}))
authenticated = json.loads(self._socket.recv())
if authenticated.get("type") != "auth_ok":
fail(f"Home Assistant WebSocket authentication failed: {authenticated}")
self._next_id = 1
def close(self):
self._socket.close()
def call(self, message_type, **kwargs):
message_id = self._next_id
self._next_id += 1
self._socket.send(json.dumps({"id": message_id, "type": message_type, **kwargs}))
while True:
result = json.loads(self._socket.recv())
if result.get("id") != message_id:
continue
if not result.get("success"):
fail(f"WebSocket {message_type} failed: {result}")
return result.get("result")
def registry_entries(self):
return self.call("config/entity_registry/list")
def configure_mqtt_integration(token):
"""Create HA's MQTT config entry; broker YAML options are no longer accepted."""
headers = {"Authorization": f"Bearer {token}"}
response = requests.post(
f"{HA_URL}/api/config/config_entries/flow",
headers=headers,
json={"handler": "mqtt"},
timeout=15,
)
flow = response_json(response, "MQTT config-entry flow creation")
if flow.get("type") == "create_entry":
return
if flow.get("type") != "form" or not flow.get("flow_id"):
fail(f"unexpected MQTT config-entry flow result: {flow}")
user_input = {"broker": MQTT_HOST, "port": MQTT_PORT}
# Newer HA versions group TLS/transport values in a required section.
# Older supported versions do not expose the section and must not receive
# unknown fields, so negotiate from the returned data schema.
schema_names = {
field.get("name")
for field in flow.get("data_schema", [])
if isinstance(field, dict)
}
if "other_settings" in schema_names:
user_input["other_settings"] = {
"set_client_cert": False,
"set_ca_cert": "off",
"transport": "tcp",
}
response = requests.post(
f"{HA_URL}/api/config/config_entries/flow/{flow['flow_id']}",
headers=headers,
json=user_input,
timeout=15,
)
configured = response_json(response, "MQTT config-entry flow configuration")
if configured.get("type") != "create_entry":
fail(f"MQTT config-entry flow did not create an entry: {configured}")
# The successful flow result is returned before the integration has had a
# chance to subscribe to retained discovery topics.
time.sleep(3)
class RetainedPublisher:
def __init__(self):
self._client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"contract-{uuid.uuid4()}")
self._client.connect(MQTT_HOST, MQTT_PORT, keepalive=15)
self._client.loop_start()
def close(self):
self._client.loop_stop()
self._client.disconnect()
def publish(self, topic, payload):
info = self._client.publish(topic, payload, qos=1, retain=True)
info.wait_for_publish(timeout=10)
if not info.is_published():
fail(f"MQTT publish timed out for {topic}")
def retained_payload(self, topic):
received = []
def on_message(_client, _userdata, message):
if message.topic == topic:
received.append(message.payload.decode("utf-8"))
self._client.on_message = on_message
self._client.subscribe(topic, qos=1)
value = wait_until(f"retained MQTT message {topic}", lambda: received[0] if received else None)
self._client.unsubscribe(topic)
self._client.on_message = None
return value
def legacy_topic(object_id):
return f"homeassistant/sensor/{DEVICE_ID}/{object_id}/config"
def device_topic():
return f"homeassistant/device/{DEVICE_ID}/config"
def component(object_id, unique_id=None, **extra):
payload = {
"p": "sensor",
"name": object_id.replace("_", " ").title(),
"uniq_id": unique_id or f"{DEVICE_ID}_{object_id}",
"stat_t": f"contract/{object_id}/state",
}
payload.update(extra)
return payload
def device_payload(components):
return {
"dev": {"ids": [DEVICE_ID], "name": "ArduinoHA contract device"},
"o": {"name": "ArduinoHA", "sw": "3.0.2"},
"cmps": components,
}
def legacy_payload(object_id, unique_id=None, **extra):
payload = component(object_id, unique_id, **extra)
payload["dev"] = {"ids": [DEVICE_ID], "name": "ArduinoHA contract device"}
payload.pop("p")
return payload
def find_unique(entries, unique_id):
matches = [entry for entry in entries if entry.get("unique_id") == unique_id]
if len(matches) > 1:
fail(f"duplicate entity-registry entries for {unique_id}: {matches}")
return matches[0] if matches else None
def wait_for_entry(ws, unique_id):
return wait_until(
f"entity registry entry {unique_id}",
lambda: find_unique(ws.registry_entries(), unique_id),
timeout=60,
)
def migration_contract():
publisher = RetainedPublisher()
token = onboarding_token()
configure_mqtt_integration(token)
ws = HAWebSocket(token)
try:
# Existing single-component entity and a user-owned registry customization.
unique = f"{DEVICE_ID}_temperature"
publisher.publish(legacy_topic("temperature"), json.dumps(legacy_payload("temperature", unique)))
publisher.publish("contract/temperature/state", "21.5")
original = wait_for_entry(ws, unique)
original_id = original["id"]
renamed_entity_id = "sensor.contract_temperature_user_name"
ws.call(
"config/entity_registry/update",
entity_id=original["entity_id"],
new_entity_id=renamed_entity_id,
disabled_by="user",
)
# HA's required sequence: marker, device payload, then retained cleanup.
publisher.publish(legacy_topic("temperature"), '{"migrate_discovery":true}')
publisher.publish(device_topic(), json.dumps(device_payload({"temperature": component("temperature", unique)})))
publisher.publish(legacy_topic("temperature"), "")
migrated = wait_for_entry(ws, unique)
if migrated["id"] != original_id:
fail("single-to-device migration changed the entity registry ID")
if migrated["entity_id"] != renamed_entity_id or migrated.get("disabled_by") != "user":
fail(f"migration did not preserve user registry settings: {migrated}")
if find_unique(ws.registry_entries(), unique) is None:
fail("migrated entity disappeared from the entity registry")
# Reverse migration is also ordered: marker the device topic, restore
# legacy discovery, then clear the device topic. This is the protocol
# HA documents for preserving the existing registry entry.
publisher.publish(device_topic(), '{"migrate_discovery":true}')
publisher.publish(legacy_topic("temperature"), json.dumps(legacy_payload("temperature", unique)))
publisher.publish(device_topic(), "")
rolled_back = wait_for_entry(ws, unique)
if rolled_back["id"] != original_id:
fail("device-to-single rollback changed the entity registry ID")
if rolled_back["entity_id"] != renamed_entity_id or rolled_back.get("disabled_by") != "user":
fail(f"rollback did not preserve user registry settings: {rolled_back}")
# Restore device discovery so the retained-restart check continues to
# exercise the forward migration form used by deployed firmware.
publisher.publish(legacy_topic("temperature"), '{"migrate_discovery":true}')
publisher.publish(device_topic(), json.dumps(device_payload({"temperature": component("temperature", unique)})))
publisher.publish(legacy_topic("temperature"), "")
remigrated = wait_for_entry(ws, unique)
if remigrated["id"] != original_id:
fail("repeat single-to-device migration changed the entity registry ID")
# Direct publication is deliberately not a migration protocol. It must not
# create a second registry entry for the same stable unique ID.
direct_unique = f"{DEVICE_ID}_direct"
publisher.publish(legacy_topic("direct"), json.dumps(legacy_payload("direct", direct_unique)))
direct_entry = wait_for_entry(ws, direct_unique)
publisher.publish(device_topic(), json.dumps(device_payload({"direct": component("direct", direct_unique)})))
time.sleep(2)
entries = [entry for entry in ws.registry_entries() if entry.get("unique_id") == direct_unique]
if len(entries) != 1 or entries[0]["id"] != direct_entry["id"]:
fail("direct device discovery publish created a duplicate registry entity")
# Device-mode removal is two root updates: platform tombstone then omission.
removable_unique = f"{DEVICE_ID}_removable"
publisher.publish(
device_topic(),
json.dumps(device_payload({
"anchor": component("anchor"),
"removable": component("removable", removable_unique),
})),
)
wait_for_entry(ws, removable_unique)
publisher.publish(
device_topic(),
json.dumps(device_payload({
"anchor": component("anchor"),
"removable": {"p": "sensor"},
})),
)
publisher.publish(device_topic(), json.dumps(device_payload({"anchor": component("anchor")})))
# HA 2026.5 fixed cleanup for discovered entities that start disabled.
# Exercise the same device-component tombstone/omission sequence so a
# future regression is caught by the stable/dev contract matrix.
disabled_unique = f"{DEVICE_ID}_disabled"
publisher.publish(
device_topic(),
json.dumps(device_payload({
"anchor": component("anchor"),
"disabled": component(
"disabled", disabled_unique, enabled_by_default=False
),
})),
)
disabled_entry = wait_for_entry(ws, disabled_unique)
if disabled_entry.get("disabled_by") != "integration":
fail(f"expected initially disabled entity to be integration-disabled: {disabled_entry}")
publisher.publish(
device_topic(),
json.dumps(device_payload({
"anchor": component("anchor"),
"disabled": {"p": "sensor"},
})),
)
publisher.publish(device_topic(), json.dumps(device_payload({"anchor": component("anchor")})))
wait_until(
"disabled device component cleanup",
lambda: find_unique(ws.registry_entries(), disabled_unique) is None,
timeout=60,
)
# Invalid retained discovery JSON never creates a registry entry. Escaped
# strings are covered by the firmware-native serializer tests.
publisher.publish(legacy_topic("malformed"), "{not-json")
time.sleep(2)
if find_unique(ws.registry_entries(), f"{DEVICE_ID}_malformed"):
fail("malformed discovery payload created an entity")
# This fixture documents the current HA field contract: def_ent_id is
# allowed on first creation; obsolete obj_id is intentionally absent.
default_payload = legacy_payload("default_name", def_ent_id="contract_default_name")
if "obj_id" in default_payload:
fail("contract fixture accidentally contains obsolete obj_id")
publisher.publish(legacy_topic("default_name"), json.dumps(default_payload))
wait_for_entry(ws, f"{DEVICE_ID}_default_name")
STATE.mkdir(parents=True, exist_ok=True)
(STATE / "migration-complete").write_text("ok", encoding="utf-8")
finally:
ws.close()
publisher.close()
def retained_restart_contract():
if not (STATE / "migration-complete").exists():
fail("retained-restart mode requires the migration contract to run first")
publisher = RetainedPublisher()
token = onboarding_token()
ws = HAWebSocket(token)
try:
migrated = wait_for_entry(ws, f"{DEVICE_ID}_temperature")
if migrated["entity_id"] != "sensor.contract_temperature_user_name":
fail("HA restart lost the user-owned entity rename")
retained = json.loads(publisher.retained_payload(device_topic()))
if "cmps" not in retained:
fail("broker restart check did not receive retained device discovery")
finally:
ws.close()
publisher.close()
def main():
wait_for_home_assistant()
if MODE == "migration":
migration_contract()
elif MODE == "retained-restart":
retained_restart_contract()
else:
fail(f"unknown CONTRACT_MODE: {MODE}")
print(f"Home Assistant MQTT contract mode {MODE} passed")
if __name__ == "__main__":
main()
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /tests
COPY test-support/ha-mqtt-test-harness /opt/ha-mqtt-test-harness
COPY tests/ha-test-harness/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir /opt/ha-mqtt-test-harness
COPY tests/ha-test-harness/test_harness.py .
CMD ["python", "test_harness.py"]
@@ -1,4 +1,4 @@
# Home Assistant MQTT contract tests
# Home Assistant MQTT test harness tests
This harness runs retained MQTT discovery messages through Mosquitto and a real
Home Assistant container. It verifies the behavior that firmware unit tests
@@ -23,23 +23,35 @@ device-component tombstone/omission sequence, and makes the current
From the repository root:
```bash
HA_VERSION=2024.11.3 docker compose -f tests/ha-contract/compose.yaml up -d mqtt homeassistant
HA_VERSION=2024.11.3 docker compose -f tests/ha-contract/compose.yaml run --rm tests
HA_VERSION=2024.11.3 docker compose -f tests/ha-contract/compose.yaml restart homeassistant
CONTRACT_MODE=retained-restart HA_VERSION=2024.11.3 docker compose -f tests/ha-contract/compose.yaml run --rm tests
HA_VERSION=2024.11.3 docker compose -f tests/ha-contract/compose.yaml down -v
HA_VERSION=2024.11.3 docker compose -f tests/ha-test-harness/compose.yaml up -d mqtt homeassistant
HA_VERSION=2024.11.3 docker compose -f tests/ha-test-harness/compose.yaml run --rm tests
HA_VERSION=2024.11.3 docker compose -f tests/ha-test-harness/compose.yaml restart homeassistant
TEST_HARNESS_MODE=retained-restart HA_VERSION=2024.11.3 docker compose -f tests/ha-test-harness/compose.yaml run --rm tests
HA_VERSION=2024.11.3 docker compose -f tests/ha-test-harness/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 `TEST_HARNESS_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.
## Shared support
The schema-neutral [HA/MQTT test harness testkit](../../test-support/ha-mqtt-test-harness/README.md) owns only Docker-side Home Assistant onboarding, MQTT transport, registry/service access, retries, and artifact helpers. It has no ArduinoHA discovery assertions. This suite owns ArduinoHA migration fixtures; DeviceFramework carries its own fixtures and hardware adapter while reusing the testkit.
## Scope
The firmware's native Unity suite covers JSON escaping, invalid topic tokens,
serializer preflight, migration ordering, component removal, and lifecycle
behavior. This container suite covers Home Assistant's persistence contract. It
behavior. This container suite covers Home Assistant's persistence test harness. It
does not need a physical board: fixture discovery documents mirror retained
payloads emitted by ArduinoHA and isolate Home Assistant/MQTT compatibility.
@@ -23,14 +23,16 @@ services:
tests:
build:
context: .
context: ../..
dockerfile: tests/ha-test-harness/Dockerfile
environment:
HA_URL: http://homeassistant:8123
MQTT_HOST: mqtt
MQTT_PORT: "1883"
CONTRACT_MODE: ${CONTRACT_MODE:-migration}
TEST_HARNESS_MODE: ${TEST_HARNESS_MODE:-migration}
TEST_HARNESS_EXPECT_DISABLED_CLEANUP: ${TEST_HARNESS_EXPECT_DISABLED_CLEANUP:-0}
volumes:
- contract_state:/state
- test_harness_state:/state
depends_on:
mqtt:
condition: service_healthy
@@ -39,5 +41,4 @@ services:
volumes:
ha_config:
contract_state:
test_harness_state:
@@ -1,3 +1,2 @@
default_config:
@@ -3,4 +3,3 @@ persistence_location /mosquitto/data/
allow_anonymous true
listener 1883
log_type all
@@ -1,4 +1,3 @@
paho-mqtt==2.1.0
requests==2.32.3
websocket-client==1.8.0
+280
View File
@@ -0,0 +1,280 @@
"""Home Assistant MQTT discovery test harness checks.
The test intentionally uses retained MQTT messages, like a deployed firmware
node. It inspects HA's entity registry over the authenticated WebSocket API so
the migration assertion is about HA's persistent identity, not just payload
shape.
"""
import json
import os
import pathlib
import time
from ha_mqtt_test_harness import (
TestHarnessError,
HomeAssistantClient,
RetainedPublisher as SharedRetainedPublisher,
wait_until,
)
HA_URL = os.environ.get("HA_URL", "http://homeassistant:8123").rstrip("/")
MQTT_HOST = os.environ.get("MQTT_HOST", "mqtt")
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
MODE = os.environ.get("TEST_HARNESS_MODE", "migration")
STATE = pathlib.Path("/state")
TOKEN_FILE = STATE / "ha-token"
DEVICE_ID = "test_harness_device"
EDGE_DEVICE_ID = "test_harness_edge_device"
EXPECT_DISABLED_CLEANUP = os.environ.get("TEST_HARNESS_EXPECT_DISABLED_CLEANUP") == "1"
def fail(message):
raise TestHarnessError(message)
def legacy_topic(object_id, device_id=DEVICE_ID):
return f"homeassistant/sensor/{device_id}/{object_id}/config"
def device_topic(device_id=DEVICE_ID):
return f"homeassistant/device/{device_id}/config"
def component(object_id, unique_id=None, device_id=DEVICE_ID, **extra):
payload = {
"p": "sensor",
"name": object_id.replace("_", " ").title(),
"uniq_id": unique_id or f"{device_id}_{object_id}",
"stat_t": f"test-harness/{object_id}/state",
}
payload.update(extra)
return payload
def device_payload(components, device_id=DEVICE_ID):
return {
"dev": {"ids": [device_id], "name": "ArduinoHA test harness device"},
"o": {"name": "ArduinoHA", "sw": "test harness"},
"cmps": components,
}
def legacy_payload(object_id, unique_id=None, device_id=DEVICE_ID, **extra):
payload = component(object_id, unique_id, **extra)
payload["dev"] = {"ids": [device_id], "name": "ArduinoHA test harness device"}
payload.pop("p")
return payload
def find_unique(entries, unique_id):
matches = [entry for entry in entries if entry.get("unique_id") == unique_id]
if len(matches) > 1:
fail(f"duplicate entity-registry entries for {unique_id}: {matches}")
return matches[0] if matches else None
def wait_for_entry(ws, unique_id):
return wait_until(
f"entity registry entry {unique_id}",
lambda: find_unique(ws.registry_entries(), unique_id),
timeout=60,
)
def migration_test_harness():
publisher = SharedRetainedPublisher(MQTT_HOST, MQTT_PORT)
ws = HomeAssistantClient.bootstrap(
HA_URL,
STATE,
owner_name="ArduinoHA Test Harness Owner",
username="arduinoha-test-harness",
password="arduinoha-test-harness-password",
)
ws.configure_mqtt(MQTT_HOST, MQTT_PORT)
try:
# Existing single-component entity and a user-owned registry customization.
unique = f"{DEVICE_ID}_temperature"
publisher.publish(legacy_topic("temperature"), json.dumps(legacy_payload("temperature", unique)))
publisher.publish("test-harness/temperature/state", "21.5")
original = wait_for_entry(ws, unique)
original_id = original["id"]
renamed_entity_id = "sensor.test_harness_temperature_user_name"
ws.call(
"config/entity_registry/update",
entity_id=original["entity_id"],
new_entity_id=renamed_entity_id,
disabled_by="user",
)
# HA's required sequence: marker, device payload, then retained cleanup.
publisher.publish(legacy_topic("temperature"), '{"migrate_discovery":true}')
publisher.publish(device_topic(), json.dumps(device_payload({"temperature": component("temperature", unique)})))
publisher.publish(legacy_topic("temperature"), "")
migrated = wait_for_entry(ws, unique)
if migrated["id"] != original_id:
fail("single-to-device migration changed the entity registry ID")
if migrated["entity_id"] != renamed_entity_id or migrated.get("disabled_by") != "user":
fail(f"migration did not preserve user registry settings: {migrated}")
if find_unique(ws.registry_entries(), unique) is None:
fail("migrated entity disappeared from the entity registry")
# Reverse migration is also ordered: marker the device topic, restore
# legacy discovery, then clear the device topic. This is the protocol
# HA documents for preserving the existing registry entry.
publisher.publish(device_topic(), '{"migrate_discovery":true}')
publisher.publish(legacy_topic("temperature"), json.dumps(legacy_payload("temperature", unique)))
publisher.publish(device_topic(), "")
rolled_back = wait_for_entry(ws, unique)
if rolled_back["id"] != original_id:
fail("device-to-single rollback changed the entity registry ID")
if rolled_back["entity_id"] != renamed_entity_id or rolled_back.get("disabled_by") != "user":
fail(f"rollback did not preserve user registry settings: {rolled_back}")
# Restore device discovery so the retained-restart check continues to
# exercise the forward migration form used by deployed firmware.
publisher.publish(legacy_topic("temperature"), '{"migrate_discovery":true}')
publisher.publish(device_topic(), json.dumps(device_payload({"temperature": component("temperature", unique)})))
publisher.publish(legacy_topic("temperature"), "")
remigrated = wait_for_entry(ws, unique)
if remigrated["id"] != original_id:
fail("repeat single-to-device migration changed the entity registry ID")
retained = json.loads(publisher.retained_payload(device_topic()))
if "temperature" not in retained.get("cmps", {}):
fail("migration did not retain the primary device discovery payload")
# Direct publication is deliberately not a migration protocol. It must not
# create a second registry entry for the same stable unique ID. Keep it
# on a separate device topic so it cannot invalidate the primary
# retained-payload/restart test harness.
direct_unique = f"{EDGE_DEVICE_ID}_direct"
publisher.publish(
legacy_topic("direct", EDGE_DEVICE_ID),
json.dumps(legacy_payload("direct", direct_unique, device_id=EDGE_DEVICE_ID)),
)
direct_entry = wait_for_entry(ws, direct_unique)
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload(
{"direct": component("direct", direct_unique)}, device_id=EDGE_DEVICE_ID
)),
)
time.sleep(2)
entries = [entry for entry in ws.registry_entries() if entry.get("unique_id") == direct_unique]
if len(entries) != 1 or entries[0]["id"] != direct_entry["id"]:
fail("direct device discovery publish created a duplicate registry entity")
# Device-mode removal is two root updates: platform tombstone then
# omission. This uses the edge fixture so retained-restart behavior is
# independently asserted on the migrated primary fixture.
removable_unique = f"{EDGE_DEVICE_ID}_removable"
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({
"anchor": component("anchor", device_id=EDGE_DEVICE_ID),
"removable": component("removable", removable_unique),
}, device_id=EDGE_DEVICE_ID)),
)
wait_for_entry(ws, removable_unique)
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({
"anchor": component("anchor", device_id=EDGE_DEVICE_ID),
"removable": {"p": "sensor"},
}, device_id=EDGE_DEVICE_ID)),
)
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({"anchor": component("anchor", device_id=EDGE_DEVICE_ID)}, device_id=EDGE_DEVICE_ID)),
)
# Older Home Assistant versions do not clean initially disabled device
# components. Keep that capability assertion in the development lane,
# where it detects regressions without making the supported baseline
# falsely fail.
if EXPECT_DISABLED_CLEANUP:
disabled_unique = f"{EDGE_DEVICE_ID}_disabled"
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({
"anchor": component("anchor", device_id=EDGE_DEVICE_ID),
"disabled": component(
"disabled", disabled_unique, enabled_by_default=False
),
}, device_id=EDGE_DEVICE_ID)),
)
disabled_entry = wait_for_entry(ws, disabled_unique)
if disabled_entry.get("disabled_by") != "integration":
fail(f"expected initially disabled entity to be integration-disabled: {disabled_entry}")
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({
"anchor": component("anchor", device_id=EDGE_DEVICE_ID),
"disabled": {"p": "sensor"},
}, device_id=EDGE_DEVICE_ID)),
)
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({"anchor": component("anchor", device_id=EDGE_DEVICE_ID)}, device_id=EDGE_DEVICE_ID)),
)
wait_until(
"disabled device component cleanup",
lambda: find_unique(ws.registry_entries(), disabled_unique) is None,
timeout=60,
)
# Invalid retained discovery JSON never creates a registry entry. Escaped
# strings are covered by the firmware-native serializer tests.
publisher.publish(legacy_topic("malformed"), "{not-json")
time.sleep(2)
if find_unique(ws.registry_entries(), f"{DEVICE_ID}_malformed"):
fail("malformed discovery payload created an entity")
# This fixture documents the current HA field behaviour: def_ent_id is
# allowed on first creation; obsolete obj_id is intentionally absent.
default_payload = legacy_payload("default_name", def_ent_id="test_harness_default_name")
if "obj_id" in default_payload:
fail("test harness fixture accidentally contains obsolete obj_id")
publisher.publish(legacy_topic("default_name"), json.dumps(default_payload))
wait_for_entry(ws, f"{DEVICE_ID}_default_name")
STATE.mkdir(parents=True, exist_ok=True)
(STATE / "migration-complete").write_text("ok", encoding="utf-8")
finally:
ws.close()
publisher.close()
def retained_restart_test_harness():
if not (STATE / "migration-complete").exists():
fail("retained-restart mode requires the migration test harness to run first")
publisher = SharedRetainedPublisher(MQTT_HOST, MQTT_PORT)
ws = HomeAssistantClient.from_state(HA_URL, STATE)
try:
migrated = wait_for_entry(ws, f"{DEVICE_ID}_temperature")
if migrated["entity_id"] != "sensor.test_harness_temperature_user_name":
fail("HA restart lost the user-owned entity rename")
retained = json.loads(publisher.retained_payload(device_topic()))
if "cmps" not in retained:
fail("broker restart check did not receive retained device discovery")
finally:
ws.close()
publisher.close()
def main():
HomeAssistantClient.wait_until_ready(HA_URL)
if MODE == "migration":
migration_test_harness()
elif MODE == "retained-restart":
retained_restart_test_harness()
else:
fail(f"unknown TEST_HARNESS_MODE: {MODE}")
print(f"Home Assistant MQTT test harness mode {MODE} passed")
if __name__ == "__main__":
main()