mirror of
https://github.com/alexhopeoconnor/arduino-home-assistant.git
synced 2026-10-04 02:48:13 +10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72e80f236e | ||
|
|
3892ce1ca9 | ||
|
|
ea6f1cf353 | ||
|
|
e3c9388ffc | ||
|
|
e6dee4727d | ||
|
|
ea3f41b3f9 | ||
|
|
0dbf0ecb75 | ||
|
|
50cf1c36f0 | ||
|
|
bf18695c13 | ||
|
|
e11fc4ace1 | ||
|
|
ac08bf8431 | ||
|
|
35b375b497 | ||
|
|
7f28604887 | ||
|
|
56d74df55a | ||
|
|
84cc0037b1 |
@@ -18,7 +18,18 @@ jobs:
|
||||
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
|
||||
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 native --filter test_native_core
|
||||
|
||||
compile-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -32,3 +43,4 @@ jobs:
|
||||
python-version: '3.11'
|
||||
- run: python -m pip install --upgrade platformio==6.1.19
|
||||
- run: ./scripts/test.sh compile --platform ${{ matrix.platform }}
|
||||
- run: ./scripts/test.sh examples --platform ${{ matrix.platform }}
|
||||
|
||||
@@ -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
|
||||
@@ -9,8 +9,37 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test_harness:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- home_assistant: "2024.11.3"
|
||||
- home_assistant: stable
|
||||
env:
|
||||
HA_VERSION: ${{ matrix.home_assistant }}
|
||||
TEST_HARNESS_EXPECT_DISABLED_CLEANUP: "0"
|
||||
COMPOSE_FILE: tests/ha-test-harness/compose.yaml
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Start broker and Home Assistant
|
||||
run: docker compose up -d mqtt homeassistant
|
||||
- name: Check single-to-device migration
|
||||
run: docker compose run --rm tests
|
||||
- name: Restart Home Assistant from retained data
|
||||
run: docker compose restart homeassistant
|
||||
- name: Check retained discovery after restart
|
||||
run: docker compose run --rm -e TEST_HARNESS_MODE=retained-restart tests
|
||||
- name: Collect Home Assistant logs on failure
|
||||
if: failure()
|
||||
run: docker compose logs --no-color homeassistant mqtt
|
||||
- name: Remove test harness volumes
|
||||
if: always()
|
||||
run: docker compose down -v
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test_harness
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
@@ -18,7 +47,10 @@ jobs:
|
||||
python-version: '3.11'
|
||||
- run: python -m pip install --upgrade platformio==6.1.19
|
||||
- run: ./scripts/test.sh compile --platform esp8266
|
||||
- run: ./scripts/test.sh examples --platform esp8266
|
||||
- run: pio test -e native --filter test_native_core
|
||||
- run: ./scripts/test.sh compile --platform esp32
|
||||
- run: ./scripts/test.sh examples --platform esp32
|
||||
- run: ./scripts/check-docs.sh
|
||||
- run: ./scripts/prepare-release.sh "$GITHUB_REF_NAME"
|
||||
- run: ./scripts/release-notes.sh "$GITHUB_REF_NAME" > "$RUNNER_TEMP/release-notes.md"
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
.DS_Store
|
||||
tmp/
|
||||
|
||||
# Python test tooling
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# PlatformIO build output
|
||||
.pio/
|
||||
|
||||
|
||||
+38
-2
@@ -1,9 +1,45 @@
|
||||
# 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.
|
||||
@@ -31,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
|
||||
|
||||
|
||||
@@ -3,20 +3,16 @@
|
||||
[](https://github.com/alexhopeoconnor/arduino-home-assistant/releases)
|
||||
[](docs/README.md)
|
||||
|
||||
ArduinoHA is the maintained MQTT-discovery library behind compact Arduino, ESP8266, and ESP32 integrations with Home Assistant. It uses Arduino's standard network `Client` API and is continuously compile-tested on ESP8266 and ESP32.
|
||||
|
||||
## Why use it
|
||||
|
||||
- **Home Assistant discovery:** entities appear automatically from retained MQTT discovery payloads.
|
||||
- **Two-way entities:** report local state and receive Home Assistant commands with a small, explicit API.
|
||||
- **One physical device:** group multiple entities, metadata, shared availability, and MQTT Last Will under `HADevice`.
|
||||
- **Control the footprint:** compile out entity implementations a firmware does not use.
|
||||
- **Two discovery shapes:** start with one payload per entity or opt into a single device-discovery payload.
|
||||
ArduinoHA lets an Arduino, ESP8266, or ESP32 application publish MQTT discovery data that Home Assistant understands. Give it a connected Arduino `Client`, declare entities, and Home Assistant creates the controls and telemetry automatically.
|
||||
|
||||
## Start with a sensor
|
||||
|
||||
```cpp
|
||||
#if defined(ESP8266)
|
||||
#include <ESP8266WiFi.h>
|
||||
#elif defined(ESP32)
|
||||
#include <WiFi.h>
|
||||
#endif
|
||||
#include <ArduinoHA.h>
|
||||
|
||||
WiFiClient client;
|
||||
@@ -29,50 +25,45 @@ void setup() {
|
||||
WiFi.macAddress(mac);
|
||||
device.setUniqueId(mac, sizeof(mac));
|
||||
|
||||
WiFi.begin("SSID", "password");
|
||||
while (WiFi.status() != WL_CONNECTED) delay(500);
|
||||
|
||||
// Your application connects Wi-Fi before MQTT begins.
|
||||
temperature.setName("Temperature");
|
||||
temperature.setUnitOfMeasurement("°C");
|
||||
mqtt.begin("mqtt.local", "mqtt_user", "mqtt_password");
|
||||
mqtt.begin("mqtt.local", "mqtt_user", "mqtt_password"); // Connection work begins in mqtt.loop().
|
||||
}
|
||||
|
||||
void loop() {
|
||||
mqtt.loop();
|
||||
mqtt.loop(); // Maintains MQTT and publishes discovery after connecting.
|
||||
// Call temperature.setValue(...) when your reading changes.
|
||||
}
|
||||
```
|
||||
|
||||
Read [Getting started](docs/getting-started.md) before copying this into production: it explains object lifetime, MQTT lifecycle, ESP32 includes, and install routes.
|
||||
Build [ESP Sensor](examples/01-esp-sensor/) for a complete ESP8266/ESP32 project. It clearly marks the network and broker values you must provide without committing credentials.
|
||||
|
||||
## What you can build
|
||||
|
||||
- **Automatic Home Assistant discovery:** retained MQTT discovery messages create entities without hand-written Home Assistant YAML.
|
||||
- **Two-way controls:** sensors publish state while switches, lights, covers, and other writable entities receive explicit callbacks.
|
||||
- **One physical device:** `HADevice` groups metadata, multiple entities, shared availability, and MQTT Last Will.
|
||||
- **Device discovery:** publish one compact discovery document for a new multi-entity device, or keep traditional per-entity discovery.
|
||||
- **Small footprint:** exclude entity implementations your firmware does not use.
|
||||
|
||||
## Choose an example
|
||||
|
||||
| Example | Learn how to… |
|
||||
| --- | --- |
|
||||
| [ESP Sensor](examples/01-esp-sensor/) | connect an ESP application and publish changing numeric telemetry |
|
||||
| [Switch Callback](examples/02-switch-callback/) | reflect Home Assistant commands in a physical output and report state back |
|
||||
| [Multi-entity Device](examples/03-multi-entity-device/) | group controls and telemetry with shared availability and Last Will |
|
||||
| [Device Discovery](examples/04-device-discovery/) | publish one Home Assistant device-discovery document for a new device |
|
||||
| [Entity recipes](examples/README.md#entity-recipes) | find the existing focused Arduino sketches for each supported entity |
|
||||
|
||||
## Install
|
||||
|
||||
```ini
|
||||
lib_deps =
|
||||
home-assistant-integration=https://github.com/alexhopeoconnor/arduino-home-assistant.git#v3.0.2
|
||||
home-assistant-integration=https://github.com/alexhopeoconnor/arduino-home-assistant.git#v3.2.1
|
||||
```
|
||||
|
||||
PlatformIO clones the Git repository and checks out the ref after `#`; that ref is a release tag, not a GitHub Release asset. Arduino IDE is supported through the included [`library.properties`](library.properties); see [Getting started](docs/getting-started.md#install-the-library).
|
||||
PlatformIO clones the Git repository and checks out the ref after `#`; that ref is a release tag, not a GitHub Release asset. Arduino IDE is supported through the included [`library.properties`](library.properties).
|
||||
|
||||
## Documentation
|
||||
|
||||
The [documentation map](docs/README.md) is the starting point:
|
||||
|
||||
- [Getting started](docs/getting-started.md): connection lifecycle and minimal sketches.
|
||||
- [Device and discovery](docs/device-and-discovery.md): device metadata, discovery modes, and runtime refresh.
|
||||
- [MQTT usage](docs/mqtt-usage.md): callbacks, availability, custom MQTT, logging, and footprint flags.
|
||||
- [Entity guide](docs/entities.md): supported entity types and the best matching example.
|
||||
- [Examples](examples/README.md): curated entry points and the full example index.
|
||||
|
||||
## Development and releases
|
||||
|
||||
```bash
|
||||
./scripts/test.sh compile --platform esp8266
|
||||
./scripts/test.sh compile --platform esp32
|
||||
./scripts/check-docs.sh
|
||||
./scripts/prepare-release.sh vMAJOR.MINOR.PATCH --tag
|
||||
```
|
||||
|
||||
The release preflight validates both package manifests and the matching changelog section. A pushed tag repeats the board-free compile checks and creates a GitHub Release from that section; it does not publish to the PlatformIO Registry or deploy firmware.
|
||||
|
||||
See the [changelog](CHANGELOG.md) and [licence](LICENSE).
|
||||
See [getting started](docs/getting-started.md), the [documentation map](docs/README.md), [examples](examples/README.md), [release history](CHANGELOG.md), and [licence](LICENSE).
|
||||
|
||||
@@ -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).
|
||||
+8
-8
@@ -1,13 +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 |
|
||||
| [Entities](entities.md) | Supported Home Assistant entity classes and example selection |
|
||||
| [Examples](../examples/README.md) | Curated paths through the standalone sketches |
|
||||
| [Getting started](getting-started.md) | Connecting a `Client`, installing the library, and the first MQTT lifecycle |
|
||||
| [Device & discovery](device-and-discovery.md) | `HADevice`, discovery modes, metadata, identifiers, and migration |
|
||||
| [MQTT usage](mqtt-usage.md) | Callbacks, custom topics, availability, logging, and footprint flags |
|
||||
| [Entities](entities.md) | Supported Home Assistant entity classes and the best matching example |
|
||||
| [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 |
|
||||
|
||||
Class-level API details live in headers under [`src/`](../src/). Return to the [project overview](../README.md).
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
|
||||
+4
-2
@@ -22,11 +22,13 @@ ArduinoHA supports these Home Assistant MQTT discovery entity classes. Choose th
|
||||
| Tag scanner | [tag-scanner](../examples/tag-scanner/tag-scanner.ino) |
|
||||
| Text | Use the API header; no dedicated sketch yet |
|
||||
|
||||
The library does not currently implement alarm control panels, events, humidifiers, images, lawn mowers, sirens, updates, vacuums, valves, or water heaters. Those are deliberate unsupported surfaces, not configuration switches.
|
||||
The library does not currently implement alarm control panels, events,
|
||||
humidifiers, images, lawn mowers, sirens, updates, vacuums, valves, or water
|
||||
heaters. They cannot be enabled through configuration.
|
||||
|
||||
## Common lifecycle
|
||||
|
||||
Create `HADevice`, `HAMqtt`, then entities in that order; create them before `mqtt.begin(...)`. Call `mqtt.loop()` regularly. Read [Getting started](getting-started.md) for the connection flow and [device discovery](device-and-discovery.md) for discovery settings.
|
||||
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.
|
||||
|
||||
|
||||
+18
-7
@@ -11,7 +11,7 @@ ArduinoHA talks to Home Assistant over **MQTT** (TCP). You need an MQTT broker r
|
||||
|
||||
```ini
|
||||
lib_deps =
|
||||
home-assistant-integration=https://github.com/alexhopeoconnor/arduino-home-assistant.git#v3.0.2
|
||||
home-assistant-integration=https://github.com/alexhopeoconnor/arduino-home-assistant.git#v3.2.1
|
||||
```
|
||||
|
||||
**Arduino IDE:** this fork is not indexed by Library Manager. Download the
|
||||
@@ -26,7 +26,7 @@ Arduino networking API.
|
||||
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)
|
||||
|
||||
@@ -40,20 +40,24 @@ HADevice device(mac, sizeof(mac));
|
||||
HAMqtt mqtt(client, device);
|
||||
|
||||
void setup() {
|
||||
Ethernet.begin(mac);
|
||||
mqtt.begin("192.168.1.50", "mqtt_user", "mqtt_password");
|
||||
Ethernet.begin(mac); // Bring up the network Client before MQTT can connect.
|
||||
mqtt.begin("192.168.1.50", "mqtt_user", "mqtt_password"); // Connection begins in mqtt.loop().
|
||||
}
|
||||
|
||||
void loop() {
|
||||
Ethernet.maintain();
|
||||
mqtt.loop();
|
||||
Ethernet.maintain(); // Renews DHCP leases where the Ethernet library requires it.
|
||||
mqtt.loop(); // Services MQTT connection, discovery, and entity traffic.
|
||||
}
|
||||
```
|
||||
|
||||
### ESP8266 / ESP32 (example)
|
||||
|
||||
```cpp
|
||||
#if defined(ESP8266)
|
||||
#include <ESP8266WiFi.h>
|
||||
#elif defined(ESP32)
|
||||
#include <WiFi.h>
|
||||
#endif
|
||||
#include <ArduinoHA.h>
|
||||
|
||||
WiFiClient client;
|
||||
@@ -66,15 +70,18 @@ void setup() {
|
||||
device.setUniqueId(mac, sizeof(mac));
|
||||
|
||||
WiFi.begin("SSID", "password");
|
||||
// Keep this blocking wait only in a minimal sketch. Production firmware
|
||||
// should retry, time out, or hand control to its provisioning flow.
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
delay(500);
|
||||
}
|
||||
|
||||
// begin() stores broker settings; mqtt.loop() performs connection and recovery.
|
||||
mqtt.begin("192.168.1.50", "mqtt_user", "mqtt_password");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
mqtt.loop();
|
||||
mqtt.loop(); // Service reconnects, subscriptions, and discovery publishing.
|
||||
}
|
||||
```
|
||||
|
||||
@@ -87,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
@@ -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.:
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# ESP Sensor
|
||||
|
||||
This is the first complete ArduinoHA project for an ESP8266 D1 mini or ESP32 development board. It connects Wi-Fi, derives a stable device ID from the board MAC address, and publishes a changing **Uptime** sensor to Home Assistant.
|
||||
|
||||
Before uploading, replace the `EXAMPLE_*` values in `include/ExampleNetwork.h`, or provide them as PlatformIO build flags. The header contains safe placeholders and must not contain real credentials when committed.
|
||||
|
||||
After MQTT connects, Home Assistant discovers **ArduinoHA sensor example** and its Uptime entity. Rebooting the board keeps the same Home Assistant identity because its MAC address is stable.
|
||||
|
||||
See [getting started](../../docs/getting-started.md) and the shared [examples guide](../README.md).
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
#if defined(ESP32)
|
||||
#include <Network.h>
|
||||
#include <WiFi.h>
|
||||
#else
|
||||
#include <ESP8266WiFi.h>
|
||||
#endif
|
||||
|
||||
#ifndef EXAMPLE_WIFI_SSID
|
||||
#define EXAMPLE_WIFI_SSID "replace-with-wifi-name"
|
||||
#endif
|
||||
#ifndef EXAMPLE_WIFI_PASSWORD
|
||||
#define EXAMPLE_WIFI_PASSWORD "replace-with-wifi-password"
|
||||
#endif
|
||||
#ifndef EXAMPLE_MQTT_HOST
|
||||
#define EXAMPLE_MQTT_HOST "replace-with-mqtt-host"
|
||||
#endif
|
||||
#ifndef EXAMPLE_MQTT_USER
|
||||
#define EXAMPLE_MQTT_USER ""
|
||||
#endif
|
||||
#ifndef EXAMPLE_MQTT_PASSWORD
|
||||
#define EXAMPLE_MQTT_PASSWORD ""
|
||||
#endif
|
||||
|
||||
inline void connectExampleWiFi() {
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(EXAMPLE_WIFI_SSID, EXAMPLE_WIFI_PASSWORD);
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
delay(250);
|
||||
}
|
||||
}
|
||||
|
||||
inline void setExampleUniqueId(HADevice& device) {
|
||||
uint8_t mac[6];
|
||||
WiFi.macAddress(mac);
|
||||
device.setUniqueId(mac, sizeof(mac));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[platformio]
|
||||
default_envs = esp8266
|
||||
|
||||
[common]
|
||||
framework = arduino
|
||||
lib_ldf_mode = deep+
|
||||
lib_deps =
|
||||
home-assistant-integration=symlink://../..
|
||||
|
||||
[env:esp8266]
|
||||
extends = common
|
||||
platform = espressif8266
|
||||
board = d1_mini
|
||||
platform_packages =
|
||||
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
|
||||
|
||||
[env:esp32]
|
||||
extends = common
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/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,30 @@
|
||||
#include <Arduino.h>
|
||||
#include <ArduinoHA.h>
|
||||
#include "ExampleNetwork.h"
|
||||
|
||||
WiFiClient client;
|
||||
HADevice device;
|
||||
HAMqtt mqtt(client, device);
|
||||
HASensorNumber uptime("uptime");
|
||||
|
||||
void setup() {
|
||||
Serial.begin(115200);
|
||||
connectExampleWiFi();
|
||||
setExampleUniqueId(device);
|
||||
|
||||
device.setName("ArduinoHA sensor example");
|
||||
device.setSoftwareVersion("1.0.0");
|
||||
uptime.setName("Uptime");
|
||||
uptime.setUnitOfMeasurement("s");
|
||||
mqtt.begin(EXAMPLE_MQTT_HOST, EXAMPLE_MQTT_USER, EXAMPLE_MQTT_PASSWORD);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
mqtt.loop();
|
||||
|
||||
static unsigned long lastUpdate = 0;
|
||||
if (millis() - lastUpdate >= 1000) {
|
||||
uptime.setValue(static_cast<uint32_t>(millis() / 1000));
|
||||
lastUpdate = millis();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
# Switch Callback
|
||||
|
||||
This example creates one writable Home Assistant switch. When Home Assistant sends a command, `onOutputCommand()` changes the local output and immediately reports the resulting state back through the supplied `HASwitch`.
|
||||
|
||||
The ESP8266 build uses `LED_BUILTIN`; the ESP32 build uses GPIO 2. Confirm your board’s LED polarity before treating that output as a real load.
|
||||
|
||||
Set the Wi-Fi and MQTT placeholders in `include/ExampleNetwork.h`, flash the board, then toggle **Example output** in Home Assistant.
|
||||
|
||||
See [MQTT usage](../../docs/mqtt-usage.md) and the shared [examples guide](../README.md).
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
#if defined(ESP32)
|
||||
#include <Network.h>
|
||||
#include <WiFi.h>
|
||||
#else
|
||||
#include <ESP8266WiFi.h>
|
||||
#endif
|
||||
|
||||
#ifndef EXAMPLE_WIFI_SSID
|
||||
#define EXAMPLE_WIFI_SSID "replace-with-wifi-name"
|
||||
#endif
|
||||
#ifndef EXAMPLE_WIFI_PASSWORD
|
||||
#define EXAMPLE_WIFI_PASSWORD "replace-with-wifi-password"
|
||||
#endif
|
||||
#ifndef EXAMPLE_MQTT_HOST
|
||||
#define EXAMPLE_MQTT_HOST "replace-with-mqtt-host"
|
||||
#endif
|
||||
#ifndef EXAMPLE_MQTT_USER
|
||||
#define EXAMPLE_MQTT_USER ""
|
||||
#endif
|
||||
#ifndef EXAMPLE_MQTT_PASSWORD
|
||||
#define EXAMPLE_MQTT_PASSWORD ""
|
||||
#endif
|
||||
|
||||
inline void connectExampleWiFi() {
|
||||
WiFi.mode(WIFI_STA);
|
||||
WiFi.begin(EXAMPLE_WIFI_SSID, EXAMPLE_WIFI_PASSWORD);
|
||||
while (WiFi.status() != WL_CONNECTED) {
|
||||
delay(250);
|
||||
}
|
||||
}
|
||||
|
||||
inline void setExampleUniqueId(HADevice& device) {
|
||||
uint8_t mac[6];
|
||||
WiFi.macAddress(mac);
|
||||
device.setUniqueId(mac, sizeof(mac));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
[platformio]
|
||||
default_envs = esp8266
|
||||
|
||||
[common]
|
||||
framework = arduino
|
||||
lib_ldf_mode = deep+
|
||||
lib_deps =
|
||||
home-assistant-integration=symlink://../..
|
||||
|
||||
[env:esp8266]
|
||||
extends = common
|
||||
platform = espressif8266
|
||||
board = d1_mini
|
||||
platform_packages =
|
||||
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
|
||||
|
||||
[env:esp32]
|
||||
extends = common
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/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,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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+18
-12
@@ -1,19 +1,27 @@
|
||||
# ArduinoHA examples
|
||||
|
||||
Each directory is an Arduino sketch. Start with the network example matching your board, then pick an entity example from the table. Copy credentials into your local development configuration; do not commit them in a sketch.
|
||||
Start with a guided PlatformIO project when you are new to the library. Each one builds for ESP8266 and ESP32, contains safe placeholder credentials, and explains the Home Assistant result you should see.
|
||||
|
||||
| Starting point | Use it for |
|
||||
```bash
|
||||
pio run -d examples/01-esp-sensor -e esp8266
|
||||
pio run -d examples/01-esp-sensor -e esp8266 -t upload
|
||||
```
|
||||
|
||||
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 |
|
||||
| --- | --- |
|
||||
| [nodemcu](nodemcu/nodemcu.ino) | Basic ESP8266 Wi-Fi and MQTT connection |
|
||||
| [nano33iot](nano33iot/nano33iot.ino) | Basic Arduino Nano 33 IoT connection |
|
||||
| [mqtt-with-credentials](mqtt-with-credentials/mqtt-with-credentials.ino) | MQTT authentication |
|
||||
| [mqtt-advanced](mqtt-advanced/mqtt-advanced.ino) | Custom MQTT subscriptions and publishing |
|
||||
| [availability](availability/availability.ino) | Per-entity availability |
|
||||
| [advanced-availability](advanced-availability/advanced-availability.ino) | Shared availability and MQTT Last Will |
|
||||
| [ESP Sensor](01-esp-sensor/) | Wi-Fi application wiring, a unique device ID, and changing numeric telemetry |
|
||||
| [Switch Callback](02-switch-callback/) | A Home Assistant command callback and local state acknowledgement |
|
||||
| [Multi-entity Device](03-multi-entity-device/) | Multiple entities, shared availability, and MQTT Last Will |
|
||||
| [Device Discovery](04-device-discovery/) | One device-discovery document for a newly deployed device |
|
||||
|
||||
## Entity examples
|
||||
## Entity recipes
|
||||
|
||||
| Example | Home Assistant behaviour |
|
||||
The original focused sketches remain useful as short API recipes. They are intentionally transport-specific Arduino sketches, rather than full product firmware.
|
||||
|
||||
| Recipe | Home Assistant behaviour |
|
||||
| --- | --- |
|
||||
| [binary-sensor](binary-sensor/binary-sensor.ino) | Door/contact-style binary state |
|
||||
| [button](button/button.ino) | Press action |
|
||||
@@ -34,6 +42,4 @@ Each directory is an Arduino sketch. Start with the network example matching you
|
||||
| [sensor-integer](sensor-integer/sensor-integer.ino) | Integer uptime measurement |
|
||||
| [tag-scanner](tag-scanner/tag-scanner.ino) | RFID tag reporting |
|
||||
|
||||
The examples are intentionally small, not production firmware frameworks. For reusable Wi-Fi configuration, MQTT wiring, profiles, OTA, and migrations, use [DeviceFramework](https://github.com/alexhopeoconnor/DeviceFramework) in a consuming firmware.
|
||||
|
||||
See the [entity guide](../docs/entities.md) and [project overview](../README.md).
|
||||
|
||||
@@ -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
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "home-assistant-integration",
|
||||
"version": "3.0.2",
|
||||
"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",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
name=home-assistant-integration
|
||||
version=3.0.2
|
||||
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
@@ -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 = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
|
||||
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>
|
||||
|
||||
Executable
+54
@@ -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."
|
||||
+41
-1
@@ -8,6 +8,32 @@ required=(
|
||||
examples/README.md
|
||||
)
|
||||
|
||||
check_cpp_fence_scope() {
|
||||
local markdown="$1"
|
||||
awk '
|
||||
function brace_delta(line, copy) {
|
||||
copy = line
|
||||
return gsub(/\{/, "{", copy) - gsub(/\}/, "}", copy)
|
||||
}
|
||||
/^```cpp[[:space:]]*$/ { in_cpp = 1; depth = 0; next }
|
||||
in_cpp && /^```[[:space:]]*$/ { in_cpp = 0; next }
|
||||
in_cpp {
|
||||
line = $0
|
||||
sub(/^[[:space:]]+/, "", line)
|
||||
if (depth == 0 &&
|
||||
(line ~ /^(if|for|while|switch)[[:space:]]*\(/ ||
|
||||
line ~ /^[A-Za-z_][A-Za-z0-9_:]*::[A-Za-z0-9_]+[[:space:]]*\(/ ||
|
||||
line ~ /^[A-Za-z_][A-Za-z0-9_]*\./ ||
|
||||
line ~ /^[A-Za-z_][A-Za-z0-9_]*[[:space:]]*\(/)) {
|
||||
printf "%s:%d: C++ expression appears at namespace scope; wrap it in a function.\n", FILENAME, FNR > "/dev/stderr"
|
||||
failed = 1
|
||||
}
|
||||
depth += brace_delta($0)
|
||||
}
|
||||
END { exit failed }
|
||||
' "$markdown"
|
||||
}
|
||||
|
||||
for path in "${required[@]}"; do
|
||||
[[ -f "$root/$path" ]] || { echo "Missing required documentation: $path" >&2; exit 1; }
|
||||
done
|
||||
@@ -22,6 +48,20 @@ while IFS= read -r -d '' markdown; do
|
||||
esac
|
||||
[[ -e "$candidate" ]] || { echo "Broken relative link in ${markdown#$root/}: $target" >&2; exit 1; }
|
||||
done < <(sed -nE 's/.*\]\(([^ )]+)( "[^"]*")?\).*/\1/p' "$markdown")
|
||||
done < <(find "$root" -path "$root/.git" -prune -o -name '*.md' -type f -print0)
|
||||
done < <(find "$root" -path "$root/.git" -prune -o -path '*/.pio' -prune -o -name '*.md' -type f -print0)
|
||||
|
||||
while IFS= read -r example; do
|
||||
for required in README.md platformio.ini; do
|
||||
[[ -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"
|
||||
|
||||
@@ -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)"
|
||||
@@ -32,12 +44,31 @@ if ! grep -q "^## ${version}$" "$root/CHANGELOG.md"; then
|
||||
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
|
||||
|
||||
+44
-5
@@ -2,17 +2,56 @@
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 compile --platform esp8266|esp32" >&2
|
||||
echo "Usage: $0 compile|examples --platform esp8266|esp32" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
[[ "${1:-}" == "compile" && "${2:-}" == "--platform" && $# -eq 3 ]] || usage
|
||||
|
||||
[[ $# -eq 3 && ( "${1:-}" == "compile" || "${1:-}" == "examples" ) && "${2:-}" == "--platform" ]] || usage
|
||||
case "${3:-}" in
|
||||
esp8266|esp32) environment="${3}" ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
|
||||
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
pio test -d "$root" -e "$environment" --without-uploading --without-testing
|
||||
echo "ArduinoHA compile check passed for ${3}"
|
||||
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
+129
-37
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+151
-4
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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("e, 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("e, 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("e, 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("e, 1);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -0,0 +1,10 @@
|
||||
#ifndef AHA_NATIVE_CLIENT_H
|
||||
#define AHA_NATIVE_CLIENT_H
|
||||
|
||||
class Client
|
||||
{
|
||||
public:
|
||||
virtual ~Client() = default;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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\","
|
||||
|
||||
@@ -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\","
|
||||
|
||||
@@ -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\"},"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
@@ -0,0 +1,2 @@
|
||||
default_config:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
persistence true
|
||||
persistence_location /mosquitto/data/
|
||||
allow_anonymous true
|
||||
listener 1883
|
||||
log_type all
|
||||
@@ -0,0 +1,3 @@
|
||||
paho-mqtt==2.1.0
|
||||
requests==2.32.3
|
||||
websocket-client==1.8.0
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Home Assistant MQTT discovery test harness checks.
|
||||
|
||||
The test intentionally uses retained MQTT messages, like a deployed firmware
|
||||
node. It inspects HA's entity registry over the authenticated WebSocket API so
|
||||
the migration assertion is about HA's persistent identity, not just payload
|
||||
shape.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import time
|
||||
|
||||
from ha_mqtt_test_harness import (
|
||||
TestHarnessError,
|
||||
HomeAssistantClient,
|
||||
RetainedPublisher as SharedRetainedPublisher,
|
||||
wait_until,
|
||||
)
|
||||
|
||||
|
||||
HA_URL = os.environ.get("HA_URL", "http://homeassistant:8123").rstrip("/")
|
||||
MQTT_HOST = os.environ.get("MQTT_HOST", "mqtt")
|
||||
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
|
||||
MODE = os.environ.get("TEST_HARNESS_MODE", "migration")
|
||||
STATE = pathlib.Path("/state")
|
||||
TOKEN_FILE = STATE / "ha-token"
|
||||
DEVICE_ID = "test_harness_device"
|
||||
EDGE_DEVICE_ID = "test_harness_edge_device"
|
||||
EXPECT_DISABLED_CLEANUP = os.environ.get("TEST_HARNESS_EXPECT_DISABLED_CLEANUP") == "1"
|
||||
|
||||
|
||||
def fail(message):
|
||||
raise TestHarnessError(message)
|
||||
|
||||
|
||||
def legacy_topic(object_id, device_id=DEVICE_ID):
|
||||
return f"homeassistant/sensor/{device_id}/{object_id}/config"
|
||||
|
||||
|
||||
def device_topic(device_id=DEVICE_ID):
|
||||
return f"homeassistant/device/{device_id}/config"
|
||||
|
||||
|
||||
def component(object_id, unique_id=None, device_id=DEVICE_ID, **extra):
|
||||
payload = {
|
||||
"p": "sensor",
|
||||
"name": object_id.replace("_", " ").title(),
|
||||
"uniq_id": unique_id or f"{device_id}_{object_id}",
|
||||
"stat_t": f"test-harness/{object_id}/state",
|
||||
}
|
||||
payload.update(extra)
|
||||
return payload
|
||||
|
||||
|
||||
def device_payload(components, device_id=DEVICE_ID):
|
||||
return {
|
||||
"dev": {"ids": [device_id], "name": "ArduinoHA test harness device"},
|
||||
"o": {"name": "ArduinoHA", "sw": "test harness"},
|
||||
"cmps": components,
|
||||
}
|
||||
|
||||
|
||||
def legacy_payload(object_id, unique_id=None, device_id=DEVICE_ID, **extra):
|
||||
payload = component(object_id, unique_id, **extra)
|
||||
payload["dev"] = {"ids": [device_id], "name": "ArduinoHA test harness device"}
|
||||
payload.pop("p")
|
||||
return payload
|
||||
|
||||
|
||||
def find_unique(entries, unique_id):
|
||||
matches = [entry for entry in entries if entry.get("unique_id") == unique_id]
|
||||
if len(matches) > 1:
|
||||
fail(f"duplicate entity-registry entries for {unique_id}: {matches}")
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def wait_for_entry(ws, unique_id):
|
||||
return wait_until(
|
||||
f"entity registry entry {unique_id}",
|
||||
lambda: find_unique(ws.registry_entries(), unique_id),
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
|
||||
def migration_test_harness():
|
||||
publisher = SharedRetainedPublisher(MQTT_HOST, MQTT_PORT)
|
||||
ws = HomeAssistantClient.bootstrap(
|
||||
HA_URL,
|
||||
STATE,
|
||||
owner_name="ArduinoHA Test Harness Owner",
|
||||
username="arduinoha-test-harness",
|
||||
password="arduinoha-test-harness-password",
|
||||
)
|
||||
ws.configure_mqtt(MQTT_HOST, MQTT_PORT)
|
||||
try:
|
||||
# Existing single-component entity and a user-owned registry customization.
|
||||
unique = f"{DEVICE_ID}_temperature"
|
||||
publisher.publish(legacy_topic("temperature"), json.dumps(legacy_payload("temperature", unique)))
|
||||
publisher.publish("test-harness/temperature/state", "21.5")
|
||||
original = wait_for_entry(ws, unique)
|
||||
original_id = original["id"]
|
||||
renamed_entity_id = "sensor.test_harness_temperature_user_name"
|
||||
ws.call(
|
||||
"config/entity_registry/update",
|
||||
entity_id=original["entity_id"],
|
||||
new_entity_id=renamed_entity_id,
|
||||
disabled_by="user",
|
||||
)
|
||||
|
||||
# HA's required sequence: marker, device payload, then retained cleanup.
|
||||
publisher.publish(legacy_topic("temperature"), '{"migrate_discovery":true}')
|
||||
publisher.publish(device_topic(), json.dumps(device_payload({"temperature": component("temperature", unique)})))
|
||||
publisher.publish(legacy_topic("temperature"), "")
|
||||
|
||||
migrated = wait_for_entry(ws, unique)
|
||||
if migrated["id"] != original_id:
|
||||
fail("single-to-device migration changed the entity registry ID")
|
||||
if migrated["entity_id"] != renamed_entity_id or migrated.get("disabled_by") != "user":
|
||||
fail(f"migration did not preserve user registry settings: {migrated}")
|
||||
if find_unique(ws.registry_entries(), unique) is None:
|
||||
fail("migrated entity disappeared from the entity registry")
|
||||
|
||||
# Reverse migration is also ordered: marker the device topic, restore
|
||||
# legacy discovery, then clear the device topic. This is the protocol
|
||||
# HA documents for preserving the existing registry entry.
|
||||
publisher.publish(device_topic(), '{"migrate_discovery":true}')
|
||||
publisher.publish(legacy_topic("temperature"), json.dumps(legacy_payload("temperature", unique)))
|
||||
publisher.publish(device_topic(), "")
|
||||
rolled_back = wait_for_entry(ws, unique)
|
||||
if rolled_back["id"] != original_id:
|
||||
fail("device-to-single rollback changed the entity registry ID")
|
||||
if rolled_back["entity_id"] != renamed_entity_id or rolled_back.get("disabled_by") != "user":
|
||||
fail(f"rollback did not preserve user registry settings: {rolled_back}")
|
||||
|
||||
# Restore device discovery so the retained-restart check continues to
|
||||
# exercise the forward migration form used by deployed firmware.
|
||||
publisher.publish(legacy_topic("temperature"), '{"migrate_discovery":true}')
|
||||
publisher.publish(device_topic(), json.dumps(device_payload({"temperature": component("temperature", unique)})))
|
||||
publisher.publish(legacy_topic("temperature"), "")
|
||||
remigrated = wait_for_entry(ws, unique)
|
||||
if remigrated["id"] != original_id:
|
||||
fail("repeat single-to-device migration changed the entity registry ID")
|
||||
|
||||
retained = json.loads(publisher.retained_payload(device_topic()))
|
||||
if "temperature" not in retained.get("cmps", {}):
|
||||
fail("migration did not retain the primary device discovery payload")
|
||||
|
||||
# Direct publication is deliberately not a migration protocol. It must not
|
||||
# create a second registry entry for the same stable unique ID. Keep it
|
||||
# on a separate device topic so it cannot invalidate the primary
|
||||
# retained-payload/restart test harness.
|
||||
direct_unique = f"{EDGE_DEVICE_ID}_direct"
|
||||
publisher.publish(
|
||||
legacy_topic("direct", EDGE_DEVICE_ID),
|
||||
json.dumps(legacy_payload("direct", direct_unique, device_id=EDGE_DEVICE_ID)),
|
||||
)
|
||||
direct_entry = wait_for_entry(ws, direct_unique)
|
||||
publisher.publish(
|
||||
device_topic(EDGE_DEVICE_ID),
|
||||
json.dumps(device_payload(
|
||||
{"direct": component("direct", direct_unique)}, device_id=EDGE_DEVICE_ID
|
||||
)),
|
||||
)
|
||||
time.sleep(2)
|
||||
entries = [entry for entry in ws.registry_entries() if entry.get("unique_id") == direct_unique]
|
||||
if len(entries) != 1 or entries[0]["id"] != direct_entry["id"]:
|
||||
fail("direct device discovery publish created a duplicate registry entity")
|
||||
|
||||
# Device-mode removal is two root updates: platform tombstone then
|
||||
# omission. This uses the edge fixture so retained-restart behavior is
|
||||
# independently asserted on the migrated primary fixture.
|
||||
removable_unique = f"{EDGE_DEVICE_ID}_removable"
|
||||
publisher.publish(
|
||||
device_topic(EDGE_DEVICE_ID),
|
||||
json.dumps(device_payload({
|
||||
"anchor": component("anchor", device_id=EDGE_DEVICE_ID),
|
||||
"removable": component("removable", removable_unique),
|
||||
}, device_id=EDGE_DEVICE_ID)),
|
||||
)
|
||||
wait_for_entry(ws, removable_unique)
|
||||
publisher.publish(
|
||||
device_topic(EDGE_DEVICE_ID),
|
||||
json.dumps(device_payload({
|
||||
"anchor": component("anchor", device_id=EDGE_DEVICE_ID),
|
||||
"removable": {"p": "sensor"},
|
||||
}, device_id=EDGE_DEVICE_ID)),
|
||||
)
|
||||
publisher.publish(
|
||||
device_topic(EDGE_DEVICE_ID),
|
||||
json.dumps(device_payload({"anchor": component("anchor", device_id=EDGE_DEVICE_ID)}, device_id=EDGE_DEVICE_ID)),
|
||||
)
|
||||
|
||||
# Older Home Assistant versions do not clean initially disabled device
|
||||
# components. Keep that capability assertion in the development lane,
|
||||
# where it detects regressions without making the supported baseline
|
||||
# falsely fail.
|
||||
if EXPECT_DISABLED_CLEANUP:
|
||||
disabled_unique = f"{EDGE_DEVICE_ID}_disabled"
|
||||
publisher.publish(
|
||||
device_topic(EDGE_DEVICE_ID),
|
||||
json.dumps(device_payload({
|
||||
"anchor": component("anchor", device_id=EDGE_DEVICE_ID),
|
||||
"disabled": component(
|
||||
"disabled", disabled_unique, enabled_by_default=False
|
||||
),
|
||||
}, device_id=EDGE_DEVICE_ID)),
|
||||
)
|
||||
disabled_entry = wait_for_entry(ws, disabled_unique)
|
||||
if disabled_entry.get("disabled_by") != "integration":
|
||||
fail(f"expected initially disabled entity to be integration-disabled: {disabled_entry}")
|
||||
publisher.publish(
|
||||
device_topic(EDGE_DEVICE_ID),
|
||||
json.dumps(device_payload({
|
||||
"anchor": component("anchor", device_id=EDGE_DEVICE_ID),
|
||||
"disabled": {"p": "sensor"},
|
||||
}, device_id=EDGE_DEVICE_ID)),
|
||||
)
|
||||
publisher.publish(
|
||||
device_topic(EDGE_DEVICE_ID),
|
||||
json.dumps(device_payload({"anchor": component("anchor", device_id=EDGE_DEVICE_ID)}, device_id=EDGE_DEVICE_ID)),
|
||||
)
|
||||
wait_until(
|
||||
"disabled device component cleanup",
|
||||
lambda: find_unique(ws.registry_entries(), disabled_unique) is None,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
# Invalid retained discovery JSON never creates a registry entry. Escaped
|
||||
# strings are covered by the firmware-native serializer tests.
|
||||
publisher.publish(legacy_topic("malformed"), "{not-json")
|
||||
time.sleep(2)
|
||||
if find_unique(ws.registry_entries(), f"{DEVICE_ID}_malformed"):
|
||||
fail("malformed discovery payload created an entity")
|
||||
|
||||
# This fixture documents the current HA field behaviour: def_ent_id is
|
||||
# allowed on first creation; obsolete obj_id is intentionally absent.
|
||||
default_payload = legacy_payload("default_name", def_ent_id="test_harness_default_name")
|
||||
if "obj_id" in default_payload:
|
||||
fail("test harness fixture accidentally contains obsolete obj_id")
|
||||
publisher.publish(legacy_topic("default_name"), json.dumps(default_payload))
|
||||
wait_for_entry(ws, f"{DEVICE_ID}_default_name")
|
||||
|
||||
STATE.mkdir(parents=True, exist_ok=True)
|
||||
(STATE / "migration-complete").write_text("ok", encoding="utf-8")
|
||||
finally:
|
||||
ws.close()
|
||||
publisher.close()
|
||||
|
||||
|
||||
def retained_restart_test_harness():
|
||||
if not (STATE / "migration-complete").exists():
|
||||
fail("retained-restart mode requires the migration test harness to run first")
|
||||
publisher = SharedRetainedPublisher(MQTT_HOST, MQTT_PORT)
|
||||
ws = HomeAssistantClient.from_state(HA_URL, STATE)
|
||||
try:
|
||||
migrated = wait_for_entry(ws, f"{DEVICE_ID}_temperature")
|
||||
if migrated["entity_id"] != "sensor.test_harness_temperature_user_name":
|
||||
fail("HA restart lost the user-owned entity rename")
|
||||
retained = json.loads(publisher.retained_payload(device_topic()))
|
||||
if "cmps" not in retained:
|
||||
fail("broker restart check did not receive retained device discovery")
|
||||
finally:
|
||||
ws.close()
|
||||
publisher.close()
|
||||
|
||||
|
||||
def main():
|
||||
HomeAssistantClient.wait_until_ready(HA_URL)
|
||||
if MODE == "migration":
|
||||
migration_test_harness()
|
||||
elif MODE == "retained-restart":
|
||||
retained_restart_test_harness()
|
||||
else:
|
||||
fail(f"unknown TEST_HARNESS_MODE: {MODE}")
|
||||
print(f"Home Assistant MQTT test harness mode {MODE} passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user