Compare commits

...
19 Commits
Author SHA1 Message Date
alex 72e80f236e build: share maintained ESP32 cache 2026-09-20 19:43:52 +10:00
alex 3892ce1ca9 build: standardize ESP32 3.3.11 test baseline 2026-09-18 02:41:49 +10:00
alex ea6f1cf353 ci: add current ESP32 validation lane 2026-09-17 12:10:59 +10:00
alex e3c9388ffc docs: correct MQTT example and ESP8266 guidance 2026-09-17 09:11:58 +10:00
alex e6dee4727d docs: clarify ArduinoHA discovery guidance 2026-09-08 09:52:56 +10:00
alex ea3f41b3f9 release: prepare ArduinoHA v3.2.0 2026-09-07 20:46:00 +10:00
alex 0dbf0ecb75 test: rename HA MQTT test harness 2026-09-07 19:29:21 +10:00
alex 50cf1c36f0 test: update MQTT contract support dependencies 2026-09-07 08:12:18 +10:00
alex bf18695c13 test: extract reusable HA MQTT contract testkit 2026-09-06 20:02:03 +10:00
alex e11fc4ace1 Merge pull request #1 from alexhopeoconnor/maintenance/ha-mqtt-compatibility
feat: merge Home Assistant MQTT compatibility release
2026-09-06 08:50:38 +10:00
alex ac08bf8431 style: remove trailing blank lines 2026-09-06 08:46:07 +10:00
alex 35b375b497 feat: improve MQTT discovery publishing and examples 2026-09-04 09:52:20 +10:00
alex 7f28604887 Stabilize Home Assistant contract CI 2026-09-02 07:48:48 +10:00
alex 56d74df55a Release ArduinoHA v3.1.0 2026-09-02 07:14:20 +10:00
alex 84cc0037b1 Improve release preparation tooling 2026-09-01 17:29:54 +10:00
alex 29ca5b2a29 Release v3.0.2 2026-09-01 11:39:46 +10:00
alex 5296fb1831 Release v3.0.1 2026-09-01 10:57:59 +10:00
alex 96c5aa6e78 feat: prepare ArduinoHA 3.0.0 2026-08-27 14:25:38 +10:00
alex 9d3eaa61f7 docs: clarify maintained fork installation 2026-08-25 09:01:06 +10:00
95 changed files with 5047 additions and 651 deletions
+29 -6
View File
@@ -2,22 +2,45 @@ name: Build
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
concurrency:
group: build-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
compile-tests:
documentation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: bash -n scripts/*.sh
- run: ./scripts/check-docs.sh
native-core-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
environment: ["esp8266", "esp32"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19
- run: pio test -e ${{ matrix.environment }} --without-uploading --without-testing
- run: pio test -e native --filter test_native_core
compile-tests:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
platform: [esp8266, esp32]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19
- run: ./scripts/test.sh compile --platform ${{ matrix.platform }}
- run: ./scripts/test.sh examples --platform ${{ matrix.platform }}
+43
View File
@@ -0,0 +1,43 @@
name: Home Assistant MQTT test harness
on:
workflow_dispatch:
schedule:
- cron: "17 3 * * 1"
permissions:
contents: read
jobs:
test_harness:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- home_assistant: "2024.11.3"
expect_disabled_cleanup: "0"
- home_assistant: stable
expect_disabled_cleanup: "0"
- home_assistant: dev
expect_disabled_cleanup: "1"
env:
HA_VERSION: ${{ matrix.home_assistant }}
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
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
+37 -1
View File
@@ -9,15 +9,51 @@ 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
with:
python-version: '3.11'
- run: python -m pip install --upgrade platformio==6.1.19
- run: ./scripts/test.sh compile --platform esp8266
- run: ./scripts/test.sh examples --platform esp8266
- run: pio test -e native --filter test_native_core
- run: ./scripts/test.sh compile --platform esp32
- run: ./scripts/test.sh examples --platform esp32
- run: ./scripts/check-docs.sh
- run: ./scripts/prepare-release.sh "$GITHUB_REF_NAME"
- run: gh release create "$GITHUB_REF_NAME" --generate-notes --title "$GITHUB_REF_NAME"
- run: ./scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md"
- run: gh release create "$GITHUB_REF_NAME" --title "ArduinoHA $GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/release-notes.md"
env:
GH_TOKEN: ${{ github.token }}
+4
View File
@@ -1,6 +1,10 @@
.DS_Store
tmp/
# Python test tooling
__pycache__/
*.py[cod]
# PlatformIO build output
.pio/
+48 -6
View File
@@ -1,15 +1,57 @@
# 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:**
- Add an explicit, retry-safe single-component to device-discovery migration sequence: retained `migrate_discovery` markers, device config, then legacy-topic cleanup.
- Match Home Assistant device-component removal semantics with a platform-only tombstone followed by a compacted payload; `republishDiscovery()` re-adds a removed component.
- Stop serializing removed `obj_id`; keep `setObjectId()` source-compatible and use `setDefaultEntityId()` for new entities.
**Reliability and maintenance:**
- Reject invalid discovery topic tokens, escape dynamic JSON values, and fail discovery publication on partial writes or missing device IDs.
- Register entities safely regardless of whether they are constructed before or after `HAMqtt`, diagnose entity-limit drops, and make explicit disconnect callbacks observable.
- Make `HAText` own its current state and bounded command copy; align device-discovery origin version with the package version.
- Add native AddressSanitizer/UndefinedBehaviorSanitizer core regression coverage and release-script version consistency checks.
## 3.0.2
- Align the Arduino IDE `library.properties` version with `library.json` so both package formats identify the same release.
## 3.0.1
- Build and test against the Arduino 3-compatible pioarduino ESP32 platform.
- Enable the integer overload expected by Arduino-ESP32 3 while preserving the
existing ArduinoHA public API.
## 3.0.0
- Establish this maintained fork as the canonical ArduinoHA dependency for DeviceFramework.
- Prevent discovery serialization from writing past its entry allocation.
- Correct array deallocation in affected entity types.
# Changelog
## Unreleased
**New features:**
* Added support for the `default_entity_id` (`def_ent_id`) discovery property across device types. New code should prefer `setDefaultEntityId()` over `setObjectId()`.
* Added opt-in MQTT device discovery payload support through `HAMqtt::enableDeviceDiscovery()`, while keeping single-component discovery as the default behavior.
@@ -25,8 +67,8 @@
**Migration notes:**
* Unit tests now live under `test/` as PlatformIO Unity suites (`pio test`). The legacy `tests/` tree (AUnit, EpoxyDuino, Make, AUniter) has been removed.
* Home Assistant deprecated MQTT `object_id` in favor of `default_entity_id`, and newer Home Assistant versions may warn on or remove `object_id` handling in discovery payloads.
* Existing code using `setObjectId()` remains supported as a legacy fallback, but new projects should migrate to `setDefaultEntityId()`.
* If you enable device discovery mode, avoid publishing per-entity discovery topics manually. Use `republishDiscovery()` when a runtime config change needs to refresh discovery state.
* `setObjectId()` remains source-compatible but no longer serializes the removed `obj_id` discovery property; new projects should use `setDefaultEntityId()`.
* Existing single-component devices must use the staged migration API before device discovery; use `republishDiscovery()` when a runtime config change needs to refresh discovery state.
## 2.1.0
+58 -135
View File
@@ -1,146 +1,69 @@
# Arduino Home Assistant integration 🏠
# Arduino Home Assistant integration
[![](https://img.shields.io/github/v/release/alexhopeoconnor/arduino-home-assistant?label=Version)](https://github.com/alexhopeoconnor/arduino-home-assistant/releases)
[![](https://img.shields.io/badge/Documentation-40BC13)](https://github.com/alexhopeoconnor/arduino-home-assistant/blob/main/docs/README.md)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/dawidchyrzynski)
[![](https://img.shields.io/badge/Documentation-40BC13)](docs/README.md)
ArduinoHA allows to integrate an Arduino/ESP based device with Home Assistant using MQTT.
The library is designed to use as low resources (RAM/flash) as possible.
Initially, it was optimized to work on Arduino Uno with Ethernet Shield,
but I successfully use it on ESP8266/ESP8255 boards in my projects.
ArduinoHA lets an Arduino, ESP8266, or ESP32 application publish MQTT discovery data that Home Assistant understands. Give it a connected Arduino `Client`, declare entities, and Home Assistant creates the controls and telemetry automatically.
## PlatformIO
## Start with a sensor
[`library.json`](library.json) declares the **PubSubClient** dependency for Library Manager / LDF. Example:
```cpp
#if defined(ESP8266)
#include <ESP8266WiFi.h>
#elif defined(ESP32)
#include <WiFi.h>
#endif
#include <ArduinoHA.h>
WiFiClient client;
HADevice device;
HAMqtt mqtt(client, device);
HASensorNumber temperature("temperature");
void setup() {
byte mac[WL_MAC_ADDR_LENGTH];
WiFi.macAddress(mac);
device.setUniqueId(mac, sizeof(mac));
// Your application connects Wi-Fi before MQTT begins.
temperature.setName("Temperature");
temperature.setUnitOfMeasurement("°C");
mqtt.begin("mqtt.local", "mqtt_user", "mqtt_password"); // Connection work begins in mqtt.loop().
}
void loop() {
mqtt.loop(); // Maintains MQTT and publishes discovery after connecting.
// Call temperature.setValue(...) when your reading changes.
}
```
Build [ESP Sensor](examples/01-esp-sensor/) for a complete ESP8266/ESP32 project. It clearly marks the network and broker values you must provide without committing credentials.
## What you can build
- **Automatic Home Assistant discovery:** retained MQTT discovery messages create entities without hand-written Home Assistant YAML.
- **Two-way controls:** sensors publish state while switches, lights, covers, and other writable entities receive explicit callbacks.
- **One physical device:** `HADevice` groups metadata, multiple entities, shared availability, and MQTT Last Will.
- **Device discovery:** publish one compact discovery document for a new multi-entity device, or keep traditional per-entity discovery.
- **Small footprint:** exclude entity implementations your firmware does not use.
## Choose an example
| Example | Learn how to… |
| --- | --- |
| [ESP Sensor](examples/01-esp-sensor/) | connect an ESP application and publish changing numeric telemetry |
| [Switch Callback](examples/02-switch-callback/) | reflect Home Assistant commands in a physical output and report state back |
| [Multi-entity Device](examples/03-multi-entity-device/) | group controls and telemetry with shared availability and Last Will |
| [Device Discovery](examples/04-device-discovery/) | publish one Home Assistant device-discovery document for a new device |
| [Entity recipes](examples/README.md#entity-recipes) | find the existing focused Arduino sketches for each supported entity |
## Install
```ini
lib_deps =
https://github.com/alexhopeoconnor/arduino-home-assistant.git
home-assistant-integration=https://github.com/alexhopeoconnor/arduino-home-assistant.git#v3.2.1
```
(`library.properties` remains the source of truth for the Arduino IDE 1.x/2.x library manager name **home-assistant-integration**.)
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).
## Features
* Two-way communication (state reporting and command execution)
* MQTT discovery (device is added to the Home Assistant panel automatically)
* Rich discovery metadata for entities, devices, origin, templates and availability
* MQTT Last Will and Testament
* Support for custom MQTT messages (publishing and subscribing)
* Auto reconnect with MQTT broker
* Reporting availability (online/offline states) of a device
* Markdown documentation in [`docs/`](docs/README.md); class-level notes in headers under `src/`
* Covered by unit tests ([PlatformIO](https://platformio.org/) + [Unity](https://github.com/ThrowTheSwitch/Unity); see `test/test_*`)
## Discovery Notes
ArduinoHA supports two MQTT discovery modes:
* Single-component discovery, which remains the default behavior and publishes one retained discovery payload per entity.
* Device discovery, which can be enabled explicitly with `HAMqtt::enableDeviceDiscovery()` and publishes a single retained `homeassistant/device/.../config` payload with component mappings under `cmps`.
For entity ID suggestions in Home Assistant, prefer `setDefaultEntityId()` over `setObjectId()`.
`setObjectId()` is still available as a legacy fallback, but newer Home Assistant versions are moving toward `default_entity_id`.
If you need to manage discovery at runtime:
* Use `HABaseDeviceType::republishDiscovery()` after changing discovery-relevant config at runtime.
* Use `HABaseDeviceType::removeFromDiscovery()` to clear the retained discovery payload for a single entity.
When device discovery mode is enabled, runtime discovery refreshes automatically clear any stale retained per-entity config before republishing the device discovery payload.
Recent discovery additions include:
* Shared entity metadata such as `enabled_by_default`, `entity_picture`, `qos`, `encoding`
* Device/origin metadata such as `model_id`, `hw_version`, `serial_number`, `suggested_area`, `via_device`, `connections`, `support_url`
* Availability payload overrides and multi-topic availability discovery metadata
## Supported HA types
| Home Assistant type | Supported |
| ------------------- | :--------: |
| Alarm control panel | ❌ |
| Binary sensor | ✅ |
| Button | ✅ |
| Camera | ✅ |
| Cover | ✅ |
| Device tracker | ✅ |
| Device trigger | ✅ |
| Event | ❌ |
| Fan | ✅ |
| Humidifier | ❌ |
| Image | ❌ |
| HVAC | ✅ |
| Lawn mower | ❌ |
| Light | ✅ |
| Lock | ✅ |
| Number | ✅ |
| Scene | ✅ |
| Select | ✅ |
| Sensor | ✅ |
| Siren | ❌ |
| Switch | ✅ |
| Update | ❌ |
| Tag scanner | ✅ |
| Text | ✅ |
| Vacuum | ❌ |
| Valve | ❌ |
| Water heater | ❌ |
## Examples
|Example|Description |
|-------|-----------------------------|
|[Binary sensor](examples/binary-sensor/binary-sensor.ino)|Using the binary sensor as a door contact sensor.|
|[Button](examples/button/button.ino)|Adding simple buttons to the Home Assistant panel.|
|[Camera](examples/esp32-cam/esp32-cam.ino)|Publishing the preview from the ESP32-CAM module.|
|[Cover](examples/cover/cover.ino)|Controlling a window cover (open / close / stop).|
|[Device trigger](examples/multi-state-button/multi-state-button.ino)|Implementation of a simple wall switch that reports press and hold states.|
|[Fan](examples/fan/fan.ino)|Controlling a simple fan (state + speed).|
|[HVAC](examples/hvac/hvac.ino)|HVAC controller with multiple modes, power control and target temperature.|
|[Lock](examples/lock/lock.ino)|A simple door lock that's controlled by the Home Assistant.|
|[Light](examples/light/light.ino)|A simple light that allows changing brightness, color temperature and RGB color.|
|[Number](examples/number/number.ino)|Adding an interactive numeric slider in the Home Assistant panel.|
|[Scene](examples/scene/scene.ino)|Adding a custom scene in the Home Assistant panel. |
|[Select](examples/select/select.ino)|A dropdown selector that's displayed in the Home Assistant panel.|
|[Sensor](examples/sensor/sensor.ino)|A simple sensor that reports a state in a string representation (open / opening / close).|
|[Analog sensor](examples/sensor-analog/sensor-analog.ino)|Reporting the analog pin's voltage to the Home Assistant.|
|[Integer sensor](examples/sensor-integer/sensor-integer.ino)|Reporting the device's uptime to the Home Assistant.|
|[Switch](examples/led-switch/led-switch.ino)|The LED that's controlled by the Home Assistant.|
|[Multi-switch](examples/multi-switch/multi-switch.ino)|Multiple switches controlled by the Home Assistant.|
|[Tag scanner](examples/tag-scanner/tag-scanner.ino)|Scanning RFID tags using the MFRC522 module.|
|[Availability](examples/availability/availability.ino)|Reporting entities' availability (online / offline) to the Home Assistant.|
|[Advanced availability](examples/advanced-availability/advanced-availability.ino)|Advanced availability reporting with MQTT LWT (Last Will and Testament).|
|[MQTT advanced](examples/mqtt-advanced/mqtt-advanced.ino)|Subscribing to custom topics and publishing custom messages.|
|[MQTT with credentials](examples/mqtt-with-credentials/mqtt-with-credentials.ino)|Establishing connection with a MQTT broker using the credentials. |
|[NodeMCU (ESP8266)](examples/nodemcu/nodemcu.ino)|Basic example for ESP8266 devices.|
|[Arduino Nano 33 IoT](examples/nano33iot/nano33iot.ino)|Basic example for Arduino Nano 33 IoT (SAMD family).|
|[mDNS discovery](examples/mdns/mdns.ino)|Make your ESP8266 discoverable via the mDNS.|
## Compatible hardware
The library uses the Arduino Ethernet Client API for interacting with the network hardware.
It should work fine as long as the `Client` class is available.
Here is the list of devices on which the library was tested:
* Arduino Uno
* Arduino Mega
* Arduino Nano
* Arduino Pro Mini
* Arduino Nano 33 IoT
* Arduino Due
* NodeMCU
* Controllino Mega (Pure)
* Controllino Maxi (Pure)
* ESP-01
* ESP32-CAM
* Sonoff Dual R2
* Sonoff Dual R3
* Sonoff Basic
* Sonoff Mini
* Tuya Wi-Fi switch module
* Tuya Wi-Fi curtain module
Please note that it's not the complete list of supported devices.
You may try to use the library on any device that uses Arduino core.
See [getting started](docs/getting-started.md), the [documentation map](docs/README.md), [examples](examples/README.md), [release history](CHANGELOG.md), and [licence](LICENSE).
+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).
+9 -7
View File
@@ -1,11 +1,13 @@
# Documentation
User-facing notes for this library. API details live in the headers under [`src/`](../src/).
# ArduinoHA documentation
| Topic | What it covers |
| ----- | ---------------- |
| [Getting started](getting-started.md) | Prerequisites, installing the library, minimal sketches |
| [Device & discovery](device-and-discovery.md) | `HADevice`, MQTT connection, discovery prefixes, entity IDs |
| [MQTT usage](mqtt-usage.md) | Callbacks, custom topics, availability, compiler flags |
| [Getting started](getting-started.md) | Connecting a `Client`, installing the library, and the first MQTT lifecycle |
| [Device & discovery](device-and-discovery.md) | `HADevice`, discovery modes, metadata, identifiers, and migration |
| [MQTT usage](mqtt-usage.md) | Callbacks, custom topics, availability, logging, and footprint flags |
| [Entities](entities.md) | Supported Home Assistant entity classes and the best matching example |
| [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 |
The [project README](../README.md) lists supported Home Assistant entity types, discovery notes, and links to [`examples/`](../examples/).
Class-level API details live in headers under [`src/`](../src/). Return to the [project overview](../README.md).
+79
View File
@@ -0,0 +1,79 @@
# Compatibility baseline
This maintenance line begins from fork commit `84cc0037b1c0` (release `v3.0.2`).
It intentionally tracks Home Assistant's MQTT discovery test harness without merging
upstream development wholesale.
| Reference | Audited revision / target |
| --- | --- |
| Fork baseline | `84cc0037b1c0` (`v3.0.2`) |
| Upstream main | `1d333ab229b2` (`v2.1.0`) |
| Upstream develop | `a7039fad810b` (unreleased WIP 2.2.0) |
| Device discovery minimum | Home Assistant `2024.11.0` |
| 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.
- No `obj_id` serialization: Home Assistant removed that discovery field. Use
`setDefaultEntityId()` for new code.
- No binary-sensor `state_class`: it is not valid in the current HA MQTT binary
sensor schema.
- No upstream IMqttClient abstraction or unreviewed entity-type feature PRs.
## Ongoing audit routine
The `upstream` remote has no usable push URL. Fetch and compare explicitly:
```bash
git fetch --prune upstream
git log --oneline main..upstream/develop
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 test harness gates before publishing a new release.
+86 -13
View File
@@ -22,7 +22,13 @@ String setters take **pointers whose contents are not copied** — use literals
When MQTT connects, the library publishes Home Assistant **MQTT discovery** payloads so entities appear automatically.
**Rule:** construct entity objects **after** `HAMqtt` so they can register with it.
Entities can be constructed either before or after `HAMqtt`. Entities which already
exist when `HAMqtt` is constructed are registered then; entities constructed later
register immediately. Both the device, MQTT object, and entities must have a lifetime
that outlasts `mqtt.loop()`.
`HAMqtt` has a configurable entity limit (24 by default). Check
`getRegisteredDeviceTypeCount()`, `getDeviceTypeLimit()`, and `getDeviceTypeRegistrationFailures()` in firmware diagnostics: registrations above the limit are rejected and logged rather than silently disappearing from discovery.
### Topic prefixes
@@ -34,28 +40,89 @@ 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
- **Default:** one retained discovery topic per entity (single-component discovery).
- **Optional:** call `HAMqtt::enableDeviceDiscovery()` to publish a single **device** discovery payload with components under `cmps` (see project README for migration notes).
- **Device discovery:** `HAMqtt::enableDeviceDiscovery()` publishes one retained device payload with components under `cmps`.
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 publishes, in
order:
1. `{"migrate_discovery":true}` to every old retained component config topic;
2. the retained `/device/<deviceId>/config` payload; and only then
3. an empty retained payload to every old component config topic.
Keep the device ID and each entity ID unchanged. They preserve the mapping from the old `<component>/<deviceId>/<entityId>/config` topic to `cmps[entityId]` in the new device payload.
Start migration once during setup, then advance *one stage per loop iteration* after the MQTT connection is available. Each method is retry-safe: do not advance if it returns `false`.
```cpp
bool migrationStarted = false;
void setup() {
// Create device, mqtt, and entities; keep their IDs unchanged.
migrationStarted = mqtt.beginDeviceDiscoveryMigration();
mqtt.begin("192.168.1.50", "user", "password");
}
void loop() {
mqtt.loop();
switch (mqtt.getDeviceDiscoveryMigrationState()) {
case HAMqtt::DeviceDiscoveryMigrationMarkersPending:
mqtt.publishDeviceDiscoveryMigrationMarkers();
break;
case HAMqtt::DeviceDiscoveryMigrationMarkersPublished:
mqtt.publishDeviceDiscoveryMigrationConfig();
break;
case HAMqtt::DeviceDiscoveryMigrationDevicePublished:
mqtt.completeDeviceDiscoveryMigration();
break;
default:
break;
}
}
```
The migration stage is held in RAM. If the board reboots before completion, start the same migration again; the retained marker/config/cleanup publishes are idempotent. If you must abandon a started migration while connected, call `rollbackDeviceDiscoveryMigration()`. Once a device config has been published, it first writes `{"migrate_discovery":true}` to the device discovery topic, restores the legacy configs, then clears the device config and returns to single-component mode. A failed rollback remains pending and suppresses automatic device-bundle publication until retried successfully. Run all publishing stages from `loop()`, not inside an inbound MQTT callback.
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()`**.
`setObjectId()` remains source-compatible but no longer serializes the removed MQTT `obj_id` property. `setDefaultEntityId()` influences Home Assistant's entity ID only when it first creates the entity; existing users can retain a customized entity ID in the entity registry.
Common entity discovery metadata can be configured on most entity types via:
@@ -70,9 +137,15 @@ Common entity discovery metadata can be configured on most entity types via:
After changing discovery-related settings at runtime:
- `HABaseDeviceType::republishDiscovery()` to refresh discovery.
- `HABaseDeviceType::removeFromDiscovery()` to clear retained discovery for one entity.
- `HABaseDeviceType::removeFromDiscovery()` to remove one entity from discovery.
If device discovery mode is enabled, the library clears stale per-entity configs when refreshing.
In single-component mode, removal clears that entity's retained config topic. In device discovery mode, Home Assistant requires a platform-only component marker (`{"p":"sensor"}`, for example), followed by a compacted device bundle that omits the component. ArduinoHA performs both publishes. Call `republishDiscovery()` on that entity to add it back.
## Identifier and lifetime checklist
- Give `HADevice` a stable, non-empty unique ID and each entity a stable, topic-safe ID (`A-Z`, `a-z`, `0-9`, `_`, `-`).
- Use `enableExtendedUniqueIds()` when multiple devices could otherwise reuse the same entity IDs.
- Metadata setters store pointers; retain their backing strings. `HAText` copies the current state and command text it receives, so its state does not borrow a transient MQTT buffer.
## Trimming flash (optional)
+35
View File
@@ -0,0 +1,35 @@
# Entities
ArduinoHA supports these Home Assistant MQTT discovery entity classes. Choose the focused example where possible; it shows the entity's update or command callback pattern in a runnable sketch.
| Entity | Example |
| --- | --- |
| Binary sensor | [binary-sensor](../examples/binary-sensor/binary-sensor.ino) |
| Button | [button](../examples/button/button.ino) |
| Camera | [esp32-cam](../examples/esp32-cam/esp32-cam.ino) |
| Cover | [cover](../examples/cover/cover.ino) |
| Device tracker | Use the API header; no dedicated sketch yet |
| Device trigger | [multi-state-button](../examples/multi-state-button/multi-state-button.ino) |
| Fan | [fan](../examples/fan/fan.ino) |
| HVAC | [hvac](../examples/hvac/hvac.ino) |
| Light | [light](../examples/light/light.ino) |
| Lock | [lock](../examples/lock/lock.ino) |
| Number | [number](../examples/number/number.ino) |
| Scene | [scene](../examples/scene/scene.ino) |
| Select | [select](../examples/select/select.ino) |
| Sensor | [sensor](../examples/sensor/sensor.ino), [sensor-analog](../examples/sensor-analog/sensor-analog.ino), or [sensor-integer](../examples/sensor-integer/sensor-integer.ino) |
| Switch | [led-switch](../examples/led-switch/led-switch.ino) or [multi-switch](../examples/multi-switch/multi-switch.ino) |
| 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. They cannot be enabled through configuration.
## Common lifecycle
Create long-lived `HADevice`, `HAMqtt`, and entity objects, then call `mqtt.begin(...)` once and `mqtt.loop()` regularly. Entities may be constructed before or after `HAMqtt`; both orders register safely. Read [Getting started](getting-started.md) for the connection flow and [device discovery](device-and-discovery.md) for discovery settings.
For shared online/offline state, use `device.enableSharedAvailability()` and `device.enableLastWill()` before connecting. The [availability examples](../examples/availability/) show the simplest setup, while [advanced availability](../examples/advanced-availability/) covers custom payloads and Last Will behaviour.
Back to the [documentation map](README.md).
+26 -11
View File
@@ -6,23 +6,27 @@ ArduinoHA talks to Home Assistant over **MQTT** (TCP). You need an MQTT broker r
## Install the library
**Arduino IDE:** Library Manager → search for `home-assistant-integration` → Install.
**PlatformIO:** add the dependency in `platformio.ini` (see the project [`library.json`](../library.json) for PubSubClient):
**PlatformIO (recommended):** add the maintained release tag in
`platformio.ini`. Its [`library.json`](../library.json) resolves PubSubClient:
```ini
lib_deps =
https://github.com/alexhopeoconnor/arduino-home-assistant.git
home-assistant-integration=https://github.com/alexhopeoconnor/arduino-home-assistant.git#v3.2.1
```
You also need a network `Client` (Ethernet or Wi‑Fi) compatible with the Arduino networking API.
**Arduino IDE:** this fork is not indexed by Library Manager. Download the
source archive for a release, extract it, move the extracted library directory
to `<sketchbook>/libraries/home-assistant-integration`, and restart the IDE.
You also need a network `Client` (Ethernet or Wi-Fi) compatible with the
Arduino networking API.
## Minimal layout
1. Create **`HADevice`** and **`HAMqtt`** once (global or inside a long-lived object).
2. Call **`HAMqtt::begin(...)`** once, at the **end** of `setup()` — it only stores broker settings; the actual connection runs during **`HAMqtt::loop()`**.
3. Call **`mqtt.loop()`** regularly in `loop()` (not necessarily every iteration).
4. Construct **entity classes** (sensors, switches, …) **after** `HAMqtt`, and register them before `begin()` where the API requires it.
4. Construct **entity classes** (sensors, switches, …) before or after `HAMqtt`; ArduinoHA registers both orders. Keep all objects alive for the whole MQTT lifetime and configure discovery before the first connection.
### Ethernet (example)
@@ -36,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;
@@ -62,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.
}
```
@@ -83,6 +94,10 @@ All are valid; pick one. Hostnames work instead of IP addresses.
- `mqtt.begin("192.168.1.50", "user", "pass")` — credentials, port 1883
- `mqtt.begin("192.168.1.50", 8888, "user", "pass")` — credentials + custom port
`begin()` is intentionally a one-time configuration call. Reconnects are owned by `mqtt.loop()`, which uses the configured reconnect interval. Do not call `begin()` in a reconnect timer. `mqtt.disconnect()` now also produces the registered disconnected and state-change callbacks, making an explicit shutdown observable in the same way as a transport loss.
Use Home Assistant **2024.11.0 or newer** when opting into device discovery. See [Device & discovery](device-and-discovery.md) for the required staged migration from an existing single-component device.
## Security note
Credentials go over **plain TCP** unless you use a TLS-capable stack and broker setup. On a trusted LAN this is often acceptable; treat untrusted networks accordingly.
+83 -42
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,54 +91,69 @@ 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
Defined in `ArduinoHADefines.h` or via build flags.
- **`ARDUINOHA_DISABLE_STDFUNCTION`** — disables the default capturing-lambda and
`std::bind` overloads on entity command APIs. Use it on targets without a complete
`std::function` implementation or where code size/RAM matters (constrained AVR
builds are the usual case). ESP8266/ESP32 support the overloads. `HAMqtt`
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 {
@@ -133,11 +165,20 @@ 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`. `DeviceFramework` installs a structured sink so ArduinoHA messages follow the same main log stream as the rest of the firmware.
`arduinoHALog(...)` and `arduinoHALogf(...)` support subsystem-tagged messages such as `mqtt`, `discovery`, `availability`, `serializer`, `entity`, and `device`. Install a sink when you want those messages to join your application’s normal serial or structured log stream.
Optional transport context for diagnostics (registered automatically by **DeviceFramework**): **`arduinoHASetNetworkStatusFn`** (for example returning `WiFi.status()` so publish failure lines can include `wifi=`).
Applications that use Wi-Fi can register **`arduinoHASetNetworkStatusFn`** with a callback returning `WiFi.status()`. Publish failure diagnostics can then include the current Wi-Fi status without coupling ArduinoHA to a particular network library.
**Exclude unused device types** (saves flash from vtables), e.g.:
+9
View File
@@ -0,0 +1,9 @@
# ESP Sensor
This is the first complete ArduinoHA project for an ESP8266 D1 mini or ESP32 development board. It connects Wi-Fi, derives a stable device ID from the board MAC address, and publishes a changing **Uptime** sensor to Home Assistant.
Before uploading, replace the `EXAMPLE_*` values in `include/ExampleNetwork.h`, or provide them as PlatformIO build flags. The header contains safe placeholders and must not contain real credentials when committed.
After MQTT connects, Home Assistant discovers **ArduinoHA sensor example** and its Uptime entity. Rebooting the board keeps the same Home Assistant identity because its MAC address is stable.
See [getting started](../../docs/getting-started.md) and the shared [examples guide](../README.md).
@@ -0,0 +1,37 @@
#pragma once
#if defined(ESP32)
#include <Network.h>
#include <WiFi.h>
#else
#include <ESP8266WiFi.h>
#endif
#ifndef EXAMPLE_WIFI_SSID
#define EXAMPLE_WIFI_SSID "replace-with-wifi-name"
#endif
#ifndef EXAMPLE_WIFI_PASSWORD
#define EXAMPLE_WIFI_PASSWORD "replace-with-wifi-password"
#endif
#ifndef EXAMPLE_MQTT_HOST
#define EXAMPLE_MQTT_HOST "replace-with-mqtt-host"
#endif
#ifndef EXAMPLE_MQTT_USER
#define EXAMPLE_MQTT_USER ""
#endif
#ifndef EXAMPLE_MQTT_PASSWORD
#define EXAMPLE_MQTT_PASSWORD ""
#endif
inline void connectExampleWiFi() {
WiFi.mode(WIFI_STA);
WiFi.begin(EXAMPLE_WIFI_SSID, EXAMPLE_WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(250);
}
}
inline void setExampleUniqueId(HADevice& device) {
uint8_t mac[6];
WiFi.macAddress(mac);
device.setUniqueId(mac, sizeof(mac));
}
+24
View File
@@ -0,0 +1,24 @@
[platformio]
default_envs = esp8266
[common]
framework = arduino
lib_ldf_mode = deep+
lib_deps =
home-assistant-integration=symlink://../..
[env:esp8266]
extends = common
platform = espressif8266
board = d1_mini
platform_packages =
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
-std=gnu++14
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
+30
View File
@@ -0,0 +1,30 @@
#include <Arduino.h>
#include <ArduinoHA.h>
#include "ExampleNetwork.h"
WiFiClient client;
HADevice device;
HAMqtt mqtt(client, device);
HASensorNumber uptime("uptime");
void setup() {
Serial.begin(115200);
connectExampleWiFi();
setExampleUniqueId(device);
device.setName("ArduinoHA sensor example");
device.setSoftwareVersion("1.0.0");
uptime.setName("Uptime");
uptime.setUnitOfMeasurement("s");
mqtt.begin(EXAMPLE_MQTT_HOST, EXAMPLE_MQTT_USER, EXAMPLE_MQTT_PASSWORD);
}
void loop() {
mqtt.loop();
static unsigned long lastUpdate = 0;
if (millis() - lastUpdate >= 1000) {
uptime.setValue(static_cast<uint32_t>(millis() / 1000));
lastUpdate = millis();
}
}
+9
View File
@@ -0,0 +1,9 @@
# Switch Callback
This example creates one writable Home Assistant switch. When Home Assistant sends a command, `onOutputCommand()` changes the local output and immediately reports the resulting state back through the supplied `HASwitch`.
The ESP8266 build uses `LED_BUILTIN`; the ESP32 build uses GPIO 2. Confirm your board’s LED polarity before treating that output as a real load.
Set the Wi-Fi and MQTT placeholders in `include/ExampleNetwork.h`, flash the board, then toggle **Example output** in Home Assistant.
See [MQTT usage](../../docs/mqtt-usage.md) and the shared [examples guide](../README.md).
@@ -0,0 +1,37 @@
#pragma once
#if defined(ESP32)
#include <Network.h>
#include <WiFi.h>
#else
#include <ESP8266WiFi.h>
#endif
#ifndef EXAMPLE_WIFI_SSID
#define EXAMPLE_WIFI_SSID "replace-with-wifi-name"
#endif
#ifndef EXAMPLE_WIFI_PASSWORD
#define EXAMPLE_WIFI_PASSWORD "replace-with-wifi-password"
#endif
#ifndef EXAMPLE_MQTT_HOST
#define EXAMPLE_MQTT_HOST "replace-with-mqtt-host"
#endif
#ifndef EXAMPLE_MQTT_USER
#define EXAMPLE_MQTT_USER ""
#endif
#ifndef EXAMPLE_MQTT_PASSWORD
#define EXAMPLE_MQTT_PASSWORD ""
#endif
inline void connectExampleWiFi() {
WiFi.mode(WIFI_STA);
WiFi.begin(EXAMPLE_WIFI_SSID, EXAMPLE_WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(250);
}
}
inline void setExampleUniqueId(HADevice& device) {
uint8_t mac[6];
WiFi.macAddress(mac);
device.setUniqueId(mac, sizeof(mac));
}
@@ -0,0 +1,24 @@
[platformio]
default_envs = esp8266
[common]
framework = arduino
lib_ldf_mode = deep+
lib_deps =
home-assistant-integration=symlink://../..
[env:esp8266]
extends = common
platform = espressif8266
board = d1_mini
platform_packages =
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
-std=gnu++14
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
+36
View File
@@ -0,0 +1,36 @@
#include <Arduino.h>
#include <ArduinoHA.h>
#include "ExampleNetwork.h"
#if defined(ESP32)
constexpr uint8_t kOutputPin = 2;
#else
constexpr uint8_t kOutputPin = LED_BUILTIN;
#endif
WiFiClient client;
HADevice device;
HAMqtt mqtt(client, device);
HASwitch output("output");
void onOutputCommand(bool state, HASwitch* sender) {
digitalWrite(kOutputPin, state ? HIGH : LOW);
sender->setState(state);
}
void setup() {
Serial.begin(115200);
pinMode(kOutputPin, OUTPUT);
digitalWrite(kOutputPin, LOW);
connectExampleWiFi();
setExampleUniqueId(device);
device.setName("ArduinoHA switch example");
output.setName("Example output");
output.onCommand(onOutputCommand);
mqtt.begin(EXAMPLE_MQTT_HOST, EXAMPLE_MQTT_USER, EXAMPLE_MQTT_PASSWORD);
}
void loop() {
mqtt.loop();
}
@@ -0,0 +1,9 @@
# Multi-entity Device
This example groups an Uptime sensor and writable output under one `HADevice`. It adds common metadata, one shared availability topic, and MQTT Last Will so Home Assistant marks the complete device unavailable if its network connection disappears.
Configure `include/ExampleNetwork.h`, flash the selected target, and inspect the device page in Home Assistant. Both entities belong to **ArduinoHA multi-entity example** and share its availability state.
Use this shape when one physical board exposes several related controls or sensors.
See [device and discovery](../../docs/device-and-discovery.md) and [MQTT usage](../../docs/mqtt-usage.md).
@@ -0,0 +1,37 @@
#pragma once
#if defined(ESP32)
#include <Network.h>
#include <WiFi.h>
#else
#include <ESP8266WiFi.h>
#endif
#ifndef EXAMPLE_WIFI_SSID
#define EXAMPLE_WIFI_SSID "replace-with-wifi-name"
#endif
#ifndef EXAMPLE_WIFI_PASSWORD
#define EXAMPLE_WIFI_PASSWORD "replace-with-wifi-password"
#endif
#ifndef EXAMPLE_MQTT_HOST
#define EXAMPLE_MQTT_HOST "replace-with-mqtt-host"
#endif
#ifndef EXAMPLE_MQTT_USER
#define EXAMPLE_MQTT_USER ""
#endif
#ifndef EXAMPLE_MQTT_PASSWORD
#define EXAMPLE_MQTT_PASSWORD ""
#endif
inline void connectExampleWiFi() {
WiFi.mode(WIFI_STA);
WiFi.begin(EXAMPLE_WIFI_SSID, EXAMPLE_WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(250);
}
}
inline void setExampleUniqueId(HADevice& device) {
uint8_t mac[6];
WiFi.macAddress(mac);
device.setUniqueId(mac, sizeof(mac));
}
@@ -0,0 +1,24 @@
[platformio]
default_envs = esp8266
[common]
framework = arduino
lib_ldf_mode = deep+
lib_deps =
home-assistant-integration=symlink://../..
[env:esp8266]
extends = common
platform = espressif8266
board = d1_mini
platform_packages =
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
-std=gnu++14
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
@@ -0,0 +1,48 @@
#include <Arduino.h>
#include <ArduinoHA.h>
#include "ExampleNetwork.h"
#if defined(ESP32)
constexpr uint8_t kOutputPin = 2;
#else
constexpr uint8_t kOutputPin = LED_BUILTIN;
#endif
WiFiClient client;
HADevice device;
HAMqtt mqtt(client, device);
HASensorNumber uptime("uptime");
HASwitch output("output");
void onOutputCommand(bool state, HASwitch* sender) {
digitalWrite(kOutputPin, state ? HIGH : LOW);
sender->setState(state);
}
void setup() {
Serial.begin(115200);
pinMode(kOutputPin, OUTPUT);
connectExampleWiFi();
setExampleUniqueId(device);
device.setName("ArduinoHA multi-entity example");
device.setManufacturer("Example Devices");
device.setSoftwareVersion("1.0.0");
device.enableSharedAvailability();
device.enableLastWill();
uptime.setName("Uptime");
uptime.setUnitOfMeasurement("s");
output.setName("Example output");
output.onCommand(onOutputCommand);
mqtt.begin(EXAMPLE_MQTT_HOST, EXAMPLE_MQTT_USER, EXAMPLE_MQTT_PASSWORD);
}
void loop() {
mqtt.loop();
static unsigned long lastUpdate = 0;
if (millis() - lastUpdate >= 1000) {
uptime.setValue(static_cast<uint32_t>(millis() / 1000));
lastUpdate = millis();
}
}
+9
View File
@@ -0,0 +1,9 @@
# Device Discovery
This example opts into Home Assistant MQTT device discovery. Instead of one retained discovery document per entity, ArduinoHA publishes one device document containing the component definitions and richer board metadata.
Use device discovery for a **new** device on Home Assistant 2024.11.0 or newer. Do not enable it on a device that has already published traditional single-component discovery without following the documented migration procedure.
After configuring `include/ExampleNetwork.h` and flashing the board, Home Assistant discovers **ArduinoHA device discovery example** and its Uptime sensor.
See [device discovery](../../docs/device-and-discovery.md#single-component-vs-device-discovery) and the shared [examples guide](../README.md).
@@ -0,0 +1,37 @@
#pragma once
#if defined(ESP32)
#include <Network.h>
#include <WiFi.h>
#else
#include <ESP8266WiFi.h>
#endif
#ifndef EXAMPLE_WIFI_SSID
#define EXAMPLE_WIFI_SSID "replace-with-wifi-name"
#endif
#ifndef EXAMPLE_WIFI_PASSWORD
#define EXAMPLE_WIFI_PASSWORD "replace-with-wifi-password"
#endif
#ifndef EXAMPLE_MQTT_HOST
#define EXAMPLE_MQTT_HOST "replace-with-mqtt-host"
#endif
#ifndef EXAMPLE_MQTT_USER
#define EXAMPLE_MQTT_USER ""
#endif
#ifndef EXAMPLE_MQTT_PASSWORD
#define EXAMPLE_MQTT_PASSWORD ""
#endif
inline void connectExampleWiFi() {
WiFi.mode(WIFI_STA);
WiFi.begin(EXAMPLE_WIFI_SSID, EXAMPLE_WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) {
delay(250);
}
}
inline void setExampleUniqueId(HADevice& device) {
uint8_t mac[6];
WiFi.macAddress(mac);
device.setUniqueId(mac, sizeof(mac));
}
@@ -0,0 +1,24 @@
[platformio]
default_envs = esp8266
[common]
framework = arduino
lib_ldf_mode = deep+
lib_deps =
home-assistant-integration=symlink://../..
[env:esp8266]
extends = common
platform = espressif8266
board = d1_mini
platform_packages =
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
-std=gnu++14
-I${platformio.packages_dir}/framework-arduinoespressif32/libraries/Network/src
+33
View File
@@ -0,0 +1,33 @@
#include <Arduino.h>
#include <ArduinoHA.h>
#include "ExampleNetwork.h"
WiFiClient client;
HADevice device;
HAMqtt mqtt(client, device);
HASensorNumber uptime("uptime");
void setup() {
Serial.begin(115200);
connectExampleWiFi();
setExampleUniqueId(device);
device.setName("ArduinoHA device discovery example");
device.setManufacturer("Example Devices");
device.setModel("ESP example");
device.setSoftwareVersion("1.0.0");
uptime.setName("Uptime");
uptime.setUnitOfMeasurement("s");
mqtt.enableDeviceDiscovery();
mqtt.begin(EXAMPLE_MQTT_HOST, EXAMPLE_MQTT_USER, EXAMPLE_MQTT_PASSWORD);
}
void loop() {
mqtt.loop();
static unsigned long lastUpdate = 0;
if (millis() - lastUpdate >= 1000) {
uptime.setValue(static_cast<uint32_t>(millis() / 1000));
lastUpdate = millis();
}
}
+45
View File
@@ -0,0 +1,45 @@
# ArduinoHA examples
Start with a guided PlatformIO project when you are new to the library. Each one builds for ESP8266 and ESP32, contains safe placeholder credentials, and explains the Home Assistant result you should see.
```bash
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 |
| [Switch Callback](02-switch-callback/) | A Home Assistant command callback and local state acknowledgement |
| [Multi-entity Device](03-multi-entity-device/) | Multiple entities, shared availability, and MQTT Last Will |
| [Device Discovery](04-device-discovery/) | One device-discovery document for a newly deployed device |
## Entity recipes
The original focused sketches remain useful as short API recipes. They are intentionally transport-specific Arduino sketches, rather than full product firmware.
| Recipe | Home Assistant behaviour |
| --- | --- |
| [binary-sensor](binary-sensor/binary-sensor.ino) | Door/contact-style binary state |
| [button](button/button.ino) | Press action |
| [cover](cover/cover.ino) | Open, close, and stop commands |
| [esp32-cam](esp32-cam/esp32-cam.ino) | ESP32 camera preview |
| [fan](fan/fan.ino) | State and speed commands |
| [hvac](hvac/hvac.ino) | Modes, power, and target temperature |
| [led-switch](led-switch/led-switch.ino) | Basic writable switch |
| [light](light/light.ino) | Brightness, colour temperature, and RGB |
| [lock](lock/lock.ino) | Writable lock state |
| [multi-state-button](multi-state-button/multi-state-button.ino) | Device triggers from a wall switch |
| [multi-switch](multi-switch/multi-switch.ino) | Multiple writable switches |
| [number](number/number.ino) | Writable numeric value |
| [scene](scene/scene.ino) | Scene trigger |
| [select](select/select.ino) | Writable option list |
| [sensor](sensor/sensor.ino) | String state sensor |
| [sensor-analog](sensor-analog/sensor-analog.ino) | Analog voltage measurement |
| [sensor-integer](sensor-integer/sensor-integer.ino) | Integer uptime measurement |
| [tag-scanner](tag-scanner/tag-scanner.ino) | RFID tag reporting |
See the [entity guide](../docs/entities.md) and [project overview](../README.md).
+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");
}
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "home-assistant-integration",
"version": "3.0.0",
"version": "3.2.1",
"description": "Maintained Home Assistant MQTT discovery and entity integration for Arduino and ESP devices.",
"keywords": [
"mqtt",
@@ -23,7 +23,7 @@
"maintainer": true
}
],
"license": "MIT",
"license": "AGPL-3.0",
"homepage": "https://github.com/alexhopeoconnor/arduino-home-assistant",
"repository": {
"type": "git",
@@ -37,7 +37,6 @@
"include": [
"src",
"examples",
"docs",
"library.properties",
"library.json",
"README.md",
+1 -1
View File
@@ -1,5 +1,5 @@
name=home-assistant-integration
version=3.0.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
+37 -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
@@ -27,12 +28,47 @@ lib_deps = knolleary/PubSubClient@2.8.0
[env:esp8266]
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
platform = espressif32@6.13.0
test_ignore = test_native_core
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
; Host-only regression checks for serialization, topic validation, and the
; PubSubClient mock path. The Arduino compatibility files live exclusively
; under test/native and are never included in firmware environments.
[env:native]
platform = native
test_framework = unity
test_build_src = yes
lib_deps = throwtheswitch/Unity@^2.6.1
build_flags =
-DARDUINOHA_TEST
-Itest/native/include
-fsanitize=address,undefined
-fno-omit-frame-pointer
build_src_filter =
-<*>
+<ArduinoHALog.cpp>
+<HADevice.cpp>
+<HAMqtt.cpp>
+<device-types/HABaseDeviceType.cpp>
+<device-types/HAText.cpp>
+<mocks/PubSubClientMock.cpp>
+<utils/HAAvailabilityConfig.cpp>
+<utils/HADictionary.cpp>
+<utils/HAJson.cpp>
+<utils/HANumeric.cpp>
+<utils/HASerializer.cpp>
+<utils/HASerializerArray.cpp>
+<utils/HAUtils.cpp>
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 vMAJOR.MINOR.PATCH" >&2
exit 2
}
tag="${1:-}"
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version="${tag#v}"
repo_url="https://github.com/alexhopeoconnor/arduino-home-assistant.git"
reference_files=(README.md docs/getting-started.md)
defines_file="$root/src/ArduinoHADefines.h"
current_version="$(sed -n 's/.*"version": "\([^"]*\)".*/\1/p' "$root/library.json" | head -n 1)"
[[ "$current_version" != "$version" ]] || {
echo "library.json already declares $version; choose a new version." >&2
exit 1
}
version_macro_count="$(grep -Ec '^#define ARDUINOHA_LIBRARY_VERSION "[0-9]+\.[0-9]+\.[0-9]+"$' "$defines_file" || true)"
[[ "$version_macro_count" -eq 1 ]] || {
echo "src/ArduinoHADefines.h must contain exactly one ARDUINOHA_LIBRARY_VERSION macro." >&2
exit 1
}
grep -q "^## $version$" "$root/CHANGELOG.md" && {
echo "CHANGELOG.md already has a $version section; choose a new version." >&2
exit 1
}
sed -i -E '0,/"version": "[0-9]+\.[0-9]+\.[0-9]+"/s//"version": "'"$version"'"/' "$root/library.json"
sed -i -E "s/^version=[0-9]+\.[0-9]+\.[0-9]+$/version=$version/" "$root/library.properties"
sed -i -E "s/^#define ARDUINOHA_LIBRARY_VERSION \"[0-9]+\.[0-9]+\.[0-9]+\"$/#define ARDUINOHA_LIBRARY_VERSION \"$version\"/" "$defines_file"
for file in "${reference_files[@]}"; do
sed -i -E "s|${repo_url}#v[0-9]+\.[0-9]+\.[0-9]+|${repo_url}#v${version}|g" "$root/$file"
done
temp_file="$(mktemp)"
trap 'rm -f "$temp_file"' EXIT
{
IFS= read -r changelog_heading < "$root/CHANGELOG.md"
[[ "$changelog_heading" == "# Changelog" ]] || {
echo "CHANGELOG.md must begin with # Changelog" >&2
exit 1
}
printf '%s\n\n## %s\n\n- TODO: Describe this release.\n' "$changelog_heading" "$version"
tail -n +2 "$root/CHANGELOG.md"
} > "$temp_file"
mv "$temp_file" "$root/CHANGELOG.md"
echo "Updated ArduinoHA declarations and canonical install references to $tag."
echo "Replace the generated changelog TODO with the release summary, then run scripts/check-docs.sh and scripts/prepare-release.sh $tag."
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
required=(
README.md CHANGELOG.md
docs/README.md docs/getting-started.md docs/device-and-discovery.md docs/mqtt-usage.md docs/entities.md
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
while IFS= read -r -d '' markdown; do
while IFS= read -r target; do
[[ -z "$target" || "$target" == \#* || "$target" == http://* || "$target" == https://* || "$target" == mailto:* ]] && continue
target="${target%%#*}"
case "$target" in
/*) candidate="$root/${target#/}" ;;
*) candidate="$(dirname "$markdown")/$target" ;;
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 -path '*/.pio' -prune -o -name '*.md' -type f -print0)
while IFS= read -r example; do
for required in README.md platformio.ini; do
[[ -f "$example/$required" ]] || { echo "Incomplete guided example: ${example#$root/} is missing $required" >&2; exit 1; }
done
find "$example" -maxdepth 2 -type f \( -name '*.ino' -o -name '*.cpp' \) -print -quit | grep -q . || {
echo "Incomplete guided example: ${example#$root/} has no sketch source" >&2
exit 1
}
done < <(find "$root/examples" -mindepth 1 -maxdepth 1 -type d -name '[0-9][0-9]-*' -print | sort)
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"
+39 -1
View File
@@ -18,6 +18,18 @@ if [[ "$manifest_version" != "$version" ]]; then
echo "library.json is $manifest_version; expected $version for $tag" >&2
exit 1
fi
defines_file="$root/src/ArduinoHADefines.h"
version_macro_count="$(grep -Ec '^#define ARDUINOHA_LIBRARY_VERSION "[0-9]+\.[0-9]+\.[0-9]+"$' "$defines_file" || true)"
if [[ "$version_macro_count" -ne 1 ]]; then
echo "src/ArduinoHADefines.h must contain exactly one ARDUINOHA_LIBRARY_VERSION macro." >&2
exit 1
fi
defines_version="$(sed -n 's/^#define ARDUINOHA_LIBRARY_VERSION "\([^"]*\)"$/\1/p' "$defines_file")"
if [[ "$defines_version" != "$version" ]]; then
echo "src/ArduinoHADefines.h is $defines_version; expected $version for $tag" >&2
exit 1
fi
if [[ -f "$root/library.properties" ]]; then
properties_version="$(sed -n 's/^version=//p' "$root/library.properties" | head -n 1)"
@@ -27,10 +39,36 @@ if [[ -f "$root/library.properties" ]]; then
fi
fi
if ! grep -q "^## ${version}$" "$root/CHANGELOG.md"; then
echo "CHANGELOG.md is missing a ## ${version} section" >&2
exit 1
fi
if awk -v heading="## $version" '
$0 == heading { found = 1; next }
found && /^## / { exit }
found { print }
' "$root/CHANGELOG.md" | grep -Fq 'TODO: Describe this release.'; then
echo "CHANGELOG.md still has the generated TODO for $version" >&2
exit 1
fi
repo_url="https://github.com/alexhopeoconnor/arduino-home-assistant.git"
validate_reference() {
local file="$1"
local reference_count
reference_count="$(grep -F "$repo_url#v" "$root/$file" | wc -l)"
[[ "$reference_count" -eq 1 ]] || { echo "$file must contain exactly one canonical release reference" >&2; exit 1; }
grep -Fq "$repo_url#$tag" "$root/$file" || { echo "$file does not reference $tag" >&2; exit 1; }
}
validate_reference README.md
validate_reference docs/getting-started.md
git -C "$root" diff --check
package_dir="$(mktemp -d)"
trap 'rm -rf "$package_dir"' EXIT
pio pkg pack "$root" --output "$package_dir/package.tar.gz" >/dev/null
echo "Validated PlatformIO package for $tag"
echo "Validated release metadata and PlatformIO package for $tag"
if [[ "${2:-}" == "--tag" ]]; then
git -C "$root" diff --quiet
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 vMAJOR.MINOR.PATCH" >&2
exit 2
}
tag="${1:-}"
[[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || usage
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version="${tag#v}"
changelog="$root/CHANGELOG.md"
output="$(mktemp)"
trap 'rm -f "$output"' EXIT
awk -v version="$version" '
$0 == "## " version { capture = 1; next }
capture && /^## / { exit }
capture { print }
' "$changelog" > "$output"
if [[ ! -s "$output" ]]; then
echo "No release notes found for $tag in CHANGELOG.md" >&2
exit 1
fi
printf '%s\n\n' "# ArduinoHA $tag"
cat "$output"
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
echo "Usage: $0 compile|examples --platform esp8266|esp32" >&2
exit 2
}
[[ $# -eq 3 && ( "${1:-}" == "compile" || "${1:-}" == "examples" ) && "${2:-}" == "--platform" ]] || usage
case "${3:-}" in
esp8266|esp32) environment="${3}" ;;
*) usage ;;
esac
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
pio_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)
# 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)
mapfile -t examples < <(find "$root/examples" -mindepth 1 -maxdepth 1 -type d -name '[0-9][0-9]-*' -print | sort)
if (( ${#examples[@]} == 0 )); then
echo "No example projects found" >&2
exit 1
fi
for example in "${examples[@]}"; do
pio_for_platform run -d "$example" -e "$environment" </dev/null
done
echo "ArduinoHA examples compile check passed for $environment"
;;
esac
+2 -2
View File
@@ -32,7 +32,7 @@
#endif
// Current library version used in discovery origin metadata.
#define ARDUINOHA_LIBRARY_VERSION "2.1.0"
#define ARDUINOHA_LIBRARY_VERSION "3.2.1"
#if defined(ARDUINOHA_DEBUG)
#include <Arduino.h>
@@ -55,7 +55,7 @@
#endif
#if defined(__SAMD21G18A__) or defined(__SAM3X8E__)
#if defined(__SAMD21G18A__) or defined(__SAM3X8E__) or (defined(ESP32) and defined(ESP_ARDUINO_VERSION_MAJOR) and ESP_ARDUINO_VERSION_MAJOR >= 3)
#define ARDUINOHA_INT_OVERLOAD
#endif
+129 -37
View File
@@ -3,45 +3,136 @@
#include "HAMqtt.h"
#include "utils/HAUtils.h"
#include "utils/HADictionary.h"
#include "utils/HAJson.h"
#include "utils/HASerializer.h"
#include <string.h>
static bool appendEscapedJsonString(char*& cursor, char* end, const char* value)
{
if (!cursor || !value || cursor >= end) {
return false;
}
if (cursor + 1 >= end) {
return false;
}
*cursor++ = '"';
for (const char* p = value; *p != '\0'; p++) {
if ((*p == '"' || *p == '\\') && cursor + 2 >= end) {
return false;
}
if (*p == '"' || *p == '\\') {
*cursor++ = '\\';
}
if (cursor + 1 >= end) {
return false;
}
*cursor++ = *p;
}
if (cursor + 1 >= end) {
return false;
}
*cursor++ = '"';
*cursor = 0;
return true;
return HAJson::appendEscapedString(cursor, end, value);
}
namespace {
void skipJsonWhitespace(const char*& cursor)
{
while (*cursor == ' ' || *cursor == '\t' || *cursor == '\n' || *cursor == '\r') {
cursor++;
}
}
bool isHexDigit(const char value)
{
return (value >= '0' && value <= '9') ||
(value >= 'a' && value <= 'f') ||
(value >= 'A' && value <= 'F');
}
bool parseJsonString(const char*& cursor)
{
if (*cursor != '"') {
return false;
}
cursor++;
while (*cursor != '\0') {
const unsigned char value = static_cast<unsigned char>(*cursor++);
if (value == '"') {
return true;
}
if (value < 0x20) {
return false;
}
if (value != '\\') {
continue;
}
const char escape = *cursor++;
if (escape == '\0') {
return false;
}
if (escape == '"' || escape == '\\' || escape == '/' ||
escape == 'b' || escape == 'f' || escape == 'n' ||
escape == 'r' || escape == 't') {
continue;
}
if (escape != 'u') {
return false;
}
for (uint8_t i = 0; i < 4; i++) {
if (!isHexDigit(*cursor++)) {
return false;
}
}
}
return false;
}
bool isValidConnectionsJson(const char* value, const size_t maxLength)
{
if (!value || value[0] == '\0' || strlen(value) >= maxLength) {
return false;
}
const char* cursor = value;
skipJsonWhitespace(cursor);
if (*cursor++ != '[') {
return false;
}
skipJsonWhitespace(cursor);
if (*cursor == ']') {
cursor++;
skipJsonWhitespace(cursor);
return *cursor == '\0';
}
while (true) {
if (*cursor++ != '[') {
return false;
}
skipJsonWhitespace(cursor);
if (!parseJsonString(cursor)) {
return false;
}
skipJsonWhitespace(cursor);
if (*cursor++ != ',') {
return false;
}
skipJsonWhitespace(cursor);
if (!parseJsonString(cursor)) {
return false;
}
skipJsonWhitespace(cursor);
if (*cursor++ != ']') {
return false;
}
skipJsonWhitespace(cursor);
if (*cursor == ']') {
cursor++;
skipJsonWhitespace(cursor);
return *cursor == '\0';
}
if (*cursor++ != ',') {
return false;
}
skipJsonWhitespace(cursor);
}
}
} // namespace
#define HADEVICE_INIT \
_ownsUniqueId(false), \
_serializer(new HASerializer(nullptr, 16)), \
@@ -250,14 +341,13 @@ bool HADevice::addConnection(const char* type, const char* value)
return true;
}
void HADevice::setConnectionsJson(const char* connectionsJson)
bool HADevice::setConnectionsJson(const char* connectionsJson)
{
if (!connectionsJson || connectionsJson[0] == '\0') {
return;
if (!isValidConnectionsJson(connectionsJson, MaxConnectionsJsonLength)) {
return false;
}
strncpy(_connectionsJson, connectionsJson, MaxConnectionsJsonLength - 1);
_connectionsJson[MaxConnectionsJsonLength - 1] = 0;
strcpy(_connectionsJson, connectionsJson);
_hasConnections = true;
if (!_connectionsPropertyRegistered) {
@@ -268,6 +358,8 @@ void HADevice::setConnectionsJson(const char* connectionsJson)
);
_connectionsPropertyRegistered = true;
}
return true;
}
void HADevice::setPayloadAvailable(const char* payload)
+13 -2
View File
@@ -41,6 +41,11 @@ public:
*/
~HADevice();
HADevice(const HADevice&) = delete;
HADevice& operator=(const HADevice&) = delete;
HADevice(HADevice&&) = delete;
HADevice& operator=(HADevice&&) = delete;
/**
* Returns pointer to the unique ID. It can be nullptr if the device has no ID assigned.
*/
@@ -147,9 +152,15 @@ public:
/**
* Sets the `connections` array as raw JSON (e.g. [[\"mac\",\"aa:bb:cc:dd:ee:ff\"]]).
* The payload is copied into an internal buffer.
*
* This legacy escape hatch accepts only a complete JSON array of two-string
* connection tuples and rejects an oversized or malformed value rather than
* truncating a retained discovery document. Prefer addConnection().
*
* @returns false when the value is malformed or does not fit in the
* internal discovery buffer.
*/
void setConnectionsJson(const char* connectionsJson);
bool setConnectionsJson(const char* connectionsJson);
void setPayloadAvailable(const char* payload);
void setPayloadNotAvailable(const char* payload);
+698 -133
View File
File diff suppressed because it is too large Load Diff
+151 -4
View File
@@ -25,6 +25,7 @@ class PubSubClientMock;
class HADevice;
class HABaseDeviceType;
class HASerializer;
#if defined(ARDUINO_API_VERSION)
using namespace arduino;
@@ -67,6 +68,19 @@ public:
StateUnauthorized = 5
};
/**
* Explicit stages for safely migrating retained single-component discovery
* to Home Assistant device discovery.
*/
enum DeviceDiscoveryMigrationState : uint8_t {
DeviceDiscoveryMigrationIdle = 0,
DeviceDiscoveryMigrationMarkersPending,
DeviceDiscoveryMigrationMarkersPublished,
DeviceDiscoveryMigrationDevicePublished,
DeviceDiscoveryMigrationCompleted,
DeviceDiscoveryMigrationRollbackPending
};
/**
* Returns existing instance (singleton) of the HAMqtt class.
* It may be a null pointer if the HAMqtt object was never constructed or it was destroyed.
@@ -100,6 +114,11 @@ public:
* Removes singleton of the HAMqtt class.
*/
~HAMqtt();
HAMqtt(const HAMqtt&) = delete;
HAMqtt& operator=(const HAMqtt&) = delete;
HAMqtt(HAMqtt&&) = delete;
HAMqtt& operator=(HAMqtt&&) = delete;
/**
* Sets the prefix of the Home Assistant discovery topics.
@@ -146,6 +165,59 @@ public:
inline bool isDeviceDiscoveryEnabled() const
{ return _deviceDiscoveryEnabled; }
/**
* Starts an explicit Home Assistant-safe migration from retained
* single-component discovery to device discovery.
*
* This method enables device discovery but does not publish anything. Call
* the subsequent migration methods after MQTT is connected, in order.
*/
bool beginDeviceDiscoveryMigration();
/**
* Publishes retained migration markers to each legacy component config
* topic. The state advances only when every marker is published.
*/
bool publishDeviceDiscoveryMigrationMarkers();
/**
* Publishes the retained device discovery config after all legacy markers
* have been published.
*/
bool publishDeviceDiscoveryMigrationConfig();
/**
* Clears the retained legacy component config topics after the device
* config was published. The state advances only when every cleanup
* publish succeeds.
*/
bool completeDeviceDiscoveryMigration();
/**
* Reverses a staged device discovery migration. A migration marker is
* published to the device discovery topic before legacy retained configs
* are restored and the device config is cleared.
* The method never runs automatically and returns to single-component
* discovery only after every required retained publish succeeds.
*/
bool rollbackDeviceDiscoveryMigration();
/**
* Returns the current in-memory device discovery migration stage.
*/
inline DeviceDiscoveryMigrationState getDeviceDiscoveryMigrationState() const
{ return _deviceDiscoveryMigrationState; }
/**
* Returns true while an explicit device discovery migration awaits a
* marker, device config, or legacy-topic cleanup step.
*/
inline bool isDeviceDiscoveryMigrationInProgress() const
{
return _deviceDiscoveryMigrationState != DeviceDiscoveryMigrationIdle &&
_deviceDiscoveryMigrationState != DeviceDiscoveryMigrationCompleted;
}
/**
* Republishes the current MQTT device discovery payload when device
* discovery is enabled.
@@ -348,7 +420,31 @@ public:
* @note The HAMqtt class doesn't take ownership of the given pointer.
* @param deviceType Instance of the device's type (HASwitch, HABinarySensor, etc.).
*/
void addDeviceType(HABaseDeviceType* deviceType);
bool addDeviceType(HABaseDeviceType* deviceType);
/**
* Removes a destroyed entity from the connection registry.
* The MQTT instance does not own entity lifetimes.
*/
bool removeDeviceType(HABaseDeviceType* deviceType);
/**
* Number of entities currently registered for connection callbacks.
*/
inline uint8_t getRegisteredDeviceTypeCount() const
{ return _devicesTypesNb; }
/**
* Maximum number of entities that can be registered in this HAMqtt instance.
*/
inline uint8_t getDeviceTypeLimit() const
{ return _maxDevicesTypesNb; }
/**
* Number of registrations dropped because the configured entity limit was reached.
*/
inline uint16_t getDeviceTypeRegistrationFailures() const
{ return _deviceTypeRegistrationFailures; }
/**
* Publishes the MQTT message with given topic and payload.
@@ -383,7 +479,7 @@ public:
* @param data The string to publish.
* @param length Length of the data (bytes).
*/
void writePayload(const char* data, const uint16_t length);
bool writePayload(const char* data, const uint16_t length);
/**
* Writes given data to the TCP stream.
@@ -393,7 +489,7 @@ public:
* @param data The data to publish.
* @param length Length of the data (bytes).
*/
void writePayload(const uint8_t* data, const uint16_t length);
bool writePayload(const uint8_t* data, const uint16_t length);
/**
* Writes given progmem data to the TCP stream.
@@ -402,7 +498,7 @@ public:
*
* @param data Progmem data to publish.
*/
void writePayload(const __FlashStringHelper* data);
bool writePayload(const __FlashStringHelper* data);
/**
* Finishes publishing of a message.
@@ -512,6 +608,36 @@ private:
*/
void onConnectedLogic();
bool publishDeviceDiscoveryPayload(HABaseDeviceType* removalType = nullptr);
HASerializer* buildDeviceDiscoveryComponentSerializer(
HABaseDeviceType* deviceType,
HABaseDeviceType* removalType
);
bool clearDeviceDiscoveryConfig();
bool publishDeviceDiscoveryMigrationMarker(HABaseDeviceType* deviceType);
/**
* Marks the retained device discovery config for Home Assistant's reverse
* migration protocol before restoring legacy component configs.
*/
bool publishDeviceDiscoveryMigrationMarker();
/**
* Removes a component from a device discovery payload using Home
* Assistant's required platform-only marker, followed by a compacted
* bundle when another component remains.
*/
bool removeDeviceDiscoveryComponent(HABaseDeviceType* deviceType);
/**
* Re-adds (or refreshes) a component in the current device discovery
* payload.
*/
bool republishDeviceDiscoveryComponent(HABaseDeviceType* deviceType);
/**
* Sets the state of the MQTT connection.
*/
@@ -567,6 +693,15 @@ private:
*/
String formatDirectPublishFailureDiagnostics(bool hamqttConnectedBefore, int pubsubStateBefore) const;
// Buffer direct MQTT streaming writes so serializers do not issue hundreds
// of tiny TCP writes while a PubSubClient publish is open.
static const uint16_t DirectPublishBufferSize = 128;
bool flushDirectPublishBuffer();
bool appendDirectPublishPayload(const uint8_t* data, uint16_t length);
bool appendDirectPublishProgmemPayload(const __FlashStringHelper* src);
void clearDirectPublishBuffer();
void abortDirectPublish();
#ifdef ARDUINOHA_TEST
PubSubClientMock* _mqtt;
#else
@@ -577,6 +712,10 @@ private:
PubSubClient* _mqtt;
#endif
uint8_t _directPublishBuffer[DirectPublishBufferSize];
uint16_t _directPublishBufferLength;
bool _directPublishActive;
/// Instance of the HADevice passed to the constructor.
const HADevice& _device;
@@ -604,6 +743,9 @@ private:
/// Enables MQTT device discovery mode when set to true.
bool _deviceDiscoveryEnabled;
/// Current in-memory stage of an explicit device discovery migration.
DeviceDiscoveryMigrationState _deviceDiscoveryMigrationState;
const char* _originSupportUrl;
/// The username used for the authentication. It's set in the HAMqtt::begin method.
@@ -630,6 +772,9 @@ private:
/// The last will topic set by HAMqtt::setLastWill
const char* _lastWillTopic;
/// Count of entity registrations rejected because the configured cap was full.
uint16_t _deviceTypeRegistrationFailures;
/// The last will message set by HAMqtt::setLastWill
const char* _lastWillMessage;
@@ -659,6 +804,8 @@ private:
bool _deferredFlushFailedForTest = false;
uint8_t _lastDeferredFlushErrorForTest = 0;
#endif
friend class HABaseDeviceType;
};
#endif
+90 -30
View File
@@ -6,6 +6,15 @@
#include "../utils/HASerializer.h"
#include <string.h>
HABaseDeviceType* HABaseDeviceType::_firstInstance = nullptr;
void HABaseDeviceType::registerAllWith(HAMqtt& mqttInstance)
{
for (HABaseDeviceType* entity = _firstInstance; entity; entity = entity->_nextInstance) {
mqttInstance.addDeviceType(entity);
}
}
HABaseDeviceType::HABaseDeviceType(
const __FlashStringHelper* componentName,
const char* uniqueId
@@ -27,15 +36,34 @@ HABaseDeviceType::HABaseDeviceType(
_payloadNotAvailable(nullptr),
_availabilityMode(nullptr),
_availabilityList(),
_availability(AvailabilityDefault)
_deviceDiscoveryRemoved(false),
_availability(AvailabilityDefault),
_nextInstance(_firstInstance)
{
if (mqtt()) {
mqtt()->addDeviceType(this);
_firstInstance = this;
if (HAMqtt* mqttInstance = mqtt()) {
mqttInstance->addDeviceType(this);
}
}
HABaseDeviceType::~HABaseDeviceType()
{
if (_firstInstance == this) {
_firstInstance = _nextInstance;
} else {
HABaseDeviceType* previous = _firstInstance;
while (previous && previous->_nextInstance != this) {
previous = previous->_nextInstance;
}
if (previous) {
previous->_nextInstance = _nextInstance;
}
}
if (HAMqtt* mqttInstance = mqtt()) {
mqttInstance->removeDeviceType(this);
}
destroySerializer();
}
@@ -47,6 +75,25 @@ void HABaseDeviceType::setAvailability(bool online)
bool HABaseDeviceType::removeFromDiscovery()
{
HAMqtt* mqttInstance = mqtt();
if (!mqttInstance) {
return false;
}
if (mqttInstance->isDeviceDiscoveryEnabled() && supportsDeviceDiscovery()) {
return mqttInstance->removeDeviceDiscoveryComponent(this);
}
return removeSingleComponentDiscovery();
}
bool HABaseDeviceType::removeSingleComponentDiscovery()
{
HAMqtt* mqttInstance = mqtt();
if (!mqttInstance) {
return false;
}
const uint16_t topicLength = HASerializer::calculateConfigTopicLength(
componentName(),
uniqueId()
@@ -61,11 +108,11 @@ bool HABaseDeviceType::removeFromDiscovery()
}
destroySerializer();
if (!mqtt()->beginPublish(topic, 0, true)) {
if (!mqttInstance->beginPublish(topic, 0, true)) {
return false;
}
return mqtt()->endPublish();
return mqttInstance->endPublish();
}
bool HABaseDeviceType::republishDiscovery()
@@ -79,10 +126,7 @@ bool HABaseDeviceType::republishDiscovery()
return publishConfig();
}
// Clear any stale per-entity retained config so device discovery remains
// the single source of truth for supported entities.
removeFromDiscovery();
return mqttInstance->publishDeviceDiscovery();
return mqttInstance->republishDeviceDiscoveryComponent(this);
}
HAMqtt* HABaseDeviceType::mqtt()
@@ -136,8 +180,12 @@ void HABaseDeviceType::destroySerializer()
bool HABaseDeviceType::publishConfig()
{
buildSerializer();
HAMqtt* mqttInstance = mqtt();
if (!mqttInstance) {
return false;
}
buildSerializer();
if (_serializer == nullptr) {
return false;
}
@@ -151,15 +199,19 @@ bool HABaseDeviceType::publishConfig()
bool published = false;
if (topicLength > 0 && dataLength > 0) {
char topic[topicLength];
HASerializer::generateConfigTopic(
if (!HASerializer::generateConfigTopic(
topic,
componentName(),
uniqueId()
);
)) {
destroySerializer();
return false;
}
if (mqtt()->beginPublish(topic, dataLength, true)) {
_serializer->flush();
published = mqtt()->endPublish();
if (mqttInstance->beginPublish(topic, dataLength, true)) {
const bool flushed = _serializer->flush();
const bool ended = mqttInstance->endPublish();
published = flushed && ended;
}
}
@@ -169,7 +221,12 @@ bool HABaseDeviceType::publishConfig()
void HABaseDeviceType::publishAvailability()
{
const HADevice* device = mqtt()->getDevice();
HAMqtt* mqttInstance = mqtt();
if (!mqttInstance) {
return;
}
const HADevice* device = mqttInstance->getDevice();
if (
!device ||
device->isSharedAvailabilityEnabled() ||
@@ -251,13 +308,15 @@ bool HABaseDeviceType::publishAbsolute(
return false;
}
HAMqtt* mqttInstance = mqtt();
const uint16_t len = strlen(payload);
if (!mqtt()->beginPublish(fullTopic, len, retained)) {
if (!mqttInstance->beginPublish(fullTopic, len, retained)) {
return false;
}
mqtt()->writePayload(payload, len);
return mqtt()->endPublish();
const bool written = mqttInstance->writePayload(payload, len);
const bool ended = mqttInstance->endPublish();
return written && ended;
}
bool HABaseDeviceType::publishOnDataTopic(
@@ -305,7 +364,12 @@ bool HABaseDeviceType::publishOnDataTopic(
bool isProgmemData
)
{
if (!payload) {
HAMqtt* mqttInstance = mqtt();
if (!payload || !mqttInstance) {
return false;
}
if (!topic) {
return false;
}
@@ -326,14 +390,16 @@ bool HABaseDeviceType::publishOnDataTopic(
return false;
}
if (mqtt()->beginPublish(fullTopic, length, retained)) {
if (mqttInstance->beginPublish(fullTopic, length, retained)) {
bool written = false;
if (isProgmemData) {
mqtt()->writePayload(AHATOFSTR(payload));
written = mqttInstance->writePayload(AHATOFSTR(payload));
} else {
mqtt()->writePayload(payload, length);
written = mqttInstance->writePayload(payload, length);
}
return mqtt()->endPublish();
const bool ended = mqttInstance->endPublish();
return written && ended;
}
return false;
@@ -353,12 +419,6 @@ void HABaseDeviceType::setEntityIdProperty(HASerializer* serializer) const
const char* defaultEntityId = nonEmptyString(_defaultEntityId);
if (defaultEntityId) {
serializer->set(AHATOFSTR(HADefaultEntityIdProperty), defaultEntityId);
return;
}
const char* objectId = nonEmptyString(_objectId);
if (objectId) {
serializer->set(AHATOFSTR(HAObjectIdProperty), objectId);
}
}
+37 -5
View File
@@ -40,6 +40,11 @@ public:
);
virtual ~HABaseDeviceType();
HABaseDeviceType(const HABaseDeviceType&) = delete;
HABaseDeviceType& operator=(const HABaseDeviceType&) = delete;
HABaseDeviceType(HABaseDeviceType&&) = delete;
HABaseDeviceType& operator=(HABaseDeviceType&&) = delete;
/**
* Returns unique ID of the device type.
@@ -100,8 +105,12 @@ public:
{ return _defaultEntityId; }
/**
* Legacy alias for the MQTT `object_id` discovery property.
* Prefer setDefaultEntityId() for new code.
* Legacy compatibility setter. Home Assistant no longer supports the MQTT
* discovery `obj_id` payload property, so this value is retained only for
* source compatibility and is not published.
*
* Use setDefaultEntityId("domain.entity_id") to suggest an entity ID on
* first discovery. It does not rename existing entities.
*
* @param objectId The object ID.
*/
@@ -171,12 +180,23 @@ public:
/**
* Removes this entity from MQTT discovery by publishing an empty retained
* payload on its config topic.
* payload on its config topic in single-component mode. In device
* discovery mode, publishes Home Assistant's required component-removal
* marker and updates the retained device config.
*/
bool removeFromDiscovery();
/**
* Returns true after this entity was removed from the retained device
* discovery payload and before it is republished.
*/
inline bool isRemovedFromDeviceDiscovery() const
{ return _deviceDiscoveryRemoved; }
/**
* Republishes MQTT discovery config for this entity.
* In device discovery mode, this also re-adds an entity previously removed
* from the retained device payload.
*/
bool republishDiscovery();
@@ -318,8 +338,8 @@ protected:
/**
* Adds the preferred entity ID property to the serializer.
* `default_entity_id` takes precedence and the legacy `object_id` is only
* emitted when no default entity ID was configured.
* Only `default_entity_id` is emitted. The legacy `obj_id` field is not
* supported by current Home Assistant MQTT discovery schemas.
*/
void setEntityIdProperty(HASerializer* serializer) const;
@@ -371,6 +391,13 @@ protected:
const char* _availabilityMode;
HAAvailabilityConfig _availabilityList;
/// Tracks an entity removed from the retained device discovery payload.
bool _deviceDiscoveryRemoved;
static void registerAllWith(HAMqtt& mqtt);
static HABaseDeviceType* _firstInstance;
private:
enum Availability {
AvailabilityDefault = 0,
@@ -382,8 +409,13 @@ private:
Availability _availability;
const char* effectivePayloadAvailable() const;
/// Intrusive list entry used to register entities created before HAMqtt.
HABaseDeviceType* _nextInstance;
const char* effectivePayloadNotAvailable() const;
bool removeSingleComponentDiscovery();
friend class HAMqtt;
friend class HASerializer;
};
+62 -10
View File
@@ -4,6 +4,8 @@
#include "../HAMqtt.h"
#include "../utils/HADictionary.h"
#include "../utils/HASerializer.h"
#include <new>
#include <string.h>
HAText::HAText(const char* uniqueId) :
HABaseDeviceType(AHATOFSTR(HAComponentText), uniqueId),
@@ -25,6 +27,40 @@ HAText::HAText(const char* uniqueId) :
}
HAText::~HAText()
{
delete[] _currentState;
}
void HAText::setCurrentState(const char* state)
{
setCurrentStateInternal(state);
}
bool HAText::setCurrentStateInternal(const char* state)
{
if (!state) {
delete[] _currentState;
_currentState = nullptr;
return true;
}
const size_t stateLength = strlen(state);
if (stateLength > MaxCommandLength) {
return false;
}
char* stateCopy = new (std::nothrow) char[stateLength + 1];
if (!stateCopy) {
return false;
}
memcpy(stateCopy, state, stateLength + 1);
delete[] _currentState;
_currentState = stateCopy;
return true;
}
void HAText::setValueTemplate(const char* valueTemplate)
{
_valueTemplate = valueTemplate;
@@ -49,9 +85,11 @@ bool HAText::setState(const char* state, const bool force)
return true;
}
const bool published = publishState(state);
_currentState = state;
return published;
if (!setCurrentStateInternal(state)) {
return false;
}
return publishState(_currentState);
}
void HAText::buildSerializer()
@@ -222,14 +260,26 @@ void HAText::onMqttMessage(
#endif
;
if (hasCommandCallback && HASerializer::compareDataTopics(
topic,
uniqueId(),
AHATOFSTR(HACommandTopic)
)) {
char value[length + 1];
if (
hasCommandCallback &&
length <= MaxCommandLength &&
(length == 0 || payload) &&
HASerializer::compareDataTopics(
topic,
uniqueId(),
AHATOFSTR(HACommandTopic)
)
) {
char* value = new (std::nothrow) char[static_cast<size_t>(length) + 1];
if (!value) {
return;
}
value[length] = 0;
memcpy(value, payload, length);
if (length > 0) {
memcpy(value, payload, length);
}
if (_commandCallback) {
_commandCallback(value, this);
}
@@ -238,6 +288,8 @@ void HAText::onMqttMessage(
_commandStdCallback(value, this);
}
#endif
delete[] value;
}
}
+16 -4
View File
@@ -28,11 +28,16 @@ public:
ModePassword
};
/// Maximum number of bytes accepted in a text command payload.
static const uint16_t MaxCommandLength = 255;
/**
* @param uniqueId The unique ID of the text entity. It needs to be unique in a scope of your device.
*/
HAText(const char* uniqueId);
~HAText() override;
/**
* Changes state of the text and publishes MQTT message.
* Please note that if a new value is the same as previous one,
@@ -51,8 +56,7 @@ public:
*
* @param state New state of the text.
*/
inline void setCurrentState(const char* state)
{ _currentState = state; }
void setCurrentState(const char* state);
/**
* Returns last known state of the text.
@@ -174,6 +178,14 @@ private:
*/
bool publishState(const char* state);
/**
* Copies state into the owned current-state buffer, or clears it when state
* is nullptr.
*
* @returns Returns false when allocating the copy fails.
*/
bool setCurrentStateInternal(const char* state);
/**
* Returns progmem string representing mode of the text.
*/
@@ -203,8 +215,8 @@ private:
const char* _valueTemplate;
const char* _commandTemplate;
/// The current state of the text. It can be nullptr if state wasn't set.
const char* _currentState;
/// Owned current state of the text. It can be nullptr if state wasn't set.
char* _currentState;
/// The callback that will be called when command is received from the HA.
HATEXT_CALLBACK(_commandCallback);
+44 -17
View File
@@ -2,6 +2,7 @@
#ifdef ARDUINOHA_TEST
#include "../ArduinoHADefines.h"
#include <stdlib.h>
PubSubClientMock::PubSubClientMock() :
_pendingMessage(nullptr),
@@ -156,12 +157,25 @@ bool PubSubClientMock::beginPublish(
size_t PubSubClientMock::write(const uint8_t *buffer, size_t size)
{
if (!_pendingMessage || !_pendingMessage->buffer) {
if (!_pendingMessage || !_pendingMessage->buffer || !buffer) {
return 0;
}
strncat(_pendingMessage->buffer, (const char*)buffer, size);
return size;
const size_t capacity = _pendingMessage->bufferSize - 1;
if (_pendingMessage->writtenSize >= capacity) {
return 0;
}
const size_t available = capacity - _pendingMessage->writtenSize;
const size_t written = size < available ? size : available;
if (written == 0) {
return 0;
}
memcpy(_pendingMessage->buffer + _pendingMessage->writtenSize, buffer, written);
_pendingMessage->writtenSize += written;
_pendingMessage->buffer[_pendingMessage->writtenSize] = 0;
return written;
}
size_t PubSubClientMock::print(const __FlashStringHelper* buffer)
@@ -184,35 +198,46 @@ int PubSubClientMock::endPublish()
return 0;
}
size_t messageSize = _pendingMessage->bufferSize;
uint8_t index = _flushedMessagesNb;
if (_pendingMessage->writtenSize != _pendingMessage->bufferSize - 1 ||
_flushedMessagesNb == UINT8_MAX) {
return 0;
}
_flushedMessagesNb++;
_flushedMessages = static_cast<MqttMessage**>(
realloc(_flushedMessages, _flushedMessagesNb * sizeof(MqttMessage*))
MqttMessage** expanded = static_cast<MqttMessage**>(
realloc(_flushedMessages, (_flushedMessagesNb + 1) * sizeof(MqttMessage*))
);
if (!expanded) {
return 0;
}
_flushedMessages = expanded;
_flushedMessages[_flushedMessagesNb++] = _pendingMessage;
_flushedMessages[index] = _pendingMessage; // handover memory responsibility
_pendingMessage = nullptr; // do not call destructor
return messageSize;
return _flushedMessages[_flushedMessagesNb - 1]->bufferSize;
}
bool PubSubClientMock::subscribe(const char* topic)
{
uint8_t index = _subscriptionsNb;
if (!topic || _subscriptionsNb == UINT8_MAX) {
return false;
}
_subscriptionsNb++;
_subscriptions = static_cast<MqttSubscription**>(
realloc(_subscriptions, _subscriptionsNb * sizeof(MqttSubscription*))
MqttSubscription** expanded = static_cast<MqttSubscription**>(
realloc(_subscriptions, (_subscriptionsNb + 1) * sizeof(MqttSubscription*))
);
if (!expanded) {
return false;
}
size_t topicSize = strlen(topic) + 1;
MqttSubscription* subscription = new MqttSubscription();
subscription->topic = new char[topicSize];
memcpy(subscription->topic, topic, topicSize);
_subscriptions[index] = subscription;
_subscriptions = expanded;
_subscriptions[_subscriptionsNb++] = subscription;
return true;
}
@@ -223,7 +248,8 @@ void PubSubClientMock::clearFlushedMessages()
delete _flushedMessages[i];
}
delete _flushedMessages;
free(_flushedMessages);
_flushedMessages = nullptr;
}
_flushedMessagesNb = 0;
@@ -236,7 +262,8 @@ void PubSubClientMock::clearSubscriptions()
delete _subscriptions[i];
}
delete _subscriptions;
free(_subscriptions);
_subscriptions = nullptr;
}
_subscriptionsNb = 0;
+5 -3
View File
@@ -19,6 +19,7 @@ struct MqttMessage
size_t topicSize;
char* buffer;
size_t bufferSize;
size_t writtenSize;
bool retained;
MqttMessage() :
@@ -26,6 +27,7 @@ struct MqttMessage
topicSize(0),
buffer(nullptr),
bufferSize(0),
writtenSize(0),
retained(false)
{
@@ -34,11 +36,11 @@ struct MqttMessage
~MqttMessage()
{
if (topic) {
delete topic;
delete[] topic;
}
if (buffer) {
delete buffer;
delete[] buffer;
}
}
};
@@ -55,7 +57,7 @@ struct MqttSubscription {
~MqttSubscription()
{
if (topic) {
delete topic;
delete[] topic;
}
}
};
+50 -19
View File
@@ -2,20 +2,17 @@
#include <string.h>
#include "HAAvailabilityConfig.h"
#include "HADictionary.h"
#include "HAJson.h"
static uint16_t jsonEscapedStringSize(const char* s)
{
if (!s) {
return 0;
}
return 2 * strlen_P(HASerializerJsonEscapeChar) + strlen(s);
return HAJson::calculateEscapedStringSize(s);
}
static void appendEscapedString(char* buf, const char* s)
static bool appendEscapedString(char* buf, char* end, const char* s)
{
strcat_P(buf, HASerializerJsonEscapeChar);
strcat(buf, s);
strcat_P(buf, HASerializerJsonEscapeChar);
char* cursor = buf + strlen(buf);
return HAJson::appendEscapedString(cursor, end, s);
}
HAAvailabilityConfig::HAAvailabilityConfig() :
@@ -90,12 +87,12 @@ void HAAvailabilityConfig::clear()
uint16_t HAAvailabilityConfig::calculateJsonSize() const
{
uint16_t size =
uint32_t size =
strlen_P(HASerializerJsonArrayPrefix) +
strlen_P(HASerializerJsonArraySuffix);
if (_count == 0) {
return size;
return static_cast<uint16_t>(size);
}
size += (_count - 1) * strlen_P(HASerializerJsonPropertiesSeparator);
@@ -107,39 +104,59 @@ uint16_t HAAvailabilityConfig::calculateJsonSize() const
size += strlen_P(HASerializerJsonDataSuffix);
// "t":"..."
const uint16_t topicSize = jsonEscapedStringSize(e.topic);
if (topicSize == 0) {
return 0;
}
size +=
strlen_P(HASerializerJsonPropertyPrefix) +
strlen_P(HATopic) +
strlen_P(HASerializerJsonPropertySuffix) +
jsonEscapedStringSize(e.topic);
topicSize;
if (e.valueTemplate && e.valueTemplate[0] != '\0') {
size += strlen_P(HASerializerJsonPropertiesSeparator);
const uint16_t valueTemplateSize = jsonEscapedStringSize(e.valueTemplate);
if (valueTemplateSize == 0) {
return 0;
}
size +=
strlen_P(HASerializerJsonPropertyPrefix) +
strlen_P(HAValueTemplateProperty) +
strlen_P(HASerializerJsonPropertySuffix) +
jsonEscapedStringSize(e.valueTemplate);
valueTemplateSize;
}
if (e.payloadAvailable && e.payloadAvailable[0] != '\0') {
size += strlen_P(HASerializerJsonPropertiesSeparator);
const uint16_t payloadAvailableSize = jsonEscapedStringSize(e.payloadAvailable);
if (payloadAvailableSize == 0) {
return 0;
}
size +=
strlen_P(HASerializerJsonPropertyPrefix) +
strlen_P(HAPayloadAvailableProperty) +
strlen_P(HASerializerJsonPropertySuffix) +
jsonEscapedStringSize(e.payloadAvailable);
payloadAvailableSize;
}
if (e.payloadNotAvailable && e.payloadNotAvailable[0] != '\0') {
size += strlen_P(HASerializerJsonPropertiesSeparator);
const uint16_t payloadNotAvailableSize = jsonEscapedStringSize(e.payloadNotAvailable);
if (payloadNotAvailableSize == 0) {
return 0;
}
size +=
strlen_P(HASerializerJsonPropertyPrefix) +
strlen_P(HAPayloadNotAvailableProperty) +
strlen_P(HASerializerJsonPropertySuffix) +
jsonEscapedStringSize(e.payloadNotAvailable);
payloadNotAvailableSize;
}
}
return size;
if (size > UINT16_MAX) {
return 0;
}
return static_cast<uint16_t>(size);
}
bool HAAvailabilityConfig::serialize(char* output) const
@@ -148,6 +165,12 @@ bool HAAvailabilityConfig::serialize(char* output) const
return false;
}
const uint16_t jsonSize = calculateJsonSize();
if (jsonSize == 0) {
return false;
}
char* const end = output + jsonSize;
output[0] = 0;
strcat_P(output, HASerializerJsonArrayPrefix);
@@ -161,7 +184,9 @@ bool HAAvailabilityConfig::serialize(char* output) const
strcat_P(output, HASerializerJsonPropertyPrefix);
strcat_P(output, HATopic);
strcat_P(output, HASerializerJsonPropertySuffix);
appendEscapedString(output, _entries[i].topic);
if (!appendEscapedString(output, end, _entries[i].topic)) {
return false;
}
const Entry& e = _entries[i];
if (e.valueTemplate && e.valueTemplate[0] != '\0') {
@@ -169,21 +194,27 @@ bool HAAvailabilityConfig::serialize(char* output) const
strcat_P(output, HASerializerJsonPropertyPrefix);
strcat_P(output, HAValueTemplateProperty);
strcat_P(output, HASerializerJsonPropertySuffix);
appendEscapedString(output, e.valueTemplate);
if (!appendEscapedString(output, end, e.valueTemplate)) {
return false;
}
}
if (e.payloadAvailable && e.payloadAvailable[0] != '\0') {
strcat_P(output, HASerializerJsonPropertiesSeparator);
strcat_P(output, HASerializerJsonPropertyPrefix);
strcat_P(output, HAPayloadAvailableProperty);
strcat_P(output, HASerializerJsonPropertySuffix);
appendEscapedString(output, e.payloadAvailable);
if (!appendEscapedString(output, end, e.payloadAvailable)) {
return false;
}
}
if (e.payloadNotAvailable && e.payloadNotAvailable[0] != '\0') {
strcat_P(output, HASerializerJsonPropertiesSeparator);
strcat_P(output, HASerializerJsonPropertyPrefix);
strcat_P(output, HAPayloadNotAvailableProperty);
strcat_P(output, HASerializerJsonPropertySuffix);
appendEscapedString(output, e.payloadNotAvailable);
if (!appendEscapedString(output, end, e.payloadNotAvailable)) {
return false;
}
}
strcat_P(output, HASerializerJsonDataSuffix);
+5
View File
@@ -22,6 +22,11 @@ public:
HAAvailabilityConfig();
~HAAvailabilityConfig();
HAAvailabilityConfig(const HAAvailabilityConfig&) = delete;
HAAvailabilityConfig& operator=(const HAAvailabilityConfig&) = delete;
HAAvailabilityConfig(HAAvailabilityConfig&&) = delete;
HAAvailabilityConfig& operator=(HAAvailabilityConfig&&) = delete;
/**
* Adds an availability entry. `topic` must be a full MQTT topic string.
* @return false when full or topic is null/empty.
+174
View File
@@ -0,0 +1,174 @@
#include "HAJson.h"
#include <Arduino.h>
#include <limits.h>
#include <string.h>
namespace {
uint8_t escapedByteSize(const uint8_t value)
{
switch (value) {
case '"':
case '\\':
case '\b':
case '\f':
case '\n':
case '\r':
case '\t':
return 2;
default:
return value < 0x20 ? 6 : 1;
}
}
char hexDigit(const uint8_t value)
{
return value < 10 ? static_cast<char>('0' + value) : static_cast<char>('A' + (value - 10));
}
void appendEscapedByte(char*& cursor, const uint8_t value)
{
switch (value) {
case '"':
*cursor++ = '\\';
*cursor++ = '"';
return;
case '\\':
*cursor++ = '\\';
*cursor++ = '\\';
return;
case '\b':
*cursor++ = '\\';
*cursor++ = 'b';
return;
case '\f':
*cursor++ = '\\';
*cursor++ = 'f';
return;
case '\n':
*cursor++ = '\\';
*cursor++ = 'n';
return;
case '\r':
*cursor++ = '\\';
*cursor++ = 'r';
return;
case '\t':
*cursor++ = '\\';
*cursor++ = 't';
return;
default:
if (value < 0x20) {
*cursor++ = '\\';
*cursor++ = 'u';
*cursor++ = '0';
*cursor++ = '0';
*cursor++ = hexDigit(static_cast<uint8_t>(value >> 4));
*cursor++ = hexDigit(static_cast<uint8_t>(value & 0x0F));
} else {
*cursor++ = static_cast<char>(value);
}
}
}
} // namespace
uint16_t HAJson::calculateEscapedStringSize(const char* value)
{
if (!value) {
return 0;
}
uint32_t size = 2; // surrounding quotes
for (const uint8_t* p = reinterpret_cast<const uint8_t*>(value); *p != 0; p++) {
size += escapedByteSize(*p);
if (size > UINT16_MAX) {
return 0;
}
}
return static_cast<uint16_t>(size);
}
uint16_t HAJson::calculateEscapedProgmemStringSize(const char* value)
{
if (!value) {
return 0;
}
uint32_t size = 2; // surrounding quotes
for (uint16_t i = 0; ; i++) {
const uint8_t byte = pgm_read_byte(value + i);
if (byte == 0) {
break;
}
size += escapedByteSize(byte);
if (size > UINT16_MAX) {
return 0;
}
}
return static_cast<uint16_t>(size);
}
bool HAJson::appendEscapedString(char*& cursor, char* end, const char* value)
{
if (!cursor || !end || !value || cursor > end) {
return false;
}
const uint16_t size = calculateEscapedStringSize(value);
if (size == 0 || static_cast<size_t>(end - cursor) < size) {
return false;
}
*cursor++ = '"';
for (const uint8_t* p = reinterpret_cast<const uint8_t*>(value); *p != 0; p++) {
appendEscapedByte(cursor, *p);
}
*cursor++ = '"';
*cursor = 0;
return true;
}
bool HAJson::appendEscapedProgmemString(char*& cursor, char* end, const char* value)
{
if (!cursor || !end || !value || cursor > end) {
return false;
}
const uint16_t size = calculateEscapedProgmemStringSize(value);
if (size == 0 || static_cast<size_t>(end - cursor) < size) {
return false;
}
*cursor++ = '"';
for (uint16_t i = 0; ; i++) {
const uint8_t byte = pgm_read_byte(value + i);
if (byte == 0) {
break;
}
appendEscapedByte(cursor, byte);
}
*cursor++ = '"';
*cursor = 0;
return true;
}
bool HAJson::isValidDiscoveryTopicToken(const char* value)
{
if (!value || value[0] == '\0') {
return false;
}
for (const unsigned char* p = reinterpret_cast<const unsigned char*>(value); *p != 0; p++) {
const bool isLower = *p >= 'a' && *p <= 'z';
const bool isUpper = *p >= 'A' && *p <= 'Z';
const bool isDigit = *p >= '0' && *p <= '9';
if (!isLower && !isUpper && !isDigit && *p != '_' && *p != '-') {
return false;
}
}
return true;
}
+46
View File
@@ -0,0 +1,46 @@
#ifndef AHA_JSON_H
#define AHA_JSON_H
#include <stdint.h>
/**
* Small JSON helpers used by discovery serializers.
*
* The library streams discovery documents directly to PubSubClient, so the
* calculated size must exactly match the escaped JSON representation before a
* retained payload is started.
*/
namespace HAJson
{
/**
* Returns the number of bytes needed to encode value as a JSON string,
* including its surrounding quotes. Returns zero for a null value or when
* the result cannot fit in a uint16_t.
*/
uint16_t calculateEscapedStringSize(const char* value);
/**
* As calculateEscapedStringSize(), but reads value from program memory.
*/
uint16_t calculateEscapedProgmemStringSize(const char* value);
/**
* Appends a program-memory value as a JSON string to [cursor, end).
*/
bool appendEscapedProgmemString(char*& cursor, char* end, const char* value);
/**
* Appends value as a JSON string to [cursor, end). end points at the last
* usable byte for the terminating null character. The output is left
* unchanged when the complete escaped value cannot fit.
*/
bool appendEscapedString(char*& cursor, char* end, const char* value);
/**
* Home Assistant discovery node/object IDs may only contain these topic
* token characters: [A-Za-z0-9_-].
*/
bool isValidDiscoveryTopicToken(const char* value);
}
#endif
+313 -112
View File
@@ -12,9 +12,112 @@
#include "../HAMqtt.h"
#include "../utils/HAUtils.h"
#include "../utils/HANumeric.h"
#include "../utils/HAJson.h"
#include "../utils/HAAvailabilityConfig.h"
#include "../device-types/HABaseDeviceType.h"
namespace {
bool writeJsonEscapedByte(HAMqtt* mqtt, const uint8_t value)
{
char output[6];
uint8_t length = 0;
switch (value) {
case '"':
output[0] = '\\';
output[1] = '"';
length = 2;
break;
case '\\':
output[0] = '\\';
output[1] = '\\';
length = 2;
break;
case '\b':
output[0] = '\\';
output[1] = 'b';
length = 2;
break;
case '\f':
output[0] = '\\';
output[1] = 'f';
length = 2;
break;
case '\n':
output[0] = '\\';
output[1] = 'n';
length = 2;
break;
case '\r':
output[0] = '\\';
output[1] = 'r';
length = 2;
break;
case '\t':
output[0] = '\\';
output[1] = 't';
length = 2;
break;
default:
if (value < 0x20) {
static const char hex[] = "0123456789ABCDEF";
output[0] = '\\';
output[1] = 'u';
output[2] = '0';
output[3] = '0';
output[4] = hex[value >> 4];
output[5] = hex[value & 0x0F];
length = 6;
} else {
output[0] = static_cast<char>(value);
length = 1;
}
break;
}
return mqtt && mqtt->writePayload(output, length);
}
bool writeJsonString(HAMqtt* mqtt, const char* value, const bool progmem)
{
if (!mqtt || !value) {
return false;
}
const char quote = '"';
if (!mqtt->writePayload(&quote, 1)) {
return false;
}
for (size_t i = 0; ; i++) {
const uint8_t byte = progmem
? pgm_read_byte(value + i)
: static_cast<uint8_t>(value[i]);
if (byte == 0) {
break;
}
if (!writeJsonEscapedByte(mqtt, byte)) {
return false;
}
}
return mqtt->writePayload(&quote, 1);
}
bool writeJsonStringContents(HAMqtt* mqtt, const char* value)
{
if (!mqtt || !value) {
return false;
}
for (size_t i = 0; value[i] != '\0'; i++) {
if (!writeJsonEscapedByte(mqtt, static_cast<uint8_t>(value[i]))) {
return false;
}
}
return true;
}
} // namespace
uint16_t HASerializer::calculateConfigTopicLength(
const __FlashStringHelper* componentName,
const char* objectId
@@ -26,7 +129,10 @@ uint16_t HASerializer::calculateConfigTopicLength(
!objectId ||
!mqtt ||
!mqtt->getDiscoveryPrefix() ||
!mqtt->getDevice()
!mqtt->getDevice() ||
!mqtt->getDevice()->getUniqueId() ||
!HAJson::isValidDiscoveryTopicToken(mqtt->getDevice()->getUniqueId()) ||
!HAJson::isValidDiscoveryTopicToken(objectId)
) {
return 0;
}
@@ -52,7 +158,10 @@ bool HASerializer::generateConfigTopic(
!objectId ||
!mqtt ||
!mqtt->getDiscoveryPrefix() ||
!mqtt->getDevice()
!mqtt->getDevice() ||
!mqtt->getDevice()->getUniqueId() ||
!HAJson::isValidDiscoveryTopicToken(mqtt->getDevice()->getUniqueId()) ||
!HAJson::isValidDiscoveryTopicToken(objectId)
) {
return false;
}
@@ -83,7 +192,8 @@ uint16_t HASerializer::calculateDataTopicLength(
!topic ||
!mqtt ||
!mqtt->getDataPrefix() ||
!mqtt->getDevice()
!mqtt->getDevice() ||
!mqtt->getDevice()->getUniqueId()
) {
return 0;
}
@@ -112,7 +222,8 @@ bool HASerializer::generateDataTopic(
!topic ||
!mqtt ||
!mqtt->getDataPrefix() ||
!mqtt->getDevice()
!mqtt->getDevice() ||
!mqtt->getDevice()->getUniqueId()
) {
return false;
}
@@ -162,7 +273,7 @@ HASerializer::HASerializer(
_deviceType(deviceType),
_entriesNb(0),
_maxEntriesNb(maxEntriesNb),
_entries(new SerializerEntry[maxEntriesNb])
_entries(new (std::nothrow) SerializerEntry[maxEntriesNb])
{
}
@@ -229,6 +340,10 @@ void HASerializer::topic(const __FlashStringHelper* topic)
HASerializer::SerializerEntry* HASerializer::addEntry()
{
if (!_entries) {
return nullptr;
}
if (_entriesNb >= _maxEntriesNb) {
if (_maxEntriesNb == UINT8_MAX) {
return nullptr;
@@ -259,14 +374,18 @@ HASerializer::SerializerEntry* HASerializer::addEntry()
uint16_t HASerializer::calculateSize() const
{
uint16_t size =
if (!_entries) {
return 0;
}
uint32_t size =
strlen_P(HASerializerJsonDataPrefix) +
strlen_P(HASerializerJsonDataSuffix);
for (uint8_t i = 0; i < _entriesNb; i++) {
const uint16_t entrySize = calculateEntrySize(&_entries[i]);
if (entrySize == 0) {
continue;
return 0;
}
size += entrySize;
@@ -275,9 +394,13 @@ uint16_t HASerializer::calculateSize() const
if (i > 0) {
size += strlen_P(HASerializerJsonPropertiesSeparator);
}
if (size > UINT16_MAX) {
return 0;
}
}
return size;
return static_cast<uint16_t>(size);
}
bool HASerializer::flush() const
@@ -287,11 +410,19 @@ bool HASerializer::flush() const
return false;
}
mqtt->writePayload(AHATOFSTR(HASerializerJsonDataPrefix));
if (calculateSize() == 0) {
return false;
}
if (!mqtt->writePayload(AHATOFSTR(HASerializerJsonDataPrefix))) {
return false;
}
for (uint8_t i = 0; i < _entriesNb; i++) {
if (i > 0) {
mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertiesSeparator));
if (!mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertiesSeparator))) {
return false;
}
}
if (!flushEntry(&_entries[i])) {
@@ -299,21 +430,27 @@ bool HASerializer::flush() const
}
}
mqtt->writePayload(AHATOFSTR(HASerializerJsonDataSuffix));
return true;
return mqtt->writePayload(AHATOFSTR(HASerializerJsonDataSuffix));
}
uint16_t HASerializer::calculateEntrySize(const SerializerEntry* entry) const
{
switch (entry->type) {
case PropertyEntryType:
return
// property name
case PropertyEntryType: {
if (!entry->property) {
return 0;
}
const uint16_t valueSize = calculatePropertyValueSize(entry);
if (valueSize == 0) {
return 0;
}
const uint32_t size =
strlen_P(HASerializerJsonPropertyPrefix) +
strlen_P(AHAFROMFSTR(entry->property)) +
strlen_P(HASerializerJsonPropertySuffix) +
// property value
calculatePropertyValueSize(entry);
valueSize;
return size > UINT16_MAX ? 0 : static_cast<uint16_t>(size);
}
case TopicEntryType:
return calculateTopicEntrySize(entry);
@@ -335,56 +472,74 @@ uint16_t HASerializer::calculateTopicEntrySize(
const SerializerEntry* entry
) const
{
uint16_t size = 0;
// property name
size +=
uint32_t size =
strlen_P(HASerializerJsonPropertyPrefix) +
strlen_P(AHAFROMFSTR(entry->property)) +
strlen_P(HASerializerJsonPropertySuffix);
// topic escape
size += 2 * strlen_P(HASerializerJsonEscapeChar);
// topic
uint16_t topicSize = 0;
if (entry->value) {
size += strlen(static_cast<const char*>(entry->value));
topicSize = HAJson::calculateEscapedStringSize(
static_cast<const char*>(entry->value)
);
} else {
if (!_deviceType) {
if (!_deviceType || !_deviceType->uniqueId()) {
return 0;
}
size += calculateDataTopicLength(
const uint16_t length = calculateDataTopicLength(
_deviceType->uniqueId(),
entry->property
) - 1; // exclude null terminator
);
if (length == 0) {
return 0;
}
char topic[length];
if (!generateDataTopic(topic, _deviceType->uniqueId(), entry->property)) {
return 0;
}
topicSize = HAJson::calculateEscapedStringSize(topic);
}
return size;
if (topicSize == 0 || (size + topicSize) > UINT16_MAX) {
return 0;
}
return static_cast<uint16_t>(size + topicSize);
}
uint16_t HASerializer::calculateAvailabilityArrayEntrySize(
const SerializerEntry* entry
) const
{
if (!entry->value) {
if (!entry->value || !entry->property) {
return 0;
}
const HAAvailabilityConfig* cfg = static_cast<const HAAvailabilityConfig*>(
entry->value
);
const uint16_t jsonSize = cfg->calculateJsonSize();
if (jsonSize == 0) {
return 0;
}
return
const uint32_t size =
strlen_P(HASerializerJsonPropertyPrefix) +
strlen_P(AHAFROMFSTR(entry->property)) +
strlen_P(HASerializerJsonPropertySuffix) +
cfg->calculateJsonSize();
jsonSize;
return size > UINT16_MAX ? 0 : static_cast<uint16_t>(size);
}
uint16_t HASerializer::calculateFlagSize(const FlagType flag) const
{
const HAMqtt* mqtt = HAMqtt::instance();
if (!mqtt || !mqtt->getDevice()) {
return 0;
}
const HADevice* device = mqtt->getDevice();
if (flag == WithDevice && device->getSerializer()) {
@@ -393,26 +548,44 @@ uint16_t HASerializer::calculateFlagSize(const FlagType flag) const
return 0;
}
return
const uint32_t size =
strlen_P(HASerializerJsonPropertyPrefix) +
strlen_P(HADeviceProperty) +
strlen_P(HASerializerJsonPropertySuffix) +
deviceLength;
} else if (flag == WithUniqueId && _deviceType) {
uint16_t uniqueIdLength = strlen(_deviceType->uniqueId());
if (device->isExtendedUniqueIdsEnabled()) {
uniqueIdLength += strlen(device->getUniqueId()) + 1; // with separator
return size > UINT16_MAX ? 0 : static_cast<uint16_t>(size);
} else if (flag == WithUniqueId && _deviceType && _deviceType->uniqueId()) {
const uint16_t uniqueIdSize = HAJson::calculateEscapedStringSize(
_deviceType->uniqueId()
);
if (uniqueIdSize == 0) {
return 0;
}
return
// property name
uint32_t valueSize = uniqueIdSize;
if (device->isExtendedUniqueIdsEnabled()) {
if (!device->getUniqueId()) {
return 0;
}
const uint16_t deviceIdSize = HAJson::calculateEscapedStringSize(
device->getUniqueId()
);
if (deviceIdSize == 0) {
return 0;
}
// Both helper sizes include quotes; the combined value has one pair.
valueSize = deviceIdSize + uniqueIdSize - 1;
}
const uint32_t size =
strlen_P(HASerializerJsonPropertyPrefix) +
strlen_P(HAUniqueIdProperty) +
strlen_P(HASerializerJsonPropertySuffix) +
// property value
2 * strlen_P(HASerializerJsonEscapeChar) +
uniqueIdLength;
valueSize;
return size > UINT16_MAX ? 0 : static_cast<uint16_t>(size);
}
return 0;
@@ -426,9 +599,9 @@ uint16_t HASerializer::calculatePropertyValueSize(
case ConstCharPropertyValue:
case ProgmemPropertyValue: {
const char* value = static_cast<const char*>(entry->value);
const uint16_t len =
entry->subtype == ConstCharPropertyValue ? strlen(value) : strlen_P(value);
return 2 * strlen_P(HASerializerJsonEscapeChar) + len;
return entry->subtype == ConstCharPropertyValue
? HAJson::calculateEscapedStringSize(value)
: HAJson::calculateEscapedProgmemStringSize(value);
}
case BoolPropertyType: {
@@ -447,7 +620,7 @@ uint16_t HASerializer::calculatePropertyValueSize(
const HASerializerArray* array = static_cast<const HASerializerArray*>(
entry->value
);
return array->calculateSize();
return array ? array->calculateSize() : 0;
}
case JsonLiteralPropertyValue: {
@@ -466,9 +639,11 @@ bool HASerializer::flushEntry(const SerializerEntry* entry) const
switch (entry->type) {
case PropertyEntryType: {
mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertyPrefix));
mqtt->writePayload(entry->property);
mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertySuffix));
if (!mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertyPrefix)) ||
!mqtt->writePayload(entry->property) ||
!mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertySuffix))) {
return false;
}
return flushEntryValue(entry);
}
@@ -495,22 +670,12 @@ bool HASerializer::flushEntryValue(const SerializerEntry* entry) const
case ConstCharPropertyValue:
case ProgmemPropertyValue: {
const char* value = static_cast<const char*>(entry->value);
mqtt->writePayload(AHATOFSTR(HASerializerJsonEscapeChar));
if (entry->subtype == ConstCharPropertyValue) {
mqtt->writePayload(value, strlen(value));
} else {
mqtt->writePayload(AHATOFSTR(value));
}
mqtt->writePayload(AHATOFSTR(HASerializerJsonEscapeChar));
return true;
return writeJsonString(mqtt, value, entry->subtype == ProgmemPropertyValue);
}
case BoolPropertyType: {
const bool value = *static_cast<const bool*>(entry->value);
mqtt->writePayload(AHATOFSTR(value ? HATrue : HAFalse));
return true;
return mqtt->writePayload(AHATOFSTR(value ? HATrue : HAFalse));
}
case NumberPropertyType: {
@@ -521,21 +686,34 @@ bool HASerializer::flushEntryValue(const SerializerEntry* entry) const
char tmp[HANumeric::MaxDigitsNb + 1];
const uint16_t length = value->toStr(tmp);
mqtt->writePayload(tmp, length);
return true;
return mqtt->writePayload(tmp, length);
}
case ArrayPropertyType: {
const HASerializerArray* array = static_cast<const HASerializerArray*>(
entry->value
);
const uint16_t size = array->calculateSize();
char tmp[size + 1]; // including null terminator
tmp[0] = 0;
array->serialize(tmp);
mqtt->writePayload(tmp, size);
if (!array) {
return false;
}
return true;
const uint16_t size = array->calculateSize();
if (size == 0) {
return false;
}
char* tmp = new (std::nothrow) char[size + 1];
if (!tmp) {
return false;
}
tmp[0] = 0;
bool serialized = array->serialize(tmp);
if (serialized) {
serialized = mqtt->writePayload(tmp, size);
}
delete[] tmp;
return serialized;
}
case JsonLiteralPropertyValue: {
@@ -544,8 +722,7 @@ bool HASerializer::flushEntryValue(const SerializerEntry* entry) const
return false;
}
mqtt->writePayload(value, strlen(value));
return true;
return mqtt->writePayload(value, strlen(value));
}
default:
@@ -558,17 +735,20 @@ bool HASerializer::flushTopic(const SerializerEntry* entry) const
HAMqtt* mqtt = HAMqtt::instance();
// property name
mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertyPrefix));
mqtt->writePayload(entry->property);
mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertySuffix));
// value (escaped)
mqtt->writePayload(AHATOFSTR(HASerializerJsonEscapeChar));
if (!mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertyPrefix)) ||
!mqtt->writePayload(entry->property) ||
!mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertySuffix))) {
return false;
}
if (entry->value) {
const char* topic = static_cast<const char*>(entry->value);
mqtt->writePayload(topic, strlen(topic));
return writeJsonString(mqtt, topic, false);
} else {
if (!_deviceType || !_deviceType->uniqueId()) {
return false;
}
const uint16_t length = calculateDataTopicLength(
_deviceType->uniqueId(),
entry->property
@@ -578,82 +758,103 @@ bool HASerializer::flushTopic(const SerializerEntry* entry) const
}
char topic[length];
generateDataTopic(
if (!generateDataTopic(
topic,
_deviceType->uniqueId(),
entry->property
);
)) {
return false;
}
mqtt->writePayload(topic, length - 1);
return writeJsonString(mqtt, topic, false);
}
mqtt->writePayload(AHATOFSTR(HASerializerJsonEscapeChar));
return true;
}
bool HASerializer::flushAvailabilityArray(const SerializerEntry* entry) const
{
HAMqtt* mqtt = HAMqtt::instance();
if (!entry->value) {
if (!mqtt || !entry->value || !entry->property) {
return false;
}
const HAAvailabilityConfig* cfg = static_cast<const HAAvailabilityConfig*>(
entry->value
);
mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertyPrefix));
mqtt->writePayload(entry->property);
mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertySuffix));
const uint16_t jsonSize = cfg->calculateJsonSize();
if (jsonSize >= 512) {
if (jsonSize == 0) {
return false;
}
char buf[512];
if (!cfg->serialize(buf)) {
char* buf = new (std::nothrow) char[jsonSize + 1];
if (!buf) {
return false;
}
mqtt->writePayload(buf, jsonSize);
return true;
const bool serialized = cfg->serialize(buf);
if (!serialized) {
delete[] buf;
return false;
}
const bool written = mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertyPrefix)) &&
mqtt->writePayload(entry->property) &&
mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertySuffix)) &&
mqtt->writePayload(buf, jsonSize);
delete[] buf;
return written;
}
bool HASerializer::flushFlag(const SerializerEntry* entry) const
{
HAMqtt* mqtt = HAMqtt::instance();
if (!mqtt || !mqtt->getDevice()) {
return false;
}
const HADevice* device = mqtt->getDevice();
const FlagType flag = static_cast<FlagType>(entry->subtype);
if (flag == WithDevice && device) {
if (flag == WithDevice && device->getSerializer()) {
// property name
mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertyPrefix));
mqtt->writePayload(AHATOFSTR(HADeviceProperty));
mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertySuffix));
if (!mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertyPrefix)) ||
!mqtt->writePayload(AHATOFSTR(HADeviceProperty)) ||
!mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertySuffix))) {
return false;
}
// property value
return device->getSerializer()->flush();
} else if (flag == WithUniqueId && _deviceType) {
} else if (flag == WithUniqueId && _deviceType && _deviceType->uniqueId()) {
if (device->isExtendedUniqueIdsEnabled() && !device->getUniqueId()) {
return false;
}
// property name
mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertyPrefix));
mqtt->writePayload(AHATOFSTR(HAUniqueIdProperty));
mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertySuffix));
if (!mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertyPrefix)) ||
!mqtt->writePayload(AHATOFSTR(HAUniqueIdProperty)) ||
!mqtt->writePayload(AHATOFSTR(HASerializerJsonPropertySuffix))) {
return false;
}
// value
const char* uniqueId = _deviceType->uniqueId();
mqtt->writePayload(AHATOFSTR(HASerializerJsonEscapeChar));
const char quote = '"';
if (!mqtt->writePayload(&quote, 1)) {
return false;
}
if (device->isExtendedUniqueIdsEnabled()) {
const char* deviceUniqueId = device->getUniqueId();
mqtt->writePayload(deviceUniqueId, strlen(deviceUniqueId));
mqtt->writePayload(AHATOFSTR(HASerializerUnderscore));
if (!writeJsonStringContents(mqtt, deviceUniqueId)) {
return false;
}
const char separator = '_';
if (!mqtt->writePayload(&separator, 1)) {
return false;
}
}
mqtt->writePayload(uniqueId, strlen(uniqueId));
mqtt->writePayload(AHATOFSTR(HASerializerJsonEscapeChar));
return true;
return writeJsonStringContents(mqtt, uniqueId) &&
mqtt->writePayload(&quote, 1);
}
return false;
+38 -18
View File
@@ -3,6 +3,7 @@
#include "HASerializerArray.h"
#include "HADictionary.h"
#include "HAJson.h"
HASerializerArray::HASerializerArray(const uint8_t size, const bool progmemItems) :
_progmemItems(progmemItems),
@@ -39,24 +40,36 @@ const char* HASerializerArray::getItem(const uint8_t index) const
uint16_t HASerializerArray::calculateSize() const
{
uint16_t size =
uint32_t size =
strlen_P(HASerializerJsonArrayPrefix) +
strlen_P(HASerializerJsonArraySuffix);
if (_itemsNb == 0) {
return size;
return static_cast<uint16_t>(size);
}
// separators between elements
size += (_itemsNb - 1) * strlen_P(HASerializerJsonPropertiesSeparator);
for (uint8_t i = 0; i < _itemsNb; i++) {
size +=
2 * strlen_P(HASerializerJsonEscapeChar)
+ (_progmemItems ? strlen_P(_items[i]) : strlen(_items[i]));
if (!_items[i]) {
return 0;
}
const uint16_t itemSize = _progmemItems
? HAJson::calculateEscapedProgmemStringSize(_items[i])
: HAJson::calculateEscapedStringSize(_items[i]);
if (itemSize == 0) {
return 0;
}
size += itemSize;
if (size > UINT16_MAX) {
return 0;
}
}
return size;
return static_cast<uint16_t>(size);
}
bool HASerializerArray::serialize(char* output) const
@@ -65,26 +78,33 @@ bool HASerializerArray::serialize(char* output) const
return false;
}
strcat_P(output, HASerializerJsonArrayPrefix);
const uint16_t size = calculateSize();
if (size == 0) {
return false;
}
char* cursor = output;
char* const end = output + size;
*cursor++ = '[';
*cursor = 0;
for (uint8_t i = 0; i < _itemsNb; i++) {
if (i > 0) {
strcat_P(output, HASerializerJsonPropertiesSeparator);
*cursor++ = ',';
*cursor = 0;
}
strcat_P(output, HASerializerJsonEscapeChar);
if (_progmemItems) {
strcat_P(output, _items[i]);
} else {
strcat(output, _items[i]);
const bool serialized = _progmemItems
? HAJson::appendEscapedProgmemString(cursor, end, _items[i])
: HAJson::appendEscapedString(cursor, end, _items[i]);
if (!serialized) {
return false;
}
strcat_P(output, HASerializerJsonEscapeChar);
}
strcat_P(output, HASerializerJsonArraySuffix);
return true;
*cursor++ = ']';
*cursor = 0;
return static_cast<uint16_t>(cursor - output) == size;
}
void HASerializerArray::clear()
@@ -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}")
+235
View File
@@ -0,0 +1,235 @@
#ifndef AHA_NATIVE_ARDUINO_H
#define AHA_NATIVE_ARDUINO_H
// Minimal Arduino API shim for the host-only PlatformIO native test target.
// It is deliberately test-only: production builds continue to use each
// platform's Arduino core and PROGMEM implementation.
#include <stdint.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
#include <iostream>
#include <string>
typedef uint8_t byte;
class __FlashStringHelper;
#ifndef PROGMEM
#define PROGMEM
#endif
#ifndef PGM_P
typedef const char* PGM_P;
#endif
#define F(value) reinterpret_cast<const __FlashStringHelper*>(value)
#ifndef pgm_read_byte
#define pgm_read_byte(address) (*reinterpret_cast<const uint8_t*>(address))
#endif
#ifndef strlen_P
inline size_t strlen_P(PGM_P value)
{
return value ? strlen(value) : 0;
}
#endif
#ifndef strcpy_P
inline char* strcpy_P(char* destination, PGM_P source)
{
return strcpy(destination, source);
}
#endif
#ifndef strncpy_P
inline char* strncpy_P(char* destination, PGM_P source, size_t count)
{
return strncpy(destination, source, count);
}
#endif
#ifndef strcat_P
inline char* strcat_P(char* destination, PGM_P source)
{
return strcat(destination, source);
}
#endif
#ifndef strcmp_P
inline int strcmp_P(const char* left, PGM_P right)
{
return strcmp(left, right);
}
#endif
#ifndef memcpy_P
inline void* memcpy_P(void* destination, PGM_P source, size_t count)
{
return memcpy(destination, source, count);
}
#endif
class String
{
public:
String() = default;
String(const char* value) :
_value(value ? value : "")
{
}
String(const __FlashStringHelper* value) :
_value(value ? reinterpret_cast<const char*>(value) : "")
{
}
String(const std::string& value) :
_value(value)
{
}
String(char value) :
_value(1, value)
{
}
String(bool value) :
_value(value ? "1" : "0")
{
}
String(int value) : _value(std::to_string(value)) { }
String(unsigned int value) : _value(std::to_string(value)) { }
String(long value) : _value(std::to_string(value)) { }
String(unsigned long value) : _value(std::to_string(value)) { }
String(long long value) : _value(std::to_string(value)) { }
String(unsigned long long value) : _value(std::to_string(value)) { }
String(float value) : _value(std::to_string(value)) { }
String(double value) : _value(std::to_string(value)) { }
const char* c_str() const
{
return _value.c_str();
}
size_t length() const
{
return _value.length();
}
String& operator+=(const String& value)
{
_value += value._value;
return *this;
}
String& operator+=(const char* value)
{
_value += value ? value : "";
return *this;
}
String& operator+=(const __FlashStringHelper* value)
{
_value += value ? reinterpret_cast<const char*>(value) : "";
return *this;
}
String operator+(const String& value) const
{
return String(_value + value._value);
}
String operator+(const char* value) const
{
return String(_value + (value ? value : ""));
}
String operator+(const __FlashStringHelper* value) const
{
return String(_value + (value ? reinterpret_cast<const char*>(value) : ""));
}
private:
std::string _value;
};
inline String operator+(const char* left, const String& right)
{
return String(left) + right;
}
inline String operator+(const __FlashStringHelper* left, const String& right)
{
return String(left) + right;
}
class NativeSerial
{
public:
void begin(unsigned long) { }
template<typename T>
size_t print(const T& value)
{
std::cout << value;
return 1;
}
size_t print(const __FlashStringHelper* value)
{
std::cout << reinterpret_cast<const char*>(value);
return 1;
}
size_t print(const String& value)
{
std::cout << value.c_str();
return value.length();
}
template<typename T>
size_t println(const T& value)
{
print(value);
std::cout << '\n';
return 1;
}
size_t println()
{
std::cout << '\n';
return 1;
}
};
static NativeSerial Serial;
inline uint32_t& nativeArduinoMillisStorage()
{
static uint32_t value = 0;
return value;
}
inline unsigned long millis()
{
return nativeArduinoMillisStorage();
}
inline void delay(unsigned long duration)
{
nativeArduinoMillisStorage() += static_cast<uint32_t>(duration);
}
inline void yield() { }
#endif
+10
View File
@@ -0,0 +1,10 @@
#ifndef AHA_NATIVE_CLIENT_H
#define AHA_NATIVE_CLIENT_H
class Client
{
public:
virtual ~Client() = default;
};
#endif
+33
View File
@@ -0,0 +1,33 @@
#ifndef AHA_NATIVE_IPADDRESS_H
#define AHA_NATIVE_IPADDRESS_H
#include <Arduino.h>
class IPAddress
{
public:
IPAddress() :
_octets{0, 0, 0, 0}
{
}
IPAddress(uint8_t a, uint8_t b, uint8_t c, uint8_t d) :
_octets{a, b, c, d}
{
}
String toString() const
{
return String(static_cast<unsigned int>(_octets[0])) + "." +
String(static_cast<unsigned int>(_octets[1])) + "." +
String(static_cast<unsigned int>(_octets[2])) + "." +
String(static_cast<unsigned int>(_octets[3]));
}
private:
uint8_t _octets[4];
};
#endif
+109
View File
@@ -0,0 +1,109 @@
#include <Arduino.h>
#include <unity.h>
#include <ArduinoHA.h>
#include "mocks/PubSubClientMock.h"
using TestFn = void (*)(void);
struct TestCase {
const char* name;
TestFn fn;
uint16_t line;
};
#define TEST_ENTRY(fn) { #fn, fn, __LINE__ }
static void flushDeviceSerializer(
PubSubClientMock* mock,
const HADevice& device
)
{
mock->connectDummy();
const HASerializer* serializer = device.getSerializer();
TEST_ASSERT_NOT_NULL(serializer);
TEST_ASSERT_TRUE(mock->beginPublish("test/device/config", serializer->calculateSize(), true));
TEST_ASSERT_TRUE(serializer->flush());
TEST_ASSERT_TRUE(mock->endPublish());
}
void test_DeviceMetadata_add_connection_escapes_json()
{
PubSubClientMock* mock = new PubSubClientMock();
HADevice device("testDevice");
HAMqtt mqtt(mock, device);
TEST_ASSERT_TRUE(device.addConnection("mac", "aa\"\\bb\n"));
flushDeviceSerializer(mock, device);
TEST_ASSERT_EQUAL_UINT8(1, mock->getFlushedMessagesNb());
TEST_ASSERT_EQUAL_STRING(
"{\"ids\":\"testDevice\",\"cns\":[[\"mac\",\"aa\\\"\\\\bb\\n\"]]}",
mock->getFlushedMessages()[0]->buffer
);
}
void test_DeviceMetadata_raw_connections_reject_malformed_input()
{
PubSubClientMock* mock = new PubSubClientMock();
HADevice device("testDevice");
HAMqtt mqtt(mock, device);
TEST_ASSERT_FALSE(device.setConnectionsJson("[[\"mac\",invalid]]"));
flushDeviceSerializer(mock, device);
TEST_ASSERT_EQUAL_STRING(
"{\"ids\":\"testDevice\"}",
mock->getFlushedMessages()[0]->buffer
);
}
void test_DeviceMetadata_raw_connections_accept_complete_tuple_array()
{
PubSubClientMock* mock = new PubSubClientMock();
HADevice device("testDevice");
HAMqtt mqtt(mock, device);
TEST_ASSERT_TRUE(device.setConnectionsJson("[[\"mac\",\"aa:bb\"],[\"serial\",\"42\"]]"));
flushDeviceSerializer(mock, device);
TEST_ASSERT_EQUAL_STRING(
"{\"ids\":\"testDevice\",\"cns\":[[\"mac\",\"aa:bb\"],[\"serial\",\"42\"]]}",
mock->getFlushedMessages()[0]->buffer
);
}
static TestCase tests[] = {
TEST_ENTRY(test_DeviceMetadata_add_connection_escapes_json),
TEST_ENTRY(test_DeviceMetadata_raw_connections_reject_malformed_input),
TEST_ENTRY(test_DeviceMetadata_raw_connections_accept_complete_tuple_array),
};
static const size_t testCount = sizeof(tests) / sizeof(tests[0]);
static size_t nextTest = 0;
static bool begun = false;
void setUp(void) { }
void tearDown(void) { }
void setup()
{
Serial.begin(115200);
delay(500);
UNITY_BEGIN();
begun = true;
}
void loop()
{
if (begun && nextTest < testCount) {
TestCase& test = tests[nextTest++];
UnityDefaultTestRun(test.fn, test.name, test.line);
return;
}
if (begun) {
UNITY_END();
begun = false;
}
}
+4
View File
@@ -125,6 +125,10 @@ static TestCase tests[] = {
TEST_ENTRY(test_TextTest_publish_state_debounce),
TEST_ENTRY(test_TextTest_callback_publish_is_deferred_until_after_dispatch),
TEST_ENTRY(test_TextTest_retain_setter),
TEST_ENTRY(test_TextTest_current_state_is_owned),
TEST_ENTRY(test_TextTest_oversized_state_is_rejected),
TEST_ENTRY(test_TextTest_callback_state_is_owned_after_dispatch),
TEST_ENTRY(test_TextTest_oversized_command_is_ignored),
};
static const size_t TEST_COUNT = sizeof(tests) / sizeof(tests[0]);
+4
View File
@@ -135,5 +135,9 @@ extern void test_TextTest_publish_state(void);
extern void test_TextTest_publish_state_debounce(void);
extern void test_TextTest_retain_setter(void);
extern void test_TextTest_callback_publish_is_deferred_until_after_dispatch(void);
extern void test_TextTest_current_state_is_owned(void);
extern void test_TextTest_oversized_state_is_rejected(void);
extern void test_TextTest_callback_state_is_owned_after_dispatch(void);
extern void test_TextTest_oversized_command_is_ignored(void);
#endif
@@ -110,7 +110,6 @@ void test_BinarySensorTest_object_id_setter(void) {
sensor,
(
"{"
"\"obj_id\":\"testId\","
"\"uniq_id\":\"uniqueSensor\","
"\"dev\":{\"ids\":\"testDevice\"},"
"\"stat_t\":\"testData/testDevice/uniqueSensor/stat_t\""
@@ -139,7 +139,6 @@ void test_ButtonTest_object_id_setter(void) {
button,
(
"{"
"\"obj_id\":\"testId\","
"\"uniq_id\":\"uniqueButton\","
"\"dev\":{\"ids\":\"testDevice\"},"
"\"cmd_t\":\"testData/testDevice/uniqueButton/cmd_t\""
@@ -100,7 +100,6 @@ void test_SensorTest_object_id_setter(void) {
sensor,
(
"{"
"\"obj_id\":\"testId\","
"\"uniq_id\":\"uniqueSensor\","
"\"dev\":{\"ids\":\"testDevice\"},"
"\"stat_t\":\"testData/testDevice/uniqueSensor/stat_t\""
@@ -110,7 +110,7 @@ void test_SwitchTest_device_discovery_payload(void) {
(
"{"
"\"dev\":{\"ids\":\"testDevice\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"2.1.0\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"3.0.2\"},"
"\"cmps\":{"
"\"uniqueSwitch\":{"
"\"p\":\"switch\","
@@ -206,7 +206,6 @@ void test_SwitchTest_object_id_setter(void) {
testSwitch,
(
"{"
"\"obj_id\":\"testId\","
"\"uniq_id\":\"uniqueSwitch\","
"\"dev\":{\"ids\":\"testDevice\"},"
"\"stat_t\":\"testData/testDevice/uniqueSwitch/stat_t\","
+60 -1
View File
@@ -51,6 +51,14 @@ void onCommandDeferredPublish(const char* value, HAText* caller)
TEST_ASSERT_TRUE(caller->setState(value));
}
void onCommandSetStateAndOverwrite(const char* value, HAText* caller)
{
TEST_ASSERT_TRUE(caller->setState(value));
char* mutableValue = const_cast<char*>(value);
mutableValue[0] = 'x';
TEST_ASSERT_EQUAL_STRING("hello", caller->getCurrentState());
}
void test_TextTest_invalid_unique_id(void) {
prepareTest
@@ -178,7 +186,6 @@ void test_TextTest_object_id_setter(void) {
text,
(
"{"
"\"obj_id\":\"testId\","
"\"uniq_id\":\"uniqueText\","
"\"dev\":{\"ids\":\"testDevice\"},"
"\"stat_t\":\"testData/testDevice/uniqueText/stat_t\","
@@ -318,6 +325,32 @@ void test_TextTest_publish_state_debounce(void) {
TEST_ASSERT_EQUAL(0, mock->getFlushedMessagesNb());
}
void test_TextTest_current_state_is_owned(void) {
prepareTest
HAText text(testUniqueId);
char state[] = "initial";
text.setCurrentState(state);
state[0] = 'x';
TEST_ASSERT_EQUAL_STRING("initial", text.getCurrentState());
}
void test_TextTest_oversized_state_is_rejected(void) {
prepareTest
HAText text(testUniqueId);
text.setCurrentState("initial");
char state[HAText::MaxCommandLength + 2];
memset(state, 'x', sizeof(state) - 1);
state[sizeof(state) - 1] = 0;
text.setCurrentState(state);
TEST_ASSERT_EQUAL_STRING("initial", text.getCurrentState());
TEST_ASSERT_FALSE(text.setState(state));
TEST_ASSERT_EQUAL_STRING("initial", text.getCurrentState());
}
void test_TextTest_command_callback(void) {
prepareTest
@@ -343,6 +376,32 @@ void test_TextTest_callback_publish_is_deferred_until_after_dispatch(void) {
AHA_ASSERT_MQTT_MESSAGE(mock, 0, AHATOFSTR(StateTopic), "hello", true);
}
void test_TextTest_callback_state_is_owned_after_dispatch(void) {
prepareTest
mock->connectDummy();
HAText text(testUniqueId);
text.onCommand(onCommandSetStateAndOverwrite);
mock->fakeMessage(AHATOFSTR(CommandTopic), F("hello"));
TEST_ASSERT_EQUAL_STRING("hello", text.getCurrentState());
}
void test_TextTest_oversized_command_is_ignored(void) {
prepareTest
HAText text(testUniqueId);
text.onCommand(onCommandReceived);
char command[HAText::MaxCommandLength + 2];
memset(command, 'x', sizeof(command) - 1);
command[sizeof(command) - 1] = 0;
mock->fakeMessage(AHATOFSTR(CommandTopic), command);
assertCommandCallbackNotCalled()
}
void test_TextTest_different_text_command(void) {
prepareTest
@@ -66,7 +66,7 @@ void test_CameraTest_device_discovery_payload(void) {
(
"{"
"\"dev\":{\"ids\":\"testDevice\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"2.1.0\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"3.0.2\"},"
"\"cmps\":{"
"\"uniqueCamera\":{"
"\"p\":\"camera\","
@@ -127,7 +127,6 @@ void test_CameraTest_object_id_setter(void) {
camera,
(
"{"
"\"obj_id\":\"testId\","
"\"uniq_id\":\"uniqueCamera\","
"\"dev\":{\"ids\":\"testDevice\"},"
"\"t\":\"testData/testDevice/uniqueCamera/t\""
@@ -104,7 +104,7 @@ void test_CoverTest_device_discovery_payload(void) {
(
"{"
"\"dev\":{\"ids\":\"testDevice\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"2.1.0\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"3.0.2\"},"
"\"cmps\":{"
"\"uniqueCover\":{"
"\"p\":\"cover\","
@@ -234,7 +234,6 @@ void test_CoverTest_object_id_setter(void) {
cover,
(
"{"
"\"obj_id\":\"testId\","
"\"uniq_id\":\"uniqueCover\","
"\"dev\":{\"ids\":\"testDevice\"},"
"\"stat_t\":\"testData/testDevice/uniqueCover/stat_t\","
@@ -136,7 +136,7 @@ void test_FanTest_device_discovery_payload(void) {
(
"{"
"\"dev\":{\"ids\":\"testDevice\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"2.1.0\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"3.0.2\"},"
"\"cmps\":{"
"\"uniqueFan\":{"
"\"p\":\"fan\","
@@ -279,7 +279,6 @@ void test_FanTest_object_id_setter(void) {
fan,
(
"{"
"\"obj_id\":\"testId\","
"\"uniq_id\":\"uniqueFan\","
"\"dev\":{\"ids\":\"testDevice\"},"
"\"stat_t\":\"testData/testDevice/uniqueFan/stat_t\","
@@ -257,7 +257,7 @@ void test_HVACTest_device_discovery_payload(void) {
(
"{"
"\"dev\":{\"ids\":\"testDevice\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"2.1.0\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"3.0.2\"},"
"\"cmps\":{"
"\"uniqueHVAC\":{"
"\"p\":\"climate\","
@@ -579,7 +579,6 @@ void test_HVACTest_object_id_setter(void) {
hvac,
(
"{"
"\"obj_id\":\"testId\","
"\"uniq_id\":\"uniqueHVAC\","
"\"curr_temp_t\":\"testData/testDevice/uniqueHVAC/curr_temp_t\","
"\"dev\":{\"ids\":\"testDevice\"}"
@@ -429,7 +429,6 @@ void test_LightTest_object_id_setter(void) {
light,
(
"{"
"\"obj_id\":\"testId\","
"\"uniq_id\":\"uniqueLight\","
"\"dev\":{\"ids\":\"testDevice\"},"
"\"stat_t\":\"testData/testDevice/uniqueLight/stat_t\","
@@ -103,7 +103,7 @@ void test_LockTest_device_discovery_payload(void) {
(
"{"
"\"dev\":{\"ids\":\"testDevice\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"2.1.0\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"3.0.2\"},"
"\"cmps\":{"
"\"uniqueLock\":{"
"\"p\":\"lock\","
@@ -198,7 +198,6 @@ void test_LockTest_object_id_setter(void) {
lock,
(
"{"
"\"obj_id\":\"testId\","
"\"uniq_id\":\"uniqueLock\","
"\"dev\":{\"ids\":\"testDevice\"},"
"\"stat_t\":\"testData/testDevice/uniqueLock/stat_t\","
@@ -68,7 +68,7 @@ void test_DeviceTrackerTest_device_discovery_payload(void) {
(
"{"
"\"dev\":{\"ids\":\"testDevice\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"2.1.0\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"3.0.2\"},"
"\"cmps\":{"
"\"uniqueTracker\":{"
"\"p\":\"device_tracker\","
@@ -215,7 +215,6 @@ void test_DeviceTrackerTest_object_id_setter(void) {
tracker,
(
"{"
"\"obj_id\":\"testId\","
"\"uniq_id\":\"uniqueTracker\","
"\"dev\":{\"ids\":\"testDevice\"},"
"\"stat_t\":\"testData/testDevice/uniqueTracker/stat_t\""
@@ -99,7 +99,7 @@ void test_DeviceTriggerTest_device_discovery_payload(void) {
(
"{"
"\"dev\":{\"ids\":\"testDevice\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"2.1.0\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"3.0.2\"},"
"\"cmps\":{"
"\"myType_mySubtype\":{"
"\"p\":\"device_automation\","
+1 -2
View File
@@ -95,7 +95,7 @@ void test_SceneTest_device_discovery_payload(void) {
(
"{"
"\"dev\":{\"ids\":\"testDevice\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"2.1.0\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"3.0.2\"},"
"\"cmps\":{"
"\"uniqueScene\":{"
"\"p\":\"scene\","
@@ -167,7 +167,6 @@ void test_SceneTest_object_id_setter(void) {
scene,
(
"{"
"\"obj_id\":\"testId\","
"\"uniq_id\":\"uniqueScene\","
"\"pl_on\":\"ON\","
"\"cmd_t\":\"testData/testDevice/uniqueScene/cmd_t\""
@@ -46,7 +46,7 @@ void test_TagScannerTest_device_discovery_payload(void) {
(
"{"
"\"dev\":{\"ids\":\"testDevice\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"2.1.0\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"3.0.2\"},"
"\"cmps\":{"
"\"uniqueScanner\":{"
"\"p\":\"tag\","
@@ -351,7 +351,6 @@ void test_NumberTest_object_id_setter(void) {
number,
(
"{"
"\"obj_id\":\"testId\","
"\"uniq_id\":\"uniqueNumber\","
"\"dev\":{\"ids\":\"testDevice\"},"
"\"stat_t\":\"testData/testDevice/uniqueNumber/stat_t\","
@@ -926,7 +925,7 @@ void test_NumberTest_update_min_max_step_republishes_device_discovery_when_enabl
(
"{"
"\"dev\":{\"ids\":\"testDevice\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"2.1.0\"},"
"\"o\":{\"name\":\"ArduinoHA\",\"sw\":\"3.0.2\"},"
"\"cmps\":{"
"\"uniqueNumber\":{"
"\"p\":\"number\","
@@ -280,7 +280,6 @@ void test_SelectTest_object_id_setter(void) {
select,
(
"{"
"\"obj_id\":\"testId\","
"\"uniq_id\":\"uniqueSelect\","
"\"options\":[\"Option A\",\"B\",\"C\"],"
"\"dev\":{\"ids\":\"testDevice\"},"
+355
View File
@@ -0,0 +1,355 @@
#include <Arduino.h>
#include <string.h>
#include <unity.h>
#include "HADevice.h"
#include "HAMqtt.h"
#include "device-types/HABaseDeviceType.h"
#include "device-types/HAText.h"
#include "mocks/PubSubClientMock.h"
#include "utils/HAAvailabilityConfig.h"
#include "utils/HAJson.h"
#include "utils/HASerializer.h"
#include "utils/HASerializerArray.h"
namespace {
class NativeSerializerEntity : public HABaseDeviceType
{
public:
explicit NativeSerializerEntity(const char* uniqueId) :
HABaseDeviceType(F("sensor"), uniqueId)
{
}
protected:
void onMqttConnected() override { }
};
class NativeDiscoveryEntity : public HABaseDeviceType
{
public:
explicit NativeDiscoveryEntity(const char* uniqueId) :
HABaseDeviceType(F("sensor"), uniqueId)
{
}
protected:
void buildSerializer() override
{
if (_serializer) {
return;
}
_serializer = new HASerializer(this, 2);
_serializer->set(F("name"), uniqueId());
_serializer->set(HASerializer::WithUniqueId);
}
HASerializer* buildDeviceDiscoverySerializer() override
{
HASerializer* serializer = new HASerializer(this, 3);
serializer->set(
F("p"),
componentName(),
HASerializer::ProgmemPropertyValue
);
serializer->set(F("name"), uniqueId());
serializer->set(HASerializer::WithUniqueId);
return serializer;
}
bool supportsDeviceDiscovery() const override
{
return true;
}
void onMqttConnected() override { }
};
uint8_t disconnectedCallbackCalls = 0;
uint8_t stateCallbackCalls = 0;
void onNativeDisconnected()
{
disconnectedCallbackCalls++;
}
void onNativeStateChanged(HAMqtt::ConnectionState)
{
stateCallbackCalls++;
}
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] = {};
char* cursor = output;
TEST_ASSERT_EQUAL_UINT16(
strlen("\"quote\\\" slash\\\\ newline\\n tab\\t control\\u0001\""),
HAJson::calculateEscapedStringSize(value)
);
TEST_ASSERT_TRUE(HAJson::appendEscapedString(cursor, output + sizeof(output) - 1, value));
TEST_ASSERT_EQUAL_STRING(
"\"quote\\\" slash\\\\ newline\\n tab\\t control\\u0001\"",
output
);
TEST_ASSERT_EQUAL_PTR(output + strlen(output), cursor);
}
void test_availability_and_serializer_array_escape_json_values()
{
HAAvailabilityConfig availability;
TEST_ASSERT_TRUE(availability.add("availability/\"main\"", "{{ value_json.\\n }}", "on\nline", "off\\line"));
const uint16_t availabilitySize = availability.calculateJsonSize();
char availabilityJson[availabilitySize + 1];
TEST_ASSERT_TRUE(availability.serialize(availabilityJson));
TEST_ASSERT_EQUAL_STRING(
"[{\"t\":\"availability/\\\"main\\\"\",\"val_tpl\":\"{{ value_json.\\\\n }}\",\"pl_avail\":\"on\\nline\",\"pl_not_avail\":\"off\\\\line\"}]",
availabilityJson
);
TEST_ASSERT_EQUAL_UINT16(strlen(availabilityJson), availabilitySize);
HASerializerArray values(2, false);
TEST_ASSERT_TRUE(values.add("first\"item"));
TEST_ASSERT_TRUE(values.add("second\nitem"));
const uint16_t valuesSize = values.calculateSize();
char valuesJson[valuesSize + 1];
TEST_ASSERT_TRUE(values.serialize(valuesJson));
TEST_ASSERT_EQUAL_STRING("[\"first\\\"item\",\"second\\nitem\"]", valuesJson);
TEST_ASSERT_EQUAL_UINT16(strlen(valuesJson), valuesSize);
}
void test_discovery_topic_tokens_are_rejected_before_topic_generation()
{
HADevice device("native_device");
PubSubClientMock* mock = new PubSubClientMock();
HAMqtt mqtt(mock, device);
const __FlashStringHelper* component = F("sensor");
const uint16_t topicSize = HASerializer::calculateConfigTopicLength(component, "valid_entity-1");
char topic[topicSize];
TEST_ASSERT_TRUE(HASerializer::generateConfigTopic(topic, component, "valid_entity-1"));
TEST_ASSERT_EQUAL_STRING("homeassistant/sensor/native_device/valid_entity-1/config", topic);
TEST_ASSERT_EQUAL_UINT16(0, HASerializer::calculateConfigTopicLength(component, "invalid/entity"));
TEST_ASSERT_FALSE(HASerializer::generateConfigTopic(topic, component, "invalid entity"));
}
void test_streaming_serializer_writes_exact_escaped_payload_to_mqtt_mock()
{
HADevice device("native_device");
PubSubClientMock* mock = new PubSubClientMock();
HAMqtt mqtt(mock, device);
NativeSerializerEntity entity("native_entity");
TEST_ASSERT_TRUE(mqtt.begin("native-host"));
TEST_ASSERT_TRUE(mock->connectDummy());
HASerializer serializer(&entity, 2);
serializer.set(F("name"), "Name \"quoted\"\\line\nnext");
serializer.set(F("unit_of_meas"), "C\tunit");
const uint16_t payloadSize = serializer.calculateSize();
TEST_ASSERT_NOT_EQUAL(0, payloadSize);
TEST_ASSERT_TRUE(mqtt.beginPublish("native/serializer", payloadSize, true));
TEST_ASSERT_TRUE(serializer.flush());
TEST_ASSERT_TRUE(mqtt.endPublish());
TEST_ASSERT_EQUAL_UINT8(1, mock->getFlushedMessagesNb());
MqttMessage* message = mock->getFlushedMessages()[0];
TEST_ASSERT_EQUAL_STRING("native/serializer", message->topic);
TEST_ASSERT_EQUAL_STRING(
"{\"name\":\"Name \\\"quoted\\\"\\\\line\\nnext\",\"unit_of_meas\":\"C\\tunit\"}",
message->buffer
);
TEST_ASSERT_EQUAL_UINT16(strlen(message->buffer), message->writtenSize);
TEST_ASSERT_EQUAL_UINT16(payloadSize, message->writtenSize);
}
void test_staged_migration_orders_markers_device_payload_and_legacy_cleanup()
{
HADevice device("native_device");
PubSubClientMock* mock = new PubSubClientMock();
HAMqtt mqtt(mock, device);
NativeDiscoveryEntity first("first");
NativeDiscoveryEntity second("second");
TEST_ASSERT_TRUE(mock->connectDummy());
TEST_ASSERT_TRUE(mqtt.beginDeviceDiscoveryMigration());
TEST_ASSERT_EQUAL_INT(HAMqtt::DeviceDiscoveryMigrationMarkersPending,
mqtt.getDeviceDiscoveryMigrationState());
TEST_ASSERT_FALSE(mqtt.publishDeviceDiscovery());
TEST_ASSERT_EQUAL_UINT8(0, mock->getFlushedMessagesNb());
TEST_ASSERT_TRUE(mqtt.publishDeviceDiscoveryMigrationMarkers());
TEST_ASSERT_EQUAL_UINT8(2, mock->getFlushedMessagesNb());
TEST_ASSERT_EQUAL_STRING("homeassistant/sensor/native_device/first/config",
mock->getFlushedMessages()[0]->topic);
TEST_ASSERT_EQUAL_STRING("{\"migrate_discovery\":true}",
mock->getFlushedMessages()[0]->buffer);
TEST_ASSERT_EQUAL_STRING("homeassistant/sensor/native_device/second/config",
mock->getFlushedMessages()[1]->topic);
TEST_ASSERT_TRUE(mqtt.publishDeviceDiscoveryMigrationConfig());
TEST_ASSERT_EQUAL_UINT8(3, mock->getFlushedMessagesNb());
TEST_ASSERT_EQUAL_STRING("homeassistant/device/native_device/config",
mock->getFlushedMessages()[2]->topic);
TEST_ASSERT_NOT_NULL(strstr(mock->getFlushedMessages()[2]->buffer, "\"cmps\""));
TEST_ASSERT_NOT_NULL(strstr(mock->getFlushedMessages()[2]->buffer, "\"first\""));
TEST_ASSERT_NOT_NULL(strstr(mock->getFlushedMessages()[2]->buffer, "\"second\""));
TEST_ASSERT_TRUE(mqtt.completeDeviceDiscoveryMigration());
TEST_ASSERT_EQUAL_UINT8(5, mock->getFlushedMessagesNb());
TEST_ASSERT_EQUAL_STRING("homeassistant/sensor/native_device/first/config",
mock->getFlushedMessages()[3]->topic);
TEST_ASSERT_EQUAL_STRING("", mock->getFlushedMessages()[3]->buffer);
TEST_ASSERT_EQUAL_STRING("homeassistant/sensor/native_device/second/config",
mock->getFlushedMessages()[4]->topic);
TEST_ASSERT_EQUAL_STRING("", mock->getFlushedMessages()[4]->buffer);
TEST_ASSERT_EQUAL_INT(HAMqtt::DeviceDiscoveryMigrationCompleted,
mqtt.getDeviceDiscoveryMigrationState());
TEST_ASSERT_TRUE(mqtt.rollbackDeviceDiscoveryMigration());
TEST_ASSERT_EQUAL_UINT8(9, mock->getFlushedMessagesNb());
TEST_ASSERT_EQUAL_STRING("homeassistant/device/native_device/config",
mock->getFlushedMessages()[5]->topic);
TEST_ASSERT_EQUAL_STRING("{\"migrate_discovery\":true}",
mock->getFlushedMessages()[5]->buffer);
TEST_ASSERT_EQUAL_STRING("homeassistant/sensor/native_device/first/config",
mock->getFlushedMessages()[6]->topic);
TEST_ASSERT_EQUAL_STRING("homeassistant/sensor/native_device/second/config",
mock->getFlushedMessages()[7]->topic);
TEST_ASSERT_EQUAL_STRING("homeassistant/device/native_device/config",
mock->getFlushedMessages()[8]->topic);
TEST_ASSERT_EQUAL_STRING("", mock->getFlushedMessages()[8]->buffer);
TEST_ASSERT_EQUAL_INT(HAMqtt::DeviceDiscoveryMigrationIdle,
mqtt.getDeviceDiscoveryMigrationState());
TEST_ASSERT_FALSE(mqtt.isDeviceDiscoveryEnabled());
}
void test_failed_rollback_stays_staged_until_it_can_complete()
{
HADevice device("native_device");
PubSubClientMock* mock = new PubSubClientMock();
HAMqtt mqtt(mock, device);
NativeDiscoveryEntity entity("entity");
TEST_ASSERT_TRUE(mock->connectDummy());
TEST_ASSERT_TRUE(mqtt.beginDeviceDiscoveryMigration());
TEST_ASSERT_TRUE(mqtt.publishDeviceDiscoveryMigrationMarkers());
TEST_ASSERT_TRUE(mqtt.publishDeviceDiscoveryMigrationConfig());
mock->failNextBeginPublish();
TEST_ASSERT_FALSE(mqtt.rollbackDeviceDiscoveryMigration());
TEST_ASSERT_EQUAL_INT(HAMqtt::DeviceDiscoveryMigrationRollbackPending,
mqtt.getDeviceDiscoveryMigrationState());
TEST_ASSERT_TRUE(mqtt.isDeviceDiscoveryMigrationInProgress());
TEST_ASSERT_FALSE(mqtt.publishDeviceDiscovery());
TEST_ASSERT_TRUE(mqtt.rollbackDeviceDiscoveryMigration());
TEST_ASSERT_EQUAL_INT(HAMqtt::DeviceDiscoveryMigrationIdle,
mqtt.getDeviceDiscoveryMigrationState());
TEST_ASSERT_FALSE(mqtt.isDeviceDiscoveryEnabled());
}
void test_device_component_removal_uses_marker_then_omission_and_readd()
{
HADevice device("native_device");
PubSubClientMock* mock = new PubSubClientMock();
HAMqtt mqtt(mock, device);
NativeDiscoveryEntity first("first");
NativeDiscoveryEntity second("second");
TEST_ASSERT_TRUE(mock->connectDummy());
mqtt.enableDeviceDiscovery();
TEST_ASSERT_TRUE(first.removeFromDiscovery());
TEST_ASSERT_TRUE(first.isRemovedFromDeviceDiscovery());
TEST_ASSERT_EQUAL_UINT8(2, mock->getFlushedMessagesNb());
TEST_ASSERT_NOT_NULL(strstr(mock->getFlushedMessages()[0]->buffer,
"\"first\":{\"p\":\"sensor\"}"));
TEST_ASSERT_NULL(strstr(mock->getFlushedMessages()[1]->buffer, "\"first\""));
TEST_ASSERT_NOT_NULL(strstr(mock->getFlushedMessages()[1]->buffer, "\"second\""));
TEST_ASSERT_TRUE(first.republishDiscovery());
TEST_ASSERT_FALSE(first.isRemovedFromDeviceDiscovery());
TEST_ASSERT_EQUAL_UINT8(3, mock->getFlushedMessagesNb());
TEST_ASSERT_NOT_NULL(strstr(mock->getFlushedMessages()[2]->buffer, "\"first\""));
}
void test_entities_before_or_after_mqtt_have_safe_registration_lifetimes()
{
NativeDiscoveryEntity before("before");
HADevice device("native_device");
PubSubClientMock* mock = new PubSubClientMock();
HAMqtt mqtt(mock, device, 2);
TEST_ASSERT_EQUAL_UINT8(1, mqtt.getRegisteredDeviceTypeCount());
NativeDiscoveryEntity* after = new NativeDiscoveryEntity("after");
TEST_ASSERT_EQUAL_UINT8(2, mqtt.getRegisteredDeviceTypeCount());
delete after;
TEST_ASSERT_EQUAL_UINT8(1, mqtt.getRegisteredDeviceTypeCount());
}
void test_text_state_is_bounded_and_retains_the_last_valid_value()
{
HADevice device("native_device");
PubSubClientMock* mock = new PubSubClientMock();
HAMqtt mqtt(mock, device);
HAText text("text");
char oversizedState[HAText::MaxCommandLength + 2];
memset(oversizedState, 'x', sizeof(oversizedState) - 1);
oversizedState[sizeof(oversizedState) - 1] = 0;
text.setCurrentState("initial");
text.setCurrentState(oversizedState);
TEST_ASSERT_EQUAL_STRING("initial", text.getCurrentState());
TEST_ASSERT_FALSE(text.setState(oversizedState));
TEST_ASSERT_EQUAL_STRING("initial", text.getCurrentState());
}
void test_registration_cap_and_explicit_disconnect_are_reported()
{
HADevice device("native_device");
PubSubClientMock* mock = new PubSubClientMock();
HAMqtt mqtt(mock, device, 1);
NativeDiscoveryEntity first("first");
NativeDiscoveryEntity second("second");
TEST_ASSERT_EQUAL_UINT8(1, mqtt.getRegisteredDeviceTypeCount());
TEST_ASSERT_EQUAL_UINT16(1, mqtt.getDeviceTypeRegistrationFailures());
disconnectedCallbackCalls = 0;
stateCallbackCalls = 0;
mqtt.onDisconnected(onNativeDisconnected);
mqtt.onStateChanged(onNativeStateChanged);
TEST_ASSERT_TRUE(mqtt.begin("native-host"));
TEST_ASSERT_TRUE(mock->connectDummy());
mock->setState(HAMqtt::StateConnected);
mqtt.loop();
TEST_ASSERT_TRUE(mqtt.disconnect());
TEST_ASSERT_EQUAL_UINT8(1, disconnectedCallbackCalls);
TEST_ASSERT_TRUE(stateCallbackCalls >= 2);
}
} // namespace
void setUp(void) { }
void tearDown(void) { }
int main(int, char**)
{
UNITY_BEGIN();
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);
RUN_TEST(test_staged_migration_orders_markers_device_payload_and_legacy_cleanup);
RUN_TEST(test_failed_rollback_stays_staged_until_it_can_complete);
RUN_TEST(test_device_component_removal_uses_marker_then_omission_and_readd);
RUN_TEST(test_entities_before_or_after_mqtt_have_safe_registration_lifetimes);
RUN_TEST(test_text_state_is_bounded_and_retains_the_last_valid_value);
RUN_TEST(test_registration_cap_and_explicit_disconnect_are_reported);
return UNITY_END();
}
+86
View File
@@ -0,0 +1,86 @@
#include <Arduino.h>
#include <string.h>
#include <unity.h>
#include "utils/HAJson.h"
using TestFn = void (*)(void);
struct TestCase {
const char* name;
TestFn fn;
uint16_t line;
};
#define TEST_ENTRY(fn) { #fn, fn, __LINE__ }
void test_HAJson_escapes_all_required_json_characters()
{
const char value[] = "quote\" slash\\ newline\n tab\t control\x01";
char output[96] = {};
char* cursor = output;
TEST_ASSERT_EQUAL_UINT16(
strlen("\"quote\\\" slash\\\\ newline\\n tab\\t control\\u0001\""),
HAJson::calculateEscapedStringSize(value)
);
TEST_ASSERT_TRUE(HAJson::appendEscapedString(cursor, output + sizeof(output) - 1, value));
TEST_ASSERT_EQUAL_STRING(
"\"quote\\\" slash\\\\ newline\\n tab\\t control\\u0001\"",
output
);
}
void test_HAJson_rejects_too_small_output_buffer_without_partial_output()
{
char output[5] = "ok";
char* cursor = output;
TEST_ASSERT_FALSE(HAJson::appendEscapedString(cursor, output + sizeof(output) - 1, "toolong"));
TEST_ASSERT_EQUAL_STRING("ok", output);
TEST_ASSERT_EQUAL_PTR(output, cursor);
}
void test_HAJson_validates_home_assistant_discovery_topic_tokens()
{
TEST_ASSERT_TRUE(HAJson::isValidDiscoveryTopicToken("device_01-A"));
TEST_ASSERT_FALSE(HAJson::isValidDiscoveryTopicToken(""));
TEST_ASSERT_FALSE(HAJson::isValidDiscoveryTopicToken("device/id"));
TEST_ASSERT_FALSE(HAJson::isValidDiscoveryTopicToken("device id"));
TEST_ASSERT_FALSE(HAJson::isValidDiscoveryTopicToken("device\n"));
}
static TestCase tests[] = {
TEST_ENTRY(test_HAJson_escapes_all_required_json_characters),
TEST_ENTRY(test_HAJson_rejects_too_small_output_buffer_without_partial_output),
TEST_ENTRY(test_HAJson_validates_home_assistant_discovery_topic_tokens),
};
static const size_t testCount = sizeof(tests) / sizeof(tests[0]);
static size_t nextTest = 0;
static bool begun = false;
void setUp(void) { }
void tearDown(void) { }
void setup()
{
Serial.begin(115200);
delay(500);
UNITY_BEGIN();
begun = true;
}
void loop()
{
if (begun && nextTest < testCount) {
TestCase& test = tests[nextTest++];
UnityDefaultTestRun(test.fn, test.name, test.line);
return;
}
if (begun) {
UNITY_END();
begun = false;
}
}
+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"]
+57
View File
@@ -0,0 +1,57 @@
# 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
cannot: Home Assistant's entity-registry identity, user-owned registry changes,
and retained discovery after a Home Assistant restart.
It has two ordered modes:
1. `migration` publishes single-component discovery, applies a user rename and
disable, then sends `migrate_discovery` markers, a device bundle, and legacy
retained-topic cleanup.
2. `retained-restart` runs after Home Assistant restarts and verifies the
retained device bundle plus preserved registry customization.
The migration case also rejects malformed retained JSON, checks that a direct
single-to-device publication does not duplicate registry identity, exercises the
device-component tombstone/omission sequence, and makes the current
`def_ent_id`/no-`obj_id` schema expectation explicit.
## Run locally
From the repository root:
```bash
HA_VERSION=2024.11.3 docker compose -f tests/ha-test-harness/compose.yaml up -d mqtt homeassistant
HA_VERSION=2024.11.3 docker compose -f tests/ha-test-harness/compose.yaml run --rm tests
HA_VERSION=2024.11.3 docker compose -f tests/ha-test-harness/compose.yaml restart homeassistant
TEST_HARNESS_MODE=retained-restart HA_VERSION=2024.11.3 docker compose -f tests/ha-test-harness/compose.yaml run --rm tests
HA_VERSION=2024.11.3 docker compose -f tests/ha-test-harness/compose.yaml down -v
```
Use `HA_VERSION=stable` and `HA_VERSION=dev` for the current supported and
development Home Assistant images.
The checked-in GitHub workflow runs the baseline, stable, and development images
as a compatibility audit. When it is on the repository's default branch, GitHub
exposes it for manual dispatch and the weekly schedule. It is not a tag-release
gate.
For a local development-image test, add `TEST_HARNESS_EXPECT_DISABLED_CLEANUP=1` to
both `docker compose run ... tests` commands. That lane checks that initially
disabled device components are cleaned. The test uses only ephemeral named volumes;
`down -v` removes its broker data, Home Assistant config, owner token, and
registry state.
## Shared support
The schema-neutral [HA/MQTT test harness testkit](../../test-support/ha-mqtt-test-harness/README.md) owns only Docker-side Home Assistant onboarding, MQTT transport, registry/service access, retries, and artifact helpers. It has no ArduinoHA discovery assertions. This suite owns ArduinoHA migration fixtures; DeviceFramework carries its own fixtures and hardware adapter while reusing the testkit.
## Scope
The firmware's native Unity suite covers JSON escaping, invalid topic tokens,
serializer preflight, migration ordering, component removal, and lifecycle
behavior. This container suite covers Home Assistant's persistence test harness. It
does not need a physical board: fixture discovery documents mirror retained
payloads emitted by ArduinoHA and isolate Home Assistant/MQTT compatibility.
+44
View File
@@ -0,0 +1,44 @@
services:
mqtt:
image: eclipse-mosquitto:2
command: ["mosquitto", "-c", "/mosquitto/config/mosquitto.conf"]
volumes:
- ./mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
healthcheck:
test: ["CMD-SHELL", "mosquitto_sub -h localhost -t '$$SYS/broker/version' -C 1 -W 5 >/dev/null 2>&1"]
interval: 3s
timeout: 5s
retries: 20
homeassistant:
image: ghcr.io/home-assistant/home-assistant:${HA_VERSION:-stable}
environment:
TZ: UTC
volumes:
- ha_config:/config
- ./configuration.yaml:/config/configuration.yaml:ro
depends_on:
mqtt:
condition: service_healthy
tests:
build:
context: ../..
dockerfile: tests/ha-test-harness/Dockerfile
environment:
HA_URL: http://homeassistant:8123
MQTT_HOST: mqtt
MQTT_PORT: "1883"
TEST_HARNESS_MODE: ${TEST_HARNESS_MODE:-migration}
TEST_HARNESS_EXPECT_DISABLED_CLEANUP: ${TEST_HARNESS_EXPECT_DISABLED_CLEANUP:-0}
volumes:
- test_harness_state:/state
depends_on:
mqtt:
condition: service_healthy
homeassistant:
condition: service_started
volumes:
ha_config:
test_harness_state:
+2
View File
@@ -0,0 +1,2 @@
default_config:
+5
View File
@@ -0,0 +1,5 @@
persistence true
persistence_location /mosquitto/data/
allow_anonymous true
listener 1883
log_type all
+3
View File
@@ -0,0 +1,3 @@
paho-mqtt==2.1.0
requests==2.32.3
websocket-client==1.8.0
+280
View File
@@ -0,0 +1,280 @@
"""Home Assistant MQTT discovery test harness checks.
The test intentionally uses retained MQTT messages, like a deployed firmware
node. It inspects HA's entity registry over the authenticated WebSocket API so
the migration assertion is about HA's persistent identity, not just payload
shape.
"""
import json
import os
import pathlib
import time
from ha_mqtt_test_harness import (
TestHarnessError,
HomeAssistantClient,
RetainedPublisher as SharedRetainedPublisher,
wait_until,
)
HA_URL = os.environ.get("HA_URL", "http://homeassistant:8123").rstrip("/")
MQTT_HOST = os.environ.get("MQTT_HOST", "mqtt")
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
MODE = os.environ.get("TEST_HARNESS_MODE", "migration")
STATE = pathlib.Path("/state")
TOKEN_FILE = STATE / "ha-token"
DEVICE_ID = "test_harness_device"
EDGE_DEVICE_ID = "test_harness_edge_device"
EXPECT_DISABLED_CLEANUP = os.environ.get("TEST_HARNESS_EXPECT_DISABLED_CLEANUP") == "1"
def fail(message):
raise TestHarnessError(message)
def legacy_topic(object_id, device_id=DEVICE_ID):
return f"homeassistant/sensor/{device_id}/{object_id}/config"
def device_topic(device_id=DEVICE_ID):
return f"homeassistant/device/{device_id}/config"
def component(object_id, unique_id=None, device_id=DEVICE_ID, **extra):
payload = {
"p": "sensor",
"name": object_id.replace("_", " ").title(),
"uniq_id": unique_id or f"{device_id}_{object_id}",
"stat_t": f"test-harness/{object_id}/state",
}
payload.update(extra)
return payload
def device_payload(components, device_id=DEVICE_ID):
return {
"dev": {"ids": [device_id], "name": "ArduinoHA test harness device"},
"o": {"name": "ArduinoHA", "sw": "test harness"},
"cmps": components,
}
def legacy_payload(object_id, unique_id=None, device_id=DEVICE_ID, **extra):
payload = component(object_id, unique_id, **extra)
payload["dev"] = {"ids": [device_id], "name": "ArduinoHA test harness device"}
payload.pop("p")
return payload
def find_unique(entries, unique_id):
matches = [entry for entry in entries if entry.get("unique_id") == unique_id]
if len(matches) > 1:
fail(f"duplicate entity-registry entries for {unique_id}: {matches}")
return matches[0] if matches else None
def wait_for_entry(ws, unique_id):
return wait_until(
f"entity registry entry {unique_id}",
lambda: find_unique(ws.registry_entries(), unique_id),
timeout=60,
)
def migration_test_harness():
publisher = SharedRetainedPublisher(MQTT_HOST, MQTT_PORT)
ws = HomeAssistantClient.bootstrap(
HA_URL,
STATE,
owner_name="ArduinoHA Test Harness Owner",
username="arduinoha-test-harness",
password="arduinoha-test-harness-password",
)
ws.configure_mqtt(MQTT_HOST, MQTT_PORT)
try:
# Existing single-component entity and a user-owned registry customization.
unique = f"{DEVICE_ID}_temperature"
publisher.publish(legacy_topic("temperature"), json.dumps(legacy_payload("temperature", unique)))
publisher.publish("test-harness/temperature/state", "21.5")
original = wait_for_entry(ws, unique)
original_id = original["id"]
renamed_entity_id = "sensor.test_harness_temperature_user_name"
ws.call(
"config/entity_registry/update",
entity_id=original["entity_id"],
new_entity_id=renamed_entity_id,
disabled_by="user",
)
# HA's required sequence: marker, device payload, then retained cleanup.
publisher.publish(legacy_topic("temperature"), '{"migrate_discovery":true}')
publisher.publish(device_topic(), json.dumps(device_payload({"temperature": component("temperature", unique)})))
publisher.publish(legacy_topic("temperature"), "")
migrated = wait_for_entry(ws, unique)
if migrated["id"] != original_id:
fail("single-to-device migration changed the entity registry ID")
if migrated["entity_id"] != renamed_entity_id or migrated.get("disabled_by") != "user":
fail(f"migration did not preserve user registry settings: {migrated}")
if find_unique(ws.registry_entries(), unique) is None:
fail("migrated entity disappeared from the entity registry")
# Reverse migration is also ordered: marker the device topic, restore
# legacy discovery, then clear the device topic. This is the protocol
# HA documents for preserving the existing registry entry.
publisher.publish(device_topic(), '{"migrate_discovery":true}')
publisher.publish(legacy_topic("temperature"), json.dumps(legacy_payload("temperature", unique)))
publisher.publish(device_topic(), "")
rolled_back = wait_for_entry(ws, unique)
if rolled_back["id"] != original_id:
fail("device-to-single rollback changed the entity registry ID")
if rolled_back["entity_id"] != renamed_entity_id or rolled_back.get("disabled_by") != "user":
fail(f"rollback did not preserve user registry settings: {rolled_back}")
# Restore device discovery so the retained-restart check continues to
# exercise the forward migration form used by deployed firmware.
publisher.publish(legacy_topic("temperature"), '{"migrate_discovery":true}')
publisher.publish(device_topic(), json.dumps(device_payload({"temperature": component("temperature", unique)})))
publisher.publish(legacy_topic("temperature"), "")
remigrated = wait_for_entry(ws, unique)
if remigrated["id"] != original_id:
fail("repeat single-to-device migration changed the entity registry ID")
retained = json.loads(publisher.retained_payload(device_topic()))
if "temperature" not in retained.get("cmps", {}):
fail("migration did not retain the primary device discovery payload")
# Direct publication is deliberately not a migration protocol. It must not
# create a second registry entry for the same stable unique ID. Keep it
# on a separate device topic so it cannot invalidate the primary
# retained-payload/restart test harness.
direct_unique = f"{EDGE_DEVICE_ID}_direct"
publisher.publish(
legacy_topic("direct", EDGE_DEVICE_ID),
json.dumps(legacy_payload("direct", direct_unique, device_id=EDGE_DEVICE_ID)),
)
direct_entry = wait_for_entry(ws, direct_unique)
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload(
{"direct": component("direct", direct_unique)}, device_id=EDGE_DEVICE_ID
)),
)
time.sleep(2)
entries = [entry for entry in ws.registry_entries() if entry.get("unique_id") == direct_unique]
if len(entries) != 1 or entries[0]["id"] != direct_entry["id"]:
fail("direct device discovery publish created a duplicate registry entity")
# Device-mode removal is two root updates: platform tombstone then
# omission. This uses the edge fixture so retained-restart behavior is
# independently asserted on the migrated primary fixture.
removable_unique = f"{EDGE_DEVICE_ID}_removable"
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({
"anchor": component("anchor", device_id=EDGE_DEVICE_ID),
"removable": component("removable", removable_unique),
}, device_id=EDGE_DEVICE_ID)),
)
wait_for_entry(ws, removable_unique)
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({
"anchor": component("anchor", device_id=EDGE_DEVICE_ID),
"removable": {"p": "sensor"},
}, device_id=EDGE_DEVICE_ID)),
)
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({"anchor": component("anchor", device_id=EDGE_DEVICE_ID)}, device_id=EDGE_DEVICE_ID)),
)
# Older Home Assistant versions do not clean initially disabled device
# components. Keep that capability assertion in the development lane,
# where it detects regressions without making the supported baseline
# falsely fail.
if EXPECT_DISABLED_CLEANUP:
disabled_unique = f"{EDGE_DEVICE_ID}_disabled"
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({
"anchor": component("anchor", device_id=EDGE_DEVICE_ID),
"disabled": component(
"disabled", disabled_unique, enabled_by_default=False
),
}, device_id=EDGE_DEVICE_ID)),
)
disabled_entry = wait_for_entry(ws, disabled_unique)
if disabled_entry.get("disabled_by") != "integration":
fail(f"expected initially disabled entity to be integration-disabled: {disabled_entry}")
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({
"anchor": component("anchor", device_id=EDGE_DEVICE_ID),
"disabled": {"p": "sensor"},
}, device_id=EDGE_DEVICE_ID)),
)
publisher.publish(
device_topic(EDGE_DEVICE_ID),
json.dumps(device_payload({"anchor": component("anchor", device_id=EDGE_DEVICE_ID)}, device_id=EDGE_DEVICE_ID)),
)
wait_until(
"disabled device component cleanup",
lambda: find_unique(ws.registry_entries(), disabled_unique) is None,
timeout=60,
)
# Invalid retained discovery JSON never creates a registry entry. Escaped
# strings are covered by the firmware-native serializer tests.
publisher.publish(legacy_topic("malformed"), "{not-json")
time.sleep(2)
if find_unique(ws.registry_entries(), f"{DEVICE_ID}_malformed"):
fail("malformed discovery payload created an entity")
# This fixture documents the current HA field behaviour: def_ent_id is
# allowed on first creation; obsolete obj_id is intentionally absent.
default_payload = legacy_payload("default_name", def_ent_id="test_harness_default_name")
if "obj_id" in default_payload:
fail("test harness fixture accidentally contains obsolete obj_id")
publisher.publish(legacy_topic("default_name"), json.dumps(default_payload))
wait_for_entry(ws, f"{DEVICE_ID}_default_name")
STATE.mkdir(parents=True, exist_ok=True)
(STATE / "migration-complete").write_text("ok", encoding="utf-8")
finally:
ws.close()
publisher.close()
def retained_restart_test_harness():
if not (STATE / "migration-complete").exists():
fail("retained-restart mode requires the migration test harness to run first")
publisher = SharedRetainedPublisher(MQTT_HOST, MQTT_PORT)
ws = HomeAssistantClient.from_state(HA_URL, STATE)
try:
migrated = wait_for_entry(ws, f"{DEVICE_ID}_temperature")
if migrated["entity_id"] != "sensor.test_harness_temperature_user_name":
fail("HA restart lost the user-owned entity rename")
retained = json.loads(publisher.retained_payload(device_topic()))
if "cmps" not in retained:
fail("broker restart check did not receive retained device discovery")
finally:
ws.close()
publisher.close()
def main():
HomeAssistantClient.wait_until_ready(HA_URL)
if MODE == "migration":
migration_test_harness()
elif MODE == "retained-restart":
retained_restart_test_harness()
else:
fail(f"unknown TEST_HARNESS_MODE: {MODE}")
print(f"Home Assistant MQTT test harness mode {MODE} passed")
if __name__ == "__main__":
main()