Compare commits

...
39 changed files with 939 additions and 334 deletions
@@ -1,4 +1,4 @@
name: Home Assistant MQTT contract
name: Home Assistant MQTT test harness
on:
workflow_dispatch:
@@ -9,7 +9,7 @@ permissions:
contents: read
jobs:
contract:
test_harness:
runs-on: ubuntu-latest
strategy:
fail-fast: false
@@ -23,8 +23,8 @@ jobs:
expect_disabled_cleanup: "1"
env:
HA_VERSION: ${{ matrix.home_assistant }}
CONTRACT_EXPECT_DISABLED_CLEANUP: ${{ matrix.expect_disabled_cleanup }}
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
@@ -34,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
+29
View File
@@ -9,8 +9,37 @@ permissions:
contents: write
jobs:
test_harness:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- home_assistant: "2024.11.3"
- home_assistant: stable
env:
HA_VERSION: ${{ matrix.home_assistant }}
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
run: docker compose up -d mqtt homeassistant
- name: Check single-to-device migration
run: docker compose run --rm tests
- name: Restart Home Assistant from retained data
run: docker compose restart homeassistant
- name: Check retained discovery after restart
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 test harness volumes
if: always()
run: docker compose down -v
publish:
runs-on: ubuntu-latest
needs: test_harness
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
+20
View File
@@ -1,5 +1,25 @@
# Changelog
## 3.2.1
- Clarify discovery migration and entity documentation, including the choices
for new devices, existing installations, and no-device-discovery projects.
- Refresh the canonical PlatformIO and Arduino IDE package metadata.
## 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:**
+7 -3
View File
@@ -8,7 +8,11 @@ ArduinoHA lets an Arduino, ESP8266, or ESP32 application publish MQTT discovery
## Start with a sensor
```cpp
#if defined(ESP8266)
#include <ESP8266WiFi.h>
#elif defined(ESP32)
#include <WiFi.h>
#endif
#include <ArduinoHA.h>
WiFiClient client;
@@ -24,11 +28,11 @@ void setup() {
// Your application connects Wi-Fi before MQTT begins.
temperature.setName("Temperature");
temperature.setUnitOfMeasurement("°C");
mqtt.begin("mqtt.local", "mqtt_user", "mqtt_password");
mqtt.begin("mqtt.local", "mqtt_user", "mqtt_password"); // Connection work begins in mqtt.loop().
}
void loop() {
mqtt.loop();
mqtt.loop(); // Maintains MQTT and publishes discovery after connecting.
// Call temperature.setValue(...) when your reading changes.
}
```
@@ -57,7 +61,7 @@ Build [ESP Sensor](examples/01-esp-sensor/) for a complete ESP8266/ESP32 project
```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.1
```
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).
+32
View File
@@ -0,0 +1,32 @@
# ESP8266 Postmortem linker workaround
The ESP8266 Arduino framework pin used by the DeviceFramework, WiFiManager,
DFTE, and ArduinoHA maintained builds is intentional:
```ini
platform_packages =
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
```
That upstream commit fixes Postmortem's large-jump failure,
[`dangerous relocation: j: cannot encode`](https://github.com/esp8266/Arduino/commit/521ae60a89e64bb0d1eb7a0b7addf620ced5cad3).
It changes the restart wrapper to use a relaxed jump and adds an EPC1 address
check. The failure is a framework linker/runtime-support issue, not an ArduinoHA
or application-source error.
ArduinoHA's root `pio test -e esp8266` environment and every guided ESP8266
PlatformIO example now use this same snapshot. There is no root-test exception
that can hide a linker regression from the example test harness.
Keep this exact framework snapshot in ESP8266 environments that need the
maintained test harness. It is unrelated to ESP32, whose pioarduino platform
selects its framework and compiler as a unit. Do not replace the SHA with a
version range: remove or advance the pin only after an upstream release includes
the fix and the affected large firmware has compiled successfully. In
particular, changing the ESP32 validation lane does not justify changing this
ESP8266 pin.
The corresponding ESP32 Core 3.3.11 pin is documented in
[DeviceFramework's toolchain guide](https://github.com/alexhopeoconnor/DeviceFramework/blob/main/docs/TOOLCHAINS.md).
Back to the [documentation map](README.md).
+1
View File
@@ -6,6 +6,7 @@
| [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 |
| [ESP8266 linker workaround](ESP8266-LINKER-WORKAROUND.md) | Exact framework pin for the Postmortem large-jump fix |
| [Examples](../examples/README.md) | Guided PlatformIO projects and focused entity recipes |
| [Compatibility baseline](compatibility.md) | Supported Home Assistant capabilities and fork-specific compatibility notes |
+42 -3
View File
@@ -1,7 +1,7 @@
# Compatibility baseline
This maintenance line begins from fork commit `84cc0037b1c0` (release `v3.0.2`).
It intentionally tracks Home Assistant's MQTT discovery contract without merging
It intentionally tracks Home Assistant's MQTT discovery test harness without merging
upstream development wholesale.
| Reference | Audited revision / target |
@@ -10,12 +10,51 @@ 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,
lifecycle handling, and tests are fork-native changes.
## Arduino framework validation lanes
ArduinoHA compiles its embedded test suites and guided PlatformIO examples in
two maintained lanes:
| Selector | Target | Pinned stack | Role |
| --- | --- | --- | --- |
| `esp8266` | ESP8266 D1 mini | Arduino-ESP8266 commit `521ae60` | Maintained Postmortem linker-fix test-harness lane |
| `esp32` | ESP32 Dev Module | pioarduino `55.03.311` / Arduino-ESP32 3.3.11 / ESP-IDF 5.5.5 | Maintained baseline |
Every lane pins its complete framework stack. The ESP32 runner keeps its
maintained pioarduino graph in the persistent cache shared by these maintained
framework repositories, so stale global metadata cannot select an uploader or
compiler by accident without redownloading matching inputs for every repository.
The ESP8266 framework pin is independent of the ESP32 baseline; see the [linker
workaround](ESP8266-LINKER-WORKAROUND.md).
Run the current ESP32 lane locally with:
```bash
./scripts/test.sh compile --platform esp32
./scripts/test.sh examples --platform esp32
```
The script keeps that lane in the persistent PlatformIO Core/cache shared by
the maintained framework repositories by default
(`${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11`).
This prevents stale global `tool-esptoolpy` metadata from shadowing the current
pioarduino package-form uploader while avoiding duplicate Core 3.3.11
downloads. Set `ARDUINOHA_PLATFORMIO_CORE_DIR`,
`ARDUINOHA_PLATFORMIO_PACKAGES_DIR`, and
`ARDUINOHA_PLATFORMIO_CACHE_DIR` for a dedicated disk. The script never clears
that cache or repairs it by overriding one compiler package.
The embedded multi-suite compile path also defaults
`PLATFORMIO_RUN_JOBS=1`. A caller may explicitly set another value, but the
serial default avoids a reproducible PlatformIO archive missing-object race
seen in the Core 3.3.11 lane. Example builds remain parallel.
## Deliberate exclusions
- No merge or wholesale cherry-pick of upstream `develop` / WIP 2.2.0.
@@ -37,4 +76,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.
+26 -10
View File
@@ -40,8 +40,11 @@ Defaults:
Override before `begin()` if needed:
```cpp
mqtt.setDiscoveryPrefix("myHaPrefix");
mqtt.setDataPrefix("myDataPrefix");
void configureTopicPrefixes() {
// Set both before mqtt.begin(...) publishes any discovery data.
mqtt.setDiscoveryPrefix("myHaPrefix");
mqtt.setDataPrefix("myDataPrefix");
}
```
### Single-component vs device discovery
@@ -51,9 +54,19 @@ mqtt.setDataPrefix("myDataPrefix");
Both formats remain supported by Home Assistant. Device discovery requires Home Assistant **2024.11.0 or newer**. Use `enableDeviceDiscovery()` only for a new device which has never published this library's single-component discovery topics.
Choose the mode before connecting a device:
- **New device:** call `enableDeviceDiscovery()` before connecting.
- **Existing device using single-component discovery:** follow the migration
procedure below.
- **No need for device discovery:** keep the default behavior.
### Migrating an existing device to device discovery
Do not switch an existing device by calling `enableDeviceDiscovery()` alone. Home Assistant derives an entity's discovery identity from the topic, and a direct switch causes retained-topic conflicts. The migration deliberately publishes, in order:
Do not switch an existing device by calling `enableDeviceDiscovery()` alone.
Home Assistant derives an entity's discovery identity from the topic, and a
direct switch causes retained-topic conflicts. The migration publishes, in
order:
1. `{"migrate_discovery":true}` to every old retained component config topic;
2. the retained `/device/<deviceId>/config` payload; and only then
@@ -96,13 +109,16 @@ The migration stage is held in RAM. If the board reboots before completion, star
Device discovery can also publish richer origin/device metadata, for example:
```cpp
device.setModelId("esp32-s3-devkit");
device.setHardwareVersion("rev-b");
device.setSerialNumber("SN-00042");
device.setSuggestedArea("Garage");
device.setViaDevice("main_gateway");
device.addConnection("mac", "AA:BB:CC:DD:EE:FF");
mqtt.setOriginSupportUrl("https://example.com/device-help");
void configureDeviceDiscovery() {
// These strings are borrowed, so keep literals or other long-lived storage.
device.setModelId("esp32-s3-devkit");
device.setHardwareVersion("rev-b");
device.setSerialNumber("SN-00042");
device.setSuggestedArea("Garage");
device.setViaDevice("main_gateway");
device.addConnection("mac", "AA:BB:CC:DD:EE:FF");
mqtt.setOriginSupportUrl("https://example.com/device-help");
}
```
For entity identifiers in Home Assistant, prefer **`setDefaultEntityId()`** over legacy **`setObjectId()`**.
+3 -1
View File
@@ -22,7 +22,9 @@ ArduinoHA supports these Home Assistant MQTT discovery entity classes. Choose th
| Tag scanner | [tag-scanner](../examples/tag-scanner/tag-scanner.ino) |
| Text | Use the API header; no dedicated sketch yet |
The library does not currently implement alarm control panels, events, humidifiers, images, lawn mowers, sirens, updates, vacuums, valves, or water heaters. Those are deliberate unsupported surfaces, not configuration switches.
The library does not currently implement alarm control panels, events,
humidifiers, images, lawn mowers, sirens, updates, vacuums, valves, or water
heaters. They cannot be enabled through configuration.
## Common lifecycle
+13 -6
View File
@@ -11,7 +11,7 @@ ArduinoHA talks to Home Assistant over **MQTT** (TCP). You need an MQTT broker r
```ini
lib_deps =
home-assistant-integration=https://github.com/alexhopeoconnor/arduino-home-assistant.git#v3.1.0
home-assistant-integration=https://github.com/alexhopeoconnor/arduino-home-assistant.git#v3.2.1
```
**Arduino IDE:** this fork is not indexed by Library Manager. Download the
@@ -40,20 +40,24 @@ HADevice device(mac, sizeof(mac));
HAMqtt mqtt(client, device);
void setup() {
Ethernet.begin(mac);
mqtt.begin("192.168.1.50", "mqtt_user", "mqtt_password");
Ethernet.begin(mac); // Bring up the network Client before MQTT can connect.
mqtt.begin("192.168.1.50", "mqtt_user", "mqtt_password"); // Connection begins in mqtt.loop().
}
void loop() {
Ethernet.maintain();
mqtt.loop();
Ethernet.maintain(); // Renews DHCP leases where the Ethernet library requires it.
mqtt.loop(); // Services MQTT connection, discovery, and entity traffic.
}
```
### ESP8266 / ESP32 (example)
```cpp
#if defined(ESP8266)
#include <ESP8266WiFi.h>
#elif defined(ESP32)
#include <WiFi.h>
#endif
#include <ArduinoHA.h>
WiFiClient client;
@@ -66,15 +70,18 @@ void setup() {
device.setUniqueId(mac, sizeof(mac));
WiFi.begin("SSID", "password");
// Keep this blocking wait only in a minimal sketch. Production firmware
// should retry, time out, or hand control to its provisioning flow.
while (WiFi.status() != WL_CONNECTED) {
delay(500);
}
// begin() stores broker settings; mqtt.loop() performs connection and recovery.
mqtt.begin("192.168.1.50", "mqtt_user", "mqtt_password");
}
void loop() {
mqtt.loop();
mqtt.loop(); // Service reconnects, subscriptions, and discovery publishing.
}
```
+76 -40
View File
@@ -2,21 +2,25 @@
## Callbacks and tuning
The following snippets extend a sketch that already owns long-lived `HADevice`, `HAMqtt`, and entity objects. Configure callbacks, discovery metadata, and availability before `mqtt.begin(...)`; call the publishing snippets later from normal application logic.
`HAMqtt` supports optional callbacks and PubSubClient tuning:
```cpp
// These callbacks are registered before mqtt.begin(...).
void onMessage(const char* topic, const uint8_t* payload, uint16_t length) { /* ... */ }
void onConnected() { /* ... */ }
void onDisconnected() { /* ... */ }
void onStateChanged(HAMqtt::ConnectionState state) { /* ... */ }
void setup() {
// A successful reconnect creates a new MQTT session, so onConnected runs again.
mqtt.onMessage(onMessage);
mqtt.onConnected(onConnected);
mqtt.onDisconnected(onDisconnected);
mqtt.onStateChanged(onStateChanged);
mqtt.setBufferSize(512); // default 256
mqtt.setKeepAlive(60); // seconds, default 15
mqtt.setBufferSize(512); // Increase only when a larger MQTT packet is required.
mqtt.setKeepAlive(60); // Seconds; the default is 15.
mqtt.begin("192.168.1.50", "user", "pass");
}
```
@@ -27,6 +31,7 @@ Subscribe after each successful connection (for example in `onConnected`), becau
```cpp
void onConnected() {
// MQTT subscriptions belong to this session and must be restored after reconnecting.
mqtt.subscribe("my/custom/topic");
}
```
@@ -36,8 +41,11 @@ Handle payloads in `onMessage`.
## Publishing arbitrary payloads
```cpp
mqtt.publish("customTopic", "payload", false); // not retained
mqtt.publish("customTopic", "payload", true); // retained
// Call after mqtt.begin(...); retain controls whether the broker keeps this value.
void publishApplicationState() {
mqtt.publish("customTopic", "payload", false); // Not retained.
mqtt.publish("customTopic", "payload", true); // Retained.
}
```
## Availability
@@ -45,11 +53,14 @@ mqtt.publish("customTopic", "payload", true); // retained
**Shared availability (recommended):** one availability topic for the whole device — works well with **Last Will** (LWT):
```cpp
device.enableSharedAvailability();
device.setPayloadAvailable("up");
device.setPayloadNotAvailable("down");
device.enableLastWill(); // broker publishes offline when TCP drops
// device.setAvailability(false); // optional: start as offline
// Configure device-wide availability before mqtt.begin(...).
void configureSharedAvailability() {
device.enableSharedAvailability();
device.setPayloadAvailable("up");
device.setPayloadNotAvailable("down");
device.enableLastWill(); // Broker publishes offline when TCP drops.
// device.setAvailability(false); // Optional: start as offline.
}
```
**Per-entity availability:** call `someEntity.setAvailability(true/false)` on each type. Does not use LWT the same way as shared mode; see examples under `examples/availability/`.
@@ -57,16 +68,22 @@ device.enableLastWill(); // broker publishes offline when TCP drops
Custom per-entity payloads are supported:
```cpp
sensor.setPayloadAvailable("ready");
sensor.setPayloadNotAvailable("lost");
// Configure the relevant entity before mqtt.begin(...).
void configureSensorAvailability() {
sensor.setPayloadAvailable("ready");
sensor.setPayloadNotAvailable("lost");
}
```
For multi-topic availability discovery, add full MQTT topics and a mode:
```cpp
sensor.setAvailabilityMode("all");
sensor.addAvailabilityEntry("bridge/status");
sensor.addAvailabilityEntry("sensor/status", "{{ value_json.state }}");
// `sensor` is the entity whose availability depends on these external topics.
void configureExternalAvailability() {
sensor.setAvailabilityMode("all");
sensor.addAvailabilityEntry("bridge/status");
sensor.addAvailabilityEntry("sensor/status", "{{ value_json.state }}");
}
```
## Discovery helpers by entity
@@ -74,40 +91,53 @@ sensor.addAvailabilityEntry("sensor/status", "{{ value_json.state }}");
Common entity discovery metadata is available on most entity classes:
```cpp
entity.setEnabledByDefault(false);
entity.setEntityPicture("https://example.com/entity.png");
entity.setQos(1);
entity.setEncoding("utf-8");
entity.setEntityCategory("diagnostic");
// `entity` is an entity type that supports these common discovery fields.
void configureCommonDiscovery(HABaseDeviceType& entity) {
entity.setEnabledByDefault(false);
entity.setEntityPicture("https://example.com/entity.png");
entity.setQos(1);
entity.setEncoding("utf-8");
entity.setEntityCategory("diagnostic");
}
```
Read-only sensor presentation/template helpers:
```cpp
sensor.setSuggestedDisplayPrecision(2);
sensor.setValueTemplate("{{ value_json.temperature }}");
sensor.setJsonAttributesTemplate("{{ value_json.attrs | tojson }}");
sensor.setLastResetValueTemplate("{{ value_json.last_reset }}");
sensor.setDeviceClass("enum");
sensor.setOptions("idle;charging;discharging;fault");
// `sensor` is a read-only HASensor instance.
void configureSensorPresentation(HASensor& sensor) {
sensor.setSuggestedDisplayPrecision(2);
sensor.setValueTemplate("{{ value_json.temperature }}");
sensor.setJsonAttributesTemplate("{{ value_json.attrs | tojson }}");
sensor.setLastResetValueTemplate("{{ value_json.last_reset }}");
sensor.setDeviceClass("enum");
sensor.setOptions("idle;charging;discharging;fault");
}
```
Writable entity template/payload helpers:
```cpp
mySwitch.setPayloadOn("ENABLE");
mySwitch.setPayloadOff("DISABLE");
mySwitch.setStateOn("running");
mySwitch.setStateOff("stopped");
mySwitch.setValueTemplate("{{ value_json.state }}");
mySwitch.setCommandTemplate("{{ value_json.command }}");
// These names refer to the matching writable entity instances in the application.
void configureWritableEntities(HASwitch& mySwitch,
HANumber& myNumber,
HASelect& mySelect,
HAText& myText,
HAButton& myButton) {
mySwitch.setPayloadOn("ENABLE");
mySwitch.setPayloadOff("DISABLE");
mySwitch.setStateOn("running");
mySwitch.setStateOff("stopped");
mySwitch.setValueTemplate("{{ value_json.state }}");
mySwitch.setCommandTemplate("{{ value_json.command }}");
myNumber.setPayloadReset("RESET");
myNumber.setCommandTemplate("{{ value | float | round(1) }}");
myNumber.setPayloadReset("RESET");
myNumber.setCommandTemplate("{{ value | float | round(1) }}");
mySelect.setCommandTemplate("{{ value_json.choice }}");
myText.setCommandTemplate("{{ value_json.text }}");
myButton.setPayloadPress("PRESS");
mySelect.setCommandTemplate("{{ value_json.choice }}");
myText.setCommandTemplate("{{ value_json.text }}");
myButton.setPayloadPress("PRESS");
}
```
## Compiler macros
@@ -121,12 +151,9 @@ Defined in `ArduinoHADefines.h` or via build flags.
lifecycle callbacks remain ordinary function pointers.
- **`ARDUINOHA_DEBUG`** — enables ArduinoHA logging by default and sets the initial maximum verbosity to `Debug`. Without this flag, structured logs are compiled in but remain disabled until you call `arduinoHASetLogEnabled(true)`.
Structured logging is available through:
Structured logging is available through a long-lived sink:
```cpp
arduinoHASetLogEnabled(true);
arduinoHASetLogLevel(ArduinoHALogLevel::Trace);
class MyLogSink : public ArduinoHALogSink {
public:
void log(const ArduinoHALogMessage& msg) override {
@@ -138,6 +165,15 @@ public:
Serial.println(msg.text);
}
};
MyLogSink logSink; // The installed sink must outlive ArduinoHA logging.
void setup() {
Serial.begin(115200);
arduinoHASetLogSink(&logSink);
arduinoHASetLogEnabled(true);
arduinoHASetLogLevel(ArduinoHALogLevel::Trace);
}
```
`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.
+1 -1
View File
@@ -16,7 +16,7 @@ platform_packages =
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
+1 -1
View File
@@ -16,7 +16,7 @@ platform_packages =
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
@@ -16,7 +16,7 @@ platform_packages =
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
+1 -1
View File
@@ -16,7 +16,7 @@ platform_packages =
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
+3
View File
@@ -7,6 +7,9 @@ pio run -d examples/01-esp-sensor -e esp8266
pio run -d examples/01-esp-sensor -e esp8266 -t upload
```
The checked-in `esp32` environment uses Arduino-ESP32 3.3.11. A consuming
application should choose and pin its complete PlatformIO platform stack.
| Guided example | What it demonstrates |
| --- | --- |
| [ESP Sensor](01-esp-sensor/) | Wi-Fi application wiring, a unique device ID, and changing numeric telemetry |
+4 -2
View File
@@ -12,12 +12,14 @@ HAMqtt mqtt(client, device);
void onMqttMessage(const char* topic, const uint8_t* payload, uint16_t length) {
// This callback is called when message from MQTT broker is received.
// Please note that you should always verify if the message's topic is the one you expect.
// For example: if (memcmp(topic, "myCustomTopic") == 0) { ... }
// For example: if (strcmp(topic, "myCustomTopic") == 0) { ... }
Serial.print("New message on topic: ");
Serial.println(topic);
Serial.print("Data: ");
Serial.println((const char*)payload);
// MQTT payloads are length-delimited; they are not guaranteed to end in NUL.
Serial.write(payload, length);
Serial.println();
mqtt.publish("myPublishTopic", "hello");
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "home-assistant-integration",
"version": "3.1.0",
"version": "3.2.1",
"description": "Maintained Home Assistant MQTT discovery and entity integration for Arduino and ESP devices.",
"keywords": [
"mqtt",
+1 -1
View File
@@ -1,5 +1,5 @@
name=home-assistant-integration
version=3.1.0
version=3.2.1
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
+6 -1
View File
@@ -3,6 +3,7 @@
; Full suite:
; pio test -e esp8266
; pio test -e esp32
; ./scripts/test.sh compile --platform esp32
;
; Single suite (examples):
; pio test -e esp8266 --filter test_utils_serializers
@@ -30,11 +31,15 @@ extends = common
test_ignore = test_native_core
platform = espressif8266
board = d1_mini
; Keep root Unity coverage on the same Postmortem relocation-fix snapshot as
; the maintained ESP8266 examples. See docs/ESP8266-LINKER-WORKAROUND.md.
platform_packages =
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
[env:esp32]
extends = common
test_ignore = test_native_core
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags = ${common.build_flags} -std=gnu++14
+31 -1
View File
@@ -8,6 +8,32 @@ required=(
examples/README.md
)
check_cpp_fence_scope() {
local markdown="$1"
awk '
function brace_delta(line, copy) {
copy = line
return gsub(/\{/, "{", copy) - gsub(/\}/, "}", copy)
}
/^```cpp[[:space:]]*$/ { in_cpp = 1; depth = 0; next }
in_cpp && /^```[[:space:]]*$/ { in_cpp = 0; next }
in_cpp {
line = $0
sub(/^[[:space:]]+/, "", line)
if (depth == 0 &&
(line ~ /^(if|for|while|switch)[[:space:]]*\(/ ||
line ~ /^[A-Za-z_][A-Za-z0-9_:]*::[A-Za-z0-9_]+[[:space:]]*\(/ ||
line ~ /^[A-Za-z_][A-Za-z0-9_]*\./ ||
line ~ /^[A-Za-z_][A-Za-z0-9_]*[[:space:]]*\(/)) {
printf "%s:%d: C++ expression appears at namespace scope; wrap it in a function.\n", FILENAME, FNR > "/dev/stderr"
failed = 1
}
depth += brace_delta($0)
}
END { exit failed }
' "$markdown"
}
for path in "${required[@]}"; do
[[ -f "$root/$path" ]] || { echo "Missing required documentation: $path" >&2; exit 1; }
done
@@ -22,7 +48,7 @@ while IFS= read -r -d '' markdown; do
esac
[[ -e "$candidate" ]] || { echo "Broken relative link in ${markdown#$root/}: $target" >&2; exit 1; }
done < <(sed -nE 's/.*\]\(([^ )]+)( "[^"]*")?\).*/\1/p' "$markdown")
done < <(find "$root" -path "$root/.git" -prune -o -name '*.md' -type f -print0)
done < <(find "$root" -path "$root/.git" -prune -o -path '*/.pio' -prune -o -name '*.md' -type f -print0)
while IFS= read -r example; do
for required in README.md platformio.ini; do
@@ -34,4 +60,8 @@ while IFS= read -r example; do
}
done < <(find "$root/examples" -mindepth 1 -maxdepth 1 -type d -name '[0-9][0-9]-*' -print | sort)
while IFS= read -r markdown; do
check_cpp_fence_scope "$markdown"
done < <(find "$root" -path "$root/.git" -prune -o -path '*/.pio' -prune -o -name '*.md' -type f -print)
echo "Documentation links and required files passed"
+27 -2
View File
@@ -13,9 +13,34 @@ case "${3:-}" in
esac
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
pio_for_platform() {
if [[ "$environment" != "esp32" ]]; then
pio "$@"
return
fi
# Keep Core 3.3.11's package-form esptool and generated pioarduino
# environment in the persistent cache shared by the maintained framework
# repositories. It is never cleared by this script, and isolates this
# tested graph from stale global metadata without duplicate downloads.
local core_dir packages_dir cache_dir
core_dir="${ARDUINOHA_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11}"
packages_dir="${ARDUINOHA_PLATFORMIO_PACKAGES_DIR:-$core_dir/packages}"
cache_dir="${ARDUINOHA_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
PLATFORMIO_CORE_DIR="$core_dir" PLATFORMIO_PACKAGES_DIR="$packages_dir" \
PLATFORMIO_CACHE_DIR="$cache_dir" pio "$@"
}
case "$1" in
compile)
pio test -d "$root" -e "$environment" --without-uploading --without-testing
# PlatformIO's test command starts many archive jobs by default. This
# multi-suite project has a reproducible missing-object race on Core
# 3.3.11 at high parallelism, so use a deterministic default while
# preserving an explicit caller override.
export PLATFORMIO_RUN_JOBS="${PLATFORMIO_RUN_JOBS:-1}"
pio_for_platform test -d "$root" -e "$environment" --without-uploading --without-testing
echo "ArduinoHA compile check passed for $environment"
;;
examples)
@@ -25,7 +50,7 @@ case "$1" in
exit 1
fi
for example in "${examples[@]}"; do
pio run -d "$example" -e "$environment" </dev/null
pio_for_platform run -d "$example" -e "$environment" </dev/null
done
echo "ArduinoHA examples compile check passed for $environment"
;;
+1 -1
View File
@@ -32,7 +32,7 @@
#endif
// Current library version used in discovery origin metadata.
#define ARDUINOHA_LIBRARY_VERSION "3.1.0"
#define ARDUINOHA_LIBRARY_VERSION "3.2.1"
#if defined(ARDUINOHA_DEBUG)
#include <Arduino.h>
@@ -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,290 @@
"""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():
token = token_file.read_text(encoding="utf-8").strip()
else:
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}"
)
# Complete the final server-side step before any browser test starts.
# Leaving it to first-login UI redirects makes a shared disposable
# instance timing-dependent and can leave it stuck on the onboarding
# route. Matching Home Assistant's own origin keeps the IndieAuth
# client and redirect URI locally verifiable.
integration_response = requests.post(
f"{base_url}/api/onboarding/integration",
headers=client._headers,
json={
"client_id": f"{base_url}/",
"redirect_uri": f"{base_url}/onboarding.html?auth_callback=1",
},
timeout=10,
)
if integration_response.status_code not in (200, 201, 400, 403, 404):
raise TestHarnessError(
"Home Assistant onboarding integration step failed: "
f"{integration_response.status_code} {integration_response.text}"
)
onboarding_response = requests.get(f"{base_url}/api/onboarding", timeout=10)
if not onboarding_response.ok:
raise TestHarnessError(
"Home Assistant onboarding status check failed: "
f"{onboarding_response.status_code} {onboarding_response.text}"
)
try:
onboarding_steps = onboarding_response.json()
except ValueError as error:
raise TestHarnessError("Home Assistant onboarding status returned invalid JSON") from error
if not isinstance(onboarding_steps, list):
raise TestHarnessError(
"Home Assistant onboarding status returned unexpected JSON: "
f"{onboarding_steps}"
)
pending_steps = [
str(step.get("step", "unknown"))
for step in onboarding_steps
if isinstance(step, dict) and step.get("done") is not True
]
if pending_steps:
raise TestHarnessError(
"Home Assistant onboarding remains incomplete after bootstrap: " + ", ".join(pending_steps)
)
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}")
+2 -2
View File
@@ -81,7 +81,7 @@ void onNativeStateChanged(HAMqtt::ConnectionState)
}
void test_json_helpers_escape_control_bytes_and_preserve_cursor_contract()
void test_json_helpers_escape_control_bytes_and_preserve_cursor_position()
{
const char value[] = "quote\" slash\\ newline\n tab\t control\x01";
char output[96] = {};
@@ -341,7 +341,7 @@ void tearDown(void) { }
int main(int, char**)
{
UNITY_BEGIN();
RUN_TEST(test_json_helpers_escape_control_bytes_and_preserve_cursor_contract);
RUN_TEST(test_json_helpers_escape_control_bytes_and_preserve_cursor_position);
RUN_TEST(test_availability_and_serializer_array_escape_json_values);
RUN_TEST(test_discovery_topic_tokens_are_rejected_before_topic_generation);
RUN_TEST(test_streaming_serializer_writes_exact_escaped_payload_to_mqtt_mock);
-8
View File
@@ -1,8 +0,0 @@
FROM python:3.12-slim
WORKDIR /tests
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY test_contract.py .
CMD ["python", "test_contract.py"]
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.12-slim
WORKDIR /tests
COPY test-support/ha-mqtt-test-harness /opt/ha-mqtt-test-harness
COPY tests/ha-test-harness/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --no-cache-dir /opt/ha-mqtt-test-harness
COPY tests/ha-test-harness/test_harness.py .
CMD ["python", "test_harness.py"]
@@ -1,4 +1,4 @@
# Home Assistant MQTT contract tests
# Home Assistant MQTT test harness tests
This harness runs retained MQTT discovery messages through Mosquitto and a real
Home Assistant container. It verifies the behavior that firmware unit tests
@@ -23,11 +23,11 @@ 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
@@ -38,16 +38,20 @@ as a compatibility audit. When it is on the repository's default branch, GitHub
exposes it for manual dispatch and the weekly schedule. It is not a tag-release
gate.
For a local development-image test, add `CONTRACT_EXPECT_DISABLED_CLEANUP=1` to
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,15 +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}
CONTRACT_EXPECT_DISABLED_CLEANUP: ${CONTRACT_EXPECT_DISABLED_CLEANUP:-0}
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
@@ -40,4 +41,4 @@ services:
volumes:
ha_config:
contract_state:
test_harness_state:
@@ -1,4 +1,4 @@
"""Home Assistant MQTT discovery contract checks.
"""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
@@ -10,219 +10,28 @@ import json
import os
import pathlib
import time
import uuid
import paho.mqtt.client as mqtt
import requests
import websocket
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("CONTRACT_MODE", "migration")
MODE = os.environ.get("TEST_HARNESS_MODE", "migration")
STATE = pathlib.Path("/state")
TOKEN_FILE = STATE / "ha-token"
DEVICE_ID = "contract_device"
EDGE_DEVICE_ID = "contract_edge_device"
EXPECT_DISABLED_CLEANUP = os.environ.get("CONTRACT_EXPECT_DISABLED_CLEANUP") == "1"
class ContractFailure(RuntimeError):
pass
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 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
raise TestHarnessError(message)
def legacy_topic(object_id, device_id=DEVICE_ID):
@@ -238,7 +47,7 @@ def component(object_id, unique_id=None, device_id=DEVICE_ID, **extra):
"p": "sensor",
"name": object_id.replace("_", " ").title(),
"uniq_id": unique_id or f"{device_id}_{object_id}",
"stat_t": f"contract/{object_id}/state",
"stat_t": f"test-harness/{object_id}/state",
}
payload.update(extra)
return payload
@@ -246,15 +55,15 @@ def component(object_id, unique_id=None, device_id=DEVICE_ID, **extra):
def device_payload(components, device_id=DEVICE_ID):
return {
"dev": {"ids": [device_id], "name": "ArduinoHA contract device"},
"o": {"name": "ArduinoHA", "sw": "contract"},
"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 contract device"}
payload["dev"] = {"ids": [device_id], "name": "ArduinoHA test harness device"}
payload.pop("p")
return payload
@@ -274,19 +83,24 @@ def wait_for_entry(ws, unique_id):
)
def migration_contract():
publisher = RetainedPublisher()
token = onboarding_token()
configure_mqtt_integration(token)
ws = HAWebSocket(token)
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("contract/temperature/state", "21.5")
publisher.publish("test-harness/temperature/state", "21.5")
original = wait_for_entry(ws, unique)
original_id = original["id"]
renamed_entity_id = "sensor.contract_temperature_user_name"
renamed_entity_id = "sensor.test_harness_temperature_user_name"
ws.call(
"config/entity_registry/update",
entity_id=original["entity_id"],
@@ -335,7 +149,7 @@ def migration_contract():
# 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 contract.
# retained-payload/restart test harness.
direct_unique = f"{EDGE_DEVICE_ID}_direct"
publisher.publish(
legacy_topic("direct", EDGE_DEVICE_ID),
@@ -419,11 +233,11 @@ def migration_contract():
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
# 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="contract_default_name")
default_payload = legacy_payload("default_name", def_ent_id="test_harness_default_name")
if "obj_id" in default_payload:
fail("contract fixture accidentally contains obsolete obj_id")
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")
@@ -434,15 +248,14 @@ def migration_contract():
publisher.close()
def retained_restart_contract():
def retained_restart_test_harness():
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)
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.contract_temperature_user_name":
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:
@@ -453,14 +266,14 @@ def retained_restart_contract():
def main():
wait_for_home_assistant()
HomeAssistantClient.wait_until_ready(HA_URL)
if MODE == "migration":
migration_contract()
migration_test_harness()
elif MODE == "retained-restart":
retained_restart_contract()
retained_restart_test_harness()
else:
fail(f"unknown CONTRACT_MODE: {MODE}")
print(f"Home Assistant MQTT contract mode {MODE} passed")
fail(f"unknown TEST_HARNESS_MODE: {MODE}")
print(f"Home Assistant MQTT test harness mode {MODE} passed")
if __name__ == "__main__":