- uimodel: editing a listed inverter keeps the stored flat keys, and a region of "own" keeps a stored price_taxes_profile (both were dropped). - haentities: a failed read also waits CACHE_S, so polls while Home Assistant is down do not queue one 10 s read each. - ui_text: the Flanders preset names its DSO, and the help says Wallonia and Brussels have no figures yet. REGION_PRESETS cites the card. - tests: decode of a reversed solar row, both kept keys, the backoff. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
7.6 KiB
Python
228 lines
7.6 KiB
Python
"""UX-13 P0: the entity list for the picker. `python3 test_haentities.py`
|
|
|
|
`GET ha/entities` reads Home Assistant's states through the add-on's own
|
|
session (hass.states()) and must never fail the page: part 1 drives the real
|
|
HomeAssistant client against a local server that answers 502, garbage, the
|
|
wrong shape, nothing at all, or too slowly. Part 2 checks the 30 s cache and
|
|
what is served while Home Assistant is down. Part 3 serves the real route.
|
|
Mutating a module this file grades? Read MUTATION.md first (purge __pycache__).
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import sys
|
|
import time
|
|
|
|
from aiohttp import ClientSession, web as aioweb
|
|
|
|
from app import hass as H, haentities as E, web
|
|
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
STATES = [
|
|
{"entity_id": "sensor.grid", "state": "812",
|
|
"attributes": {"friendly_name": "Grid power", "unit_of_measurement": "W",
|
|
"device_class": "power"}},
|
|
{"entity_id": "sensor.soc", "state": "54",
|
|
"attributes": {"friendly_name": "Battery level", "unit_of_measurement": "%",
|
|
"device_class": "battery"}},
|
|
{"entity_id": "number.setpoint", "state": "0", "attributes": {}},
|
|
{"entity_id": "sensor.pv_kw", "state": "1.2",
|
|
"attributes": {"unit_of_measurement": "kW"}},
|
|
{"no_entity_id": True},
|
|
]
|
|
|
|
|
|
async def serve(handler):
|
|
app = aioweb.Application()
|
|
app.router.add_get("/api/states", handler)
|
|
runner = aioweb.AppRunner(app)
|
|
await runner.setup()
|
|
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
|
await site.start()
|
|
return runner, site._server.sockets[0].getsockname()[1]
|
|
|
|
|
|
async def read_with(handler, timeout_s=10):
|
|
runner, port = await serve(handler)
|
|
H.CORE_API, H.STATES_TIMEOUT_S = f"http://127.0.0.1:{port}/api", timeout_s
|
|
try:
|
|
async with ClientSession() as session:
|
|
t0 = time.monotonic()
|
|
got = await H.HomeAssistant(session, "token").states()
|
|
read_with.took = time.monotonic() - t0
|
|
return got
|
|
finally:
|
|
await runner.cleanup()
|
|
|
|
|
|
async def ok(_r):
|
|
return aioweb.json_response(STATES)
|
|
|
|
|
|
async def bad_gateway(_r):
|
|
return aioweb.Response(status=502)
|
|
|
|
|
|
async def garbage(_r):
|
|
return aioweb.Response(text="<html>", content_type="application/json")
|
|
|
|
|
|
async def not_a_list(_r):
|
|
return aioweb.json_response({"message": "hi"})
|
|
|
|
|
|
async def slow(_r):
|
|
await asyncio.sleep(2)
|
|
return aioweb.json_response(STATES)
|
|
|
|
|
|
async def closed_port():
|
|
H.CORE_API = "http://127.0.0.1:9/api"
|
|
async with ClientSession() as session:
|
|
return await H.HomeAssistant(session, "token").states()
|
|
|
|
|
|
print("1. hass.states() answers None, never an exception")
|
|
check("a normal answer is the state list", asyncio.run(read_with(ok)) == STATES)
|
|
for name, handler in (("502 while HA restarts", bad_gateway),
|
|
("a body that is not JSON", garbage),
|
|
("JSON that is not a list", not_a_list)):
|
|
check(f"{name} -> None", asyncio.run(read_with(handler)) is None)
|
|
check("an answer slower than the timeout -> None, at the timeout",
|
|
asyncio.run(read_with(slow, timeout_s=0.3)) is None
|
|
and read_with.took < 1.0)
|
|
check("no server at all -> None", asyncio.run(closed_port()) is None)
|
|
check("the timeout for the list is the same 10 s as every other read",
|
|
"STATES_TIMEOUT_S = 10 " in open(H.__file__, encoding="utf-8").read())
|
|
|
|
|
|
class FakeHass:
|
|
def __init__(self):
|
|
self.answer, self.reads = STATES, 0
|
|
|
|
async def states(self):
|
|
self.reads += 1
|
|
await asyncio.sleep(0.01)
|
|
return self.answer
|
|
|
|
|
|
class Clock:
|
|
def __init__(self):
|
|
self.t = 1000.0
|
|
|
|
def __call__(self):
|
|
return self.t
|
|
|
|
|
|
print("\n2. the cache, and Home Assistant being down")
|
|
_h, _c = FakeHass(), Clock()
|
|
_cache = E.EntityCache(_h, clock=_c)
|
|
|
|
|
|
async def burst():
|
|
return await asyncio.gather(*(_cache.payload("power") for _ in range(5)))
|
|
|
|
|
|
_p = asyncio.run(burst())
|
|
check("five pickers opening at once cost one read of Home Assistant",
|
|
_h.reads == 1 and all(x == _p[0] for x in _p))
|
|
_ids = [r["entity_id"] for r in _p[0]["entities"]]
|
|
check("power entities come first, then the rest, and a broken state is left "
|
|
f"out ({_ids})", _ids == ["sensor.grid", "sensor.pv_kw",
|
|
"number.setpoint", "sensor.soc"]
|
|
and [r["match"] for r in _p[0]["entities"]] == [True, True, False, False])
|
|
check("each row carries id, name, state, unit and device class",
|
|
_p[0]["entities"][0] == {"entity_id": "sensor.grid", "name": "Grid power",
|
|
"state": "812", "unit": "W",
|
|
"device_class": "power", "match": True})
|
|
check("the kinds filter as the pickers need",
|
|
[r["entity_id"] for r in _p[0]["entities"]
|
|
if E.matches(r, "pct")] == ["sensor.soc"]
|
|
and [r["entity_id"] for r in _p[0]["entities"]
|
|
if E.matches(r, "number")] == ["number.setpoint"])
|
|
_c.t += 10
|
|
asyncio.run(_cache.payload())
|
|
check("within 30 s the cached list is served", _h.reads == 1)
|
|
check("peek() hands the validator the fresh list without a read",
|
|
set(_cache.peek()) == set(_ids) and _h.reads == 1)
|
|
_h.answer = None
|
|
_c.t += 30
|
|
_down = asyncio.run(_cache.payload())
|
|
check("Home Assistant down after a good read: the old list, marked old",
|
|
_h.reads == 2 and _down["available"] and _down["text"] == E.STALE_TEXT
|
|
and len(_down["entities"]) == 4 and _down["age_s"] >= 30)
|
|
check("...and peek() then gives nothing, so no warning rests on an old list",
|
|
_cache.peek() is None)
|
|
|
|
|
|
async def down_windows():
|
|
"""One loop throughout: a contended asyncio.Lock binds to its loop."""
|
|
hass, clock = FakeHass(), Clock()
|
|
hass.answer = None
|
|
cache = E.EntityCache(hass, clock=clock)
|
|
counts = []
|
|
for step in (0, 1, 30):
|
|
clock.t += step
|
|
await asyncio.gather(*(cache.payload() for _ in range(5)))
|
|
counts.append(hass.reads)
|
|
return counts
|
|
|
|
|
|
check("five polls while Home Assistant is down cost one read, the polls just "
|
|
"after wait for the next window, and that window tries once",
|
|
asyncio.run(down_windows()) == [1, 1, 2])
|
|
_h2 = FakeHass()
|
|
_h2.answer = None
|
|
_empty = asyncio.run(E.EntityCache(_h2).payload("power"))
|
|
check("Home Assistant down from the start: an empty list that says why",
|
|
_empty == {"available": False, "text": E.DOWN_TEXT, "entities": []})
|
|
|
|
|
|
print("\n3. the route")
|
|
|
|
|
|
class FakeController:
|
|
def __init__(self, hass):
|
|
self.hass, self.o = hass, {}
|
|
|
|
|
|
async def route(hass, query=""):
|
|
app = web.build_app(FakeController(hass), selfcfg=object())
|
|
runner = aioweb.AppRunner(app)
|
|
await runner.setup()
|
|
site = aioweb.TCPSite(runner, "127.0.0.1", 0)
|
|
await site.start()
|
|
port = site._server.sockets[0].getsockname()[1]
|
|
try:
|
|
async with ClientSession() as s:
|
|
async with s.get(f"http://127.0.0.1:{port}/ha/entities{query}") as r:
|
|
return r.status, await r.json()
|
|
finally:
|
|
await runner.cleanup()
|
|
|
|
|
|
_st, _body = asyncio.run(route(FakeHass(), "?kind=pct"))
|
|
check("GET ha/entities?kind=pct answers 200 with the matching entity first",
|
|
_st == 200 and _body["entities"][0]["entity_id"] == "sensor.soc")
|
|
_st, _body = asyncio.run(route(_h2))
|
|
check("with Home Assistant down it still answers 200, with the reason",
|
|
_st == 200 and _body["available"] is False and _body["entities"] == [])
|
|
check("the words shown are short and plain (STYLE: one sentence)",
|
|
all(len(t.split()) <= 12 and t.count(".") == 1
|
|
for t in (E.DOWN_TEXT, E.STALE_TEXT)))
|
|
print(json.dumps(_body))
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED")
|
|
sys.exit(1)
|
|
print("all passed")
|