mirror of
https://github.com/alexhopeoconnor/arduino-home-assistant.git
synced 2026-10-15 23:51:21 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea3f41b3f9 | ||
|
|
0dbf0ecb75 | ||
|
|
50cf1c36f0 | ||
|
|
bf18695c13 | ||
|
|
e11fc4ace1 | ||
|
|
ac08bf8431 | ||
|
|
35b375b497 | ||
|
|
7f28604887 |
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -3,15 +3,7 @@
|
||||
[](https://github.com/alexhopeoconnor/arduino-home-assistant/releases)
|
||||
[](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
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.:
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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("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"));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}")
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user