Compare commits

..
4 Commits
18 changed files with 331 additions and 96 deletions
+6 -2
View File
@@ -8,7 +8,11 @@ ArduinoHA lets an Arduino, ESP8266, or ESP32 application publish MQTT discovery
## Start with a sensor
```cpp
#if defined(ESP8266)
#include <ESP8266WiFi.h>
#elif defined(ESP32)
#include <WiFi.h>
#endif
#include <ArduinoHA.h>
WiFiClient client;
@@ -24,11 +28,11 @@ void setup() {
// Your application connects Wi-Fi before MQTT begins.
temperature.setName("Temperature");
temperature.setUnitOfMeasurement("°C");
mqtt.begin("mqtt.local", "mqtt_user", "mqtt_password");
mqtt.begin("mqtt.local", "mqtt_user", "mqtt_password"); // Connection work begins in mqtt.loop().
}
void loop() {
mqtt.loop();
mqtt.loop(); // Maintains MQTT and publishes discovery after connecting.
// Call temperature.setValue(...) when your reading changes.
}
```
+32
View File
@@ -0,0 +1,32 @@
# ESP8266 Postmortem linker workaround
The ESP8266 Arduino framework pin used by the DeviceFramework, WiFiManager,
DFTE, and ArduinoHA maintained builds is intentional:
```ini
platform_packages =
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
```
That upstream commit fixes Postmortem's large-jump failure,
[`dangerous relocation: j: cannot encode`](https://github.com/esp8266/Arduino/commit/521ae60a89e64bb0d1eb7a0b7addf620ced5cad3).
It changes the restart wrapper to use a relaxed jump and adds an EPC1 address
check. The failure is a framework linker/runtime-support issue, not an ArduinoHA
or application-source error.
ArduinoHA's root `pio test -e esp8266` environment and every guided ESP8266
PlatformIO example now use this same snapshot. There is no root-test exception
that can hide a linker regression from the example test harness.
Keep this exact framework snapshot in ESP8266 environments that need the
maintained test harness. It is unrelated to ESP32, whose pioarduino platform
selects its framework and compiler as a unit. Do not replace the SHA with a
version range: remove or advance the pin only after an upstream release includes
the fix and the affected large firmware has compiled successfully. In
particular, changing the ESP32 validation lane does not justify changing this
ESP8266 pin.
The corresponding ESP32 Core 3.3.11 pin is documented in
[DeviceFramework's toolchain guide](https://github.com/alexhopeoconnor/DeviceFramework/blob/main/docs/TOOLCHAINS.md).
Back to the [documentation map](README.md).
+1
View File
@@ -6,6 +6,7 @@
| [Device & discovery](device-and-discovery.md) | `HADevice`, discovery modes, metadata, identifiers, and migration |
| [MQTT usage](mqtt-usage.md) | Callbacks, custom topics, availability, logging, and footprint flags |
| [Entities](entities.md) | Supported Home Assistant entity classes and the best matching example |
| [ESP8266 linker workaround](ESP8266-LINKER-WORKAROUND.md) | Exact framework pin for the Postmortem large-jump fix |
| [Examples](../examples/README.md) | Guided PlatformIO projects and focused entity recipes |
| [Compatibility baseline](compatibility.md) | Supported Home Assistant capabilities and fork-specific compatibility notes |
+39
View File
@@ -16,6 +16,45 @@ 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.
+15 -9
View File
@@ -40,8 +40,11 @@ Defaults:
Override before `begin()` if needed:
```cpp
mqtt.setDiscoveryPrefix("myHaPrefix");
mqtt.setDataPrefix("myDataPrefix");
void configureTopicPrefixes() {
// Set both before mqtt.begin(...) publishes any discovery data.
mqtt.setDiscoveryPrefix("myHaPrefix");
mqtt.setDataPrefix("myDataPrefix");
}
```
### Single-component vs device discovery
@@ -106,13 +109,16 @@ The migration stage is held in RAM. If the board reboots before completion, star
Device discovery can also publish richer origin/device metadata, for example:
```cpp
device.setModelId("esp32-s3-devkit");
device.setHardwareVersion("rev-b");
device.setSerialNumber("SN-00042");
device.setSuggestedArea("Garage");
device.setViaDevice("main_gateway");
device.addConnection("mac", "AA:BB:CC:DD:EE:FF");
mqtt.setOriginSupportUrl("https://example.com/device-help");
void configureDeviceDiscovery() {
// These strings are borrowed, so keep literals or other long-lived storage.
device.setModelId("esp32-s3-devkit");
device.setHardwareVersion("rev-b");
device.setSerialNumber("SN-00042");
device.setSuggestedArea("Garage");
device.setViaDevice("main_gateway");
device.addConnection("mac", "AA:BB:CC:DD:EE:FF");
mqtt.setOriginSupportUrl("https://example.com/device-help");
}
```
For entity identifiers in Home Assistant, prefer **`setDefaultEntityId()`** over legacy **`setObjectId()`**.
+12 -5
View File
@@ -40,20 +40,24 @@ HADevice device(mac, sizeof(mac));
HAMqtt mqtt(client, device);
void setup() {
Ethernet.begin(mac);
mqtt.begin("192.168.1.50", "mqtt_user", "mqtt_password");
Ethernet.begin(mac); // Bring up the network Client before MQTT can connect.
mqtt.begin("192.168.1.50", "mqtt_user", "mqtt_password"); // Connection begins in mqtt.loop().
}
void loop() {
Ethernet.maintain();
mqtt.loop();
Ethernet.maintain(); // Renews DHCP leases where the Ethernet library requires it.
mqtt.loop(); // Services MQTT connection, discovery, and entity traffic.
}
```
### ESP8266 / ESP32 (example)
```cpp
#if defined(ESP8266)
#include <ESP8266WiFi.h>
#elif defined(ESP32)
#include <WiFi.h>
#endif
#include <ArduinoHA.h>
WiFiClient client;
@@ -66,15 +70,18 @@ void setup() {
device.setUniqueId(mac, sizeof(mac));
WiFi.begin("SSID", "password");
// Keep this blocking wait only in a minimal sketch. Production firmware
// should retry, time out, or hand control to its provisioning flow.
while (WiFi.status() != WL_CONNECTED) {
delay(500);
}
// begin() stores broker settings; mqtt.loop() performs connection and recovery.
mqtt.begin("192.168.1.50", "mqtt_user", "mqtt_password");
}
void loop() {
mqtt.loop();
mqtt.loop(); // Service reconnects, subscriptions, and discovery publishing.
}
```
+76 -40
View File
@@ -2,21 +2,25 @@
## Callbacks and tuning
The following snippets extend a sketch that already owns long-lived `HADevice`, `HAMqtt`, and entity objects. Configure callbacks, discovery metadata, and availability before `mqtt.begin(...)`; call the publishing snippets later from normal application logic.
`HAMqtt` supports optional callbacks and PubSubClient tuning:
```cpp
// These callbacks are registered before mqtt.begin(...).
void onMessage(const char* topic, const uint8_t* payload, uint16_t length) { /* ... */ }
void onConnected() { /* ... */ }
void onDisconnected() { /* ... */ }
void onStateChanged(HAMqtt::ConnectionState state) { /* ... */ }
void setup() {
// A successful reconnect creates a new MQTT session, so onConnected runs again.
mqtt.onMessage(onMessage);
mqtt.onConnected(onConnected);
mqtt.onDisconnected(onDisconnected);
mqtt.onStateChanged(onStateChanged);
mqtt.setBufferSize(512); // default 256
mqtt.setKeepAlive(60); // seconds, default 15
mqtt.setBufferSize(512); // Increase only when a larger MQTT packet is required.
mqtt.setKeepAlive(60); // Seconds; the default is 15.
mqtt.begin("192.168.1.50", "user", "pass");
}
```
@@ -27,6 +31,7 @@ Subscribe after each successful connection (for example in `onConnected`), becau
```cpp
void onConnected() {
// MQTT subscriptions belong to this session and must be restored after reconnecting.
mqtt.subscribe("my/custom/topic");
}
```
@@ -36,8 +41,11 @@ Handle payloads in `onMessage`.
## Publishing arbitrary payloads
```cpp
mqtt.publish("customTopic", "payload", false); // not retained
mqtt.publish("customTopic", "payload", true); // retained
// Call after mqtt.begin(...); retain controls whether the broker keeps this value.
void publishApplicationState() {
mqtt.publish("customTopic", "payload", false); // Not retained.
mqtt.publish("customTopic", "payload", true); // Retained.
}
```
## Availability
@@ -45,11 +53,14 @@ mqtt.publish("customTopic", "payload", true); // retained
**Shared availability (recommended):** one availability topic for the whole device — works well with **Last Will** (LWT):
```cpp
device.enableSharedAvailability();
device.setPayloadAvailable("up");
device.setPayloadNotAvailable("down");
device.enableLastWill(); // broker publishes offline when TCP drops
// device.setAvailability(false); // optional: start as offline
// Configure device-wide availability before mqtt.begin(...).
void configureSharedAvailability() {
device.enableSharedAvailability();
device.setPayloadAvailable("up");
device.setPayloadNotAvailable("down");
device.enableLastWill(); // Broker publishes offline when TCP drops.
// device.setAvailability(false); // Optional: start as offline.
}
```
**Per-entity availability:** call `someEntity.setAvailability(true/false)` on each type. Does not use LWT the same way as shared mode; see examples under `examples/availability/`.
@@ -57,16 +68,22 @@ device.enableLastWill(); // broker publishes offline when TCP drops
Custom per-entity payloads are supported:
```cpp
sensor.setPayloadAvailable("ready");
sensor.setPayloadNotAvailable("lost");
// Configure the relevant entity before mqtt.begin(...).
void configureSensorAvailability() {
sensor.setPayloadAvailable("ready");
sensor.setPayloadNotAvailable("lost");
}
```
For multi-topic availability discovery, add full MQTT topics and a mode:
```cpp
sensor.setAvailabilityMode("all");
sensor.addAvailabilityEntry("bridge/status");
sensor.addAvailabilityEntry("sensor/status", "{{ value_json.state }}");
// `sensor` is the entity whose availability depends on these external topics.
void configureExternalAvailability() {
sensor.setAvailabilityMode("all");
sensor.addAvailabilityEntry("bridge/status");
sensor.addAvailabilityEntry("sensor/status", "{{ value_json.state }}");
}
```
## Discovery helpers by entity
@@ -74,40 +91,53 @@ sensor.addAvailabilityEntry("sensor/status", "{{ value_json.state }}");
Common entity discovery metadata is available on most entity classes:
```cpp
entity.setEnabledByDefault(false);
entity.setEntityPicture("https://example.com/entity.png");
entity.setQos(1);
entity.setEncoding("utf-8");
entity.setEntityCategory("diagnostic");
// `entity` is an entity type that supports these common discovery fields.
void configureCommonDiscovery(HABaseDeviceType& entity) {
entity.setEnabledByDefault(false);
entity.setEntityPicture("https://example.com/entity.png");
entity.setQos(1);
entity.setEncoding("utf-8");
entity.setEntityCategory("diagnostic");
}
```
Read-only sensor presentation/template helpers:
```cpp
sensor.setSuggestedDisplayPrecision(2);
sensor.setValueTemplate("{{ value_json.temperature }}");
sensor.setJsonAttributesTemplate("{{ value_json.attrs | tojson }}");
sensor.setLastResetValueTemplate("{{ value_json.last_reset }}");
sensor.setDeviceClass("enum");
sensor.setOptions("idle;charging;discharging;fault");
// `sensor` is a read-only HASensor instance.
void configureSensorPresentation(HASensor& sensor) {
sensor.setSuggestedDisplayPrecision(2);
sensor.setValueTemplate("{{ value_json.temperature }}");
sensor.setJsonAttributesTemplate("{{ value_json.attrs | tojson }}");
sensor.setLastResetValueTemplate("{{ value_json.last_reset }}");
sensor.setDeviceClass("enum");
sensor.setOptions("idle;charging;discharging;fault");
}
```
Writable entity template/payload helpers:
```cpp
mySwitch.setPayloadOn("ENABLE");
mySwitch.setPayloadOff("DISABLE");
mySwitch.setStateOn("running");
mySwitch.setStateOff("stopped");
mySwitch.setValueTemplate("{{ value_json.state }}");
mySwitch.setCommandTemplate("{{ value_json.command }}");
// These names refer to the matching writable entity instances in the application.
void configureWritableEntities(HASwitch& mySwitch,
HANumber& myNumber,
HASelect& mySelect,
HAText& myText,
HAButton& myButton) {
mySwitch.setPayloadOn("ENABLE");
mySwitch.setPayloadOff("DISABLE");
mySwitch.setStateOn("running");
mySwitch.setStateOff("stopped");
mySwitch.setValueTemplate("{{ value_json.state }}");
mySwitch.setCommandTemplate("{{ value_json.command }}");
myNumber.setPayloadReset("RESET");
myNumber.setCommandTemplate("{{ value | float | round(1) }}");
myNumber.setPayloadReset("RESET");
myNumber.setCommandTemplate("{{ value | float | round(1) }}");
mySelect.setCommandTemplate("{{ value_json.choice }}");
myText.setCommandTemplate("{{ value_json.text }}");
myButton.setPayloadPress("PRESS");
mySelect.setCommandTemplate("{{ value_json.choice }}");
myText.setCommandTemplate("{{ value_json.text }}");
myButton.setPayloadPress("PRESS");
}
```
## Compiler macros
@@ -121,12 +151,9 @@ Defined in `ArduinoHADefines.h` or via build flags.
lifecycle callbacks remain ordinary function pointers.
- **`ARDUINOHA_DEBUG`** — enables ArduinoHA logging by default and sets the initial maximum verbosity to `Debug`. Without this flag, structured logs are compiled in but remain disabled until you call `arduinoHASetLogEnabled(true)`.
Structured logging is available through:
Structured logging is available through a long-lived sink:
```cpp
arduinoHASetLogEnabled(true);
arduinoHASetLogLevel(ArduinoHALogLevel::Trace);
class MyLogSink : public ArduinoHALogSink {
public:
void log(const ArduinoHALogMessage& msg) override {
@@ -138,6 +165,15 @@ public:
Serial.println(msg.text);
}
};
MyLogSink logSink; // The installed sink must outlive ArduinoHA logging.
void setup() {
Serial.begin(115200);
arduinoHASetLogSink(&logSink);
arduinoHASetLogEnabled(true);
arduinoHASetLogLevel(ArduinoHALogLevel::Trace);
}
```
`arduinoHALog(...)` and `arduinoHALogf(...)` support subsystem-tagged messages such as `mqtt`, `discovery`, `availability`, `serializer`, `entity`, and `device`. Install a sink when you want those messages to join your application’s normal serial or structured log stream.
+1 -1
View File
@@ -16,7 +16,7 @@ platform_packages =
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
+1 -1
View File
@@ -16,7 +16,7 @@ platform_packages =
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
@@ -16,7 +16,7 @@ platform_packages =
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
+1 -1
View File
@@ -16,7 +16,7 @@ platform_packages =
[env:esp32]
extends = common
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags =
+3
View File
@@ -7,6 +7,9 @@ pio run -d examples/01-esp-sensor -e esp8266
pio run -d examples/01-esp-sensor -e esp8266 -t upload
```
The checked-in `esp32` environment uses Arduino-ESP32 3.3.11. A consuming
application should choose and pin its complete PlatformIO platform stack.
| Guided example | What it demonstrates |
| --- | --- |
| [ESP Sensor](01-esp-sensor/) | Wi-Fi application wiring, a unique device ID, and changing numeric telemetry |
+4 -2
View File
@@ -12,12 +12,14 @@ HAMqtt mqtt(client, device);
void onMqttMessage(const char* topic, const uint8_t* payload, uint16_t length) {
// This callback is called when message from MQTT broker is received.
// Please note that you should always verify if the message's topic is the one you expect.
// For example: if (memcmp(topic, "myCustomTopic") == 0) { ... }
// For example: if (strcmp(topic, "myCustomTopic") == 0) { ... }
Serial.print("New message on topic: ");
Serial.println(topic);
Serial.print("Data: ");
Serial.println((const char*)payload);
// MQTT payloads are length-delimited; they are not guaranteed to end in NUL.
Serial.write(payload, length);
Serial.println();
mqtt.publish("myPublishTopic", "hello");
}
+6 -1
View File
@@ -3,6 +3,7 @@
; Full suite:
; pio test -e esp8266
; pio test -e esp32
; ./scripts/test.sh compile --platform esp32
;
; Single suite (examples):
; pio test -e esp8266 --filter test_utils_serializers
@@ -30,11 +31,15 @@ extends = common
test_ignore = test_native_core
platform = espressif8266
board = d1_mini
; Keep root Unity coverage on the same Postmortem relocation-fix snapshot as
; the maintained ESP8266 examples. See docs/ESP8266-LINKER-WORKAROUND.md.
platform_packages =
platformio/framework-arduinoespressif8266 @ https://github.com/esp8266/Arduino.git#521ae60a89e64bb0d1eb7a0b7addf620ced5cad3
[env:esp32]
extends = common
test_ignore = test_native_core
platform = https://github.com/pioarduino/platform-espressif32/releases/download/51.03.05/platform-espressif32.zip
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.311/platform-espressif32.zip
board = esp32dev
build_unflags = -std=gnu++11
build_flags = ${common.build_flags} -std=gnu++14
+31 -1
View File
@@ -8,6 +8,32 @@ required=(
examples/README.md
)
check_cpp_fence_scope() {
local markdown="$1"
awk '
function brace_delta(line, copy) {
copy = line
return gsub(/\{/, "{", copy) - gsub(/\}/, "}", copy)
}
/^```cpp[[:space:]]*$/ { in_cpp = 1; depth = 0; next }
in_cpp && /^```[[:space:]]*$/ { in_cpp = 0; next }
in_cpp {
line = $0
sub(/^[[:space:]]+/, "", line)
if (depth == 0 &&
(line ~ /^(if|for|while|switch)[[:space:]]*\(/ ||
line ~ /^[A-Za-z_][A-Za-z0-9_:]*::[A-Za-z0-9_]+[[:space:]]*\(/ ||
line ~ /^[A-Za-z_][A-Za-z0-9_]*\./ ||
line ~ /^[A-Za-z_][A-Za-z0-9_]*[[:space:]]*\(/)) {
printf "%s:%d: C++ expression appears at namespace scope; wrap it in a function.\n", FILENAME, FNR > "/dev/stderr"
failed = 1
}
depth += brace_delta($0)
}
END { exit failed }
' "$markdown"
}
for path in "${required[@]}"; do
[[ -f "$root/$path" ]] || { echo "Missing required documentation: $path" >&2; exit 1; }
done
@@ -22,7 +48,7 @@ while IFS= read -r -d '' markdown; do
esac
[[ -e "$candidate" ]] || { echo "Broken relative link in ${markdown#$root/}: $target" >&2; exit 1; }
done < <(sed -nE 's/.*\]\(([^ )]+)( "[^"]*")?\).*/\1/p' "$markdown")
done < <(find "$root" -path "$root/.git" -prune -o -name '*.md' -type f -print0)
done < <(find "$root" -path "$root/.git" -prune -o -path '*/.pio' -prune -o -name '*.md' -type f -print0)
while IFS= read -r example; do
for required in README.md platformio.ini; do
@@ -34,4 +60,8 @@ while IFS= read -r example; do
}
done < <(find "$root/examples" -mindepth 1 -maxdepth 1 -type d -name '[0-9][0-9]-*' -print | sort)
while IFS= read -r markdown; do
check_cpp_fence_scope "$markdown"
done < <(find "$root" -path "$root/.git" -prune -o -path '*/.pio' -prune -o -name '*.md' -type f -print)
echo "Documentation links and required files passed"
+27 -2
View File
@@ -13,9 +13,34 @@ case "${3:-}" in
esac
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
pio_for_platform() {
if [[ "$environment" != "esp32" ]]; then
pio "$@"
return
fi
# Keep Core 3.3.11's package-form esptool and generated pioarduino
# environment in the persistent cache shared by the maintained framework
# repositories. It is never cleared by this script, and isolates this
# tested graph from stale global metadata without duplicate downloads.
local core_dir packages_dir cache_dir
core_dir="${ARDUINOHA_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11}"
packages_dir="${ARDUINOHA_PLATFORMIO_PACKAGES_DIR:-$core_dir/packages}"
cache_dir="${ARDUINOHA_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
PLATFORMIO_CORE_DIR="$core_dir" PLATFORMIO_PACKAGES_DIR="$packages_dir" \
PLATFORMIO_CACHE_DIR="$cache_dir" pio "$@"
}
case "$1" in
compile)
pio test -d "$root" -e "$environment" --without-uploading --without-testing
# PlatformIO's test command starts many archive jobs by default. This
# multi-suite project has a reproducible missing-object race on Core
# 3.3.11 at high parallelism, so use a deterministic default while
# preserving an explicit caller override.
export PLATFORMIO_RUN_JOBS="${PLATFORMIO_RUN_JOBS:-1}"
pio_for_platform test -d "$root" -e "$environment" --without-uploading --without-testing
echo "ArduinoHA compile check passed for $environment"
;;
examples)
@@ -25,7 +50,7 @@ case "$1" in
exit 1
fi
for example in "${examples[@]}"; do
pio run -d "$example" -e "$environment" </dev/null
pio_for_platform run -d "$example" -e "$environment" </dev/null
done
echo "ArduinoHA examples compile check passed for $environment"
;;
@@ -42,34 +42,34 @@ class HomeAssistantClient:
token_file = state / "ha-token"
cls.wait_until_ready(base_url)
if token_file.exists():
return cls(base_url, token_file.read_text(encoding="utf-8").strip())
client_id = "http://ha-mqtt-test-harness.local/"
user = {
"client_id": client_id,
"name": owner_name,
"username": username,
"password": password,
"language": "en",
}
created = cls._response_json(
requests.post(f"{base_url}/api/onboarding/users", json=user, timeout=10),
"Home Assistant onboarding user creation",
)
auth_code = created.get("auth_code")
if not auth_code:
raise TestHarnessError("Home Assistant onboarding did not return an auth_code")
token_response = cls._response_json(
requests.post(
f"{base_url}/auth/token",
data={"client_id": client_id, "grant_type": "authorization_code", "code": auth_code},
timeout=10,
),
"Home Assistant token exchange",
)
token = token_response.get("access_token")
if not token:
raise TestHarnessError("Home Assistant token exchange did not return an access_token")
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 (
@@ -81,6 +81,51 @@ class HomeAssistantClient:
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
+2 -2
View File
@@ -81,7 +81,7 @@ void onNativeStateChanged(HAMqtt::ConnectionState)
}
void test_json_helpers_escape_control_bytes_and_preserve_cursor_contract()
void test_json_helpers_escape_control_bytes_and_preserve_cursor_position()
{
const char value[] = "quote\" slash\\ newline\n tab\t control\x01";
char output[96] = {};
@@ -341,7 +341,7 @@ void tearDown(void) { }
int main(int, char**)
{
UNITY_BEGIN();
RUN_TEST(test_json_helpers_escape_control_bytes_and_preserve_cursor_contract);
RUN_TEST(test_json_helpers_escape_control_bytes_and_preserve_cursor_position);
RUN_TEST(test_availability_and_serializer_array_escape_json_values);
RUN_TEST(test_discovery_topic_tokens_are_rejected_before_topic_generation);
RUN_TEST(test_streaming_serializer_writes_exact_escaped_payload_to_mqtt_mock);