mirror of
https://github.com/alexhopeoconnor/arduino-home-assistant.git
synced 2026-10-21 10:12:37 +10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72e80f236e | ||
|
|
3892ce1ca9 | ||
|
|
ea6f1cf353 | ||
|
|
e3c9388ffc | ||
|
|
e6dee4727d | ||
|
|
ea3f41b3f9 | ||
|
|
0dbf0ecb75 | ||
|
|
50cf1c36f0 | ||
|
|
bf18695c13 | ||
|
|
e11fc4ace1 |
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:**
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
;;
|
||||
|
||||
@@ -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}")
|
||||
@@ -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);
|
||||
|
||||
@@ -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"]
|
||||
@@ -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__":
|
||||
Reference in New Issue
Block a user