mirror of
https://github.com/alexhopeoconnor/arduino-home-assistant.git
synced 2026-10-04 02:48:13 +10:00
281 lines
12 KiB
Python
281 lines
12 KiB
Python
"""Home Assistant MQTT discovery contract 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_contract import (
|
|
ContractError,
|
|
HomeAssistantClient,
|
|
RetainedPublisher as SharedRetainedPublisher,
|
|
wait_until,
|
|
)
|
|
|
|
|
|
HA_URL = os.environ.get("HA_URL", "http://homeassistant:8123").rstrip("/")
|
|
MQTT_HOST = os.environ.get("MQTT_HOST", "mqtt")
|
|
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
|
|
MODE = os.environ.get("CONTRACT_MODE", "migration")
|
|
STATE = pathlib.Path("/state")
|
|
TOKEN_FILE = STATE / "ha-token"
|
|
DEVICE_ID = "contract_device"
|
|
EDGE_DEVICE_ID = "contract_edge_device"
|
|
EXPECT_DISABLED_CLEANUP = os.environ.get("CONTRACT_EXPECT_DISABLED_CLEANUP") == "1"
|
|
|
|
|
|
def fail(message):
|
|
raise ContractError(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"contract/{object_id}/state",
|
|
}
|
|
payload.update(extra)
|
|
return payload
|
|
|
|
|
|
def device_payload(components, device_id=DEVICE_ID):
|
|
return {
|
|
"dev": {"ids": [device_id], "name": "ArduinoHA contract device"},
|
|
"o": {"name": "ArduinoHA", "sw": "contract"},
|
|
"cmps": components,
|
|
}
|
|
|
|
|
|
def legacy_payload(object_id, unique_id=None, device_id=DEVICE_ID, **extra):
|
|
payload = component(object_id, unique_id, **extra)
|
|
payload["dev"] = {"ids": [device_id], "name": "ArduinoHA contract device"}
|
|
payload.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_contract():
|
|
publisher = SharedRetainedPublisher(MQTT_HOST, MQTT_PORT)
|
|
ws = HomeAssistantClient.bootstrap(
|
|
HA_URL,
|
|
STATE,
|
|
owner_name="ArduinoHA Contract Owner",
|
|
username="arduinoha-contract",
|
|
password="arduinoha-contract-password",
|
|
)
|
|
ws.configure_mqtt(MQTT_HOST, MQTT_PORT)
|
|
try:
|
|
# Existing single-component entity and a user-owned registry customization.
|
|
unique = f"{DEVICE_ID}_temperature"
|
|
publisher.publish(legacy_topic("temperature"), json.dumps(legacy_payload("temperature", unique)))
|
|
publisher.publish("contract/temperature/state", "21.5")
|
|
original = wait_for_entry(ws, unique)
|
|
original_id = original["id"]
|
|
renamed_entity_id = "sensor.contract_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 contract.
|
|
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 contract: def_ent_id is
|
|
# allowed on first creation; obsolete obj_id is intentionally absent.
|
|
default_payload = legacy_payload("default_name", def_ent_id="contract_default_name")
|
|
if "obj_id" in default_payload:
|
|
fail("contract 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_contract():
|
|
if not (STATE / "migration-complete").exists():
|
|
fail("retained-restart mode requires the migration contract to run first")
|
|
publisher = SharedRetainedPublisher(MQTT_HOST, MQTT_PORT)
|
|
ws = HomeAssistantClient.from_state(HA_URL, STATE)
|
|
try:
|
|
migrated = wait_for_entry(ws, f"{DEVICE_ID}_temperature")
|
|
if migrated["entity_id"] != "sensor.contract_temperature_user_name":
|
|
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_contract()
|
|
elif MODE == "retained-restart":
|
|
retained_restart_contract()
|
|
else:
|
|
fail(f"unknown CONTRACT_MODE: {MODE}")
|
|
print(f"Home Assistant MQTT contract mode {MODE} passed")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|