diff --git a/dashboard/pumpspy-dashboard.yaml b/dashboard/pumpspy-dashboard.yaml index 929cb5e..1f07147 100644 --- a/dashboard/pumpspy-dashboard.yaml +++ b/dashboard/pumpspy-dashboard.yaml @@ -12,7 +12,9 @@ # 3. Find-and-replace `your_device_id` with your own device id. It is in your # entity ids -- look at any entity this integration made, for example # `sensor.pumpspy_123456789012345_battery_voltage`, and take the number in -# the middle. +# the middle. One entity is deliberately left alone by that replace: +# `binary_sensor.pumpspy_local_vendor_reachable` belongs to the integration +# itself rather than to a pump, so it has no device id in it. # # The pit drawing is to scale on the vertical axis, but it is scaled to *one # particular pit* -- 16 inches deep, floats at 7, 12 and 14 inches. If yours @@ -240,6 +242,16 @@ views: # restarts the device took over 7 minutes to resume -- it appears # to back off once it has been refused -- so anything tighter # cries wolf on every restart. + # + # When it does go stale it names the suspect, because a vendor + # outage and a broken redirect look identical from here: both end + # with every chip frozen. The vendor sensor is the only thing + # that separates them, so this is where it earns its place. Note + # the past tense in "vendor was OK". Nothing is forwarded while + # the device is quiet, so nothing is being learned either, and + # that verdict is the last one we got rather than a live one. It + # is still the useful one: it was taken at about the moment the + # reports stopped, which is exactly the question being asked. - type: template entity: sensor.pumpspy_your_device_id_battery_voltage icon: >- @@ -251,7 +263,45 @@ views: {{ 'green' if age.total_seconds() < 420 else 'red' }} content: >- {% set age = now() - states[entity].last_reported %} - {{ 'Live' if age.total_seconds() < 420 else 'No data' }} + {% set vendor = + states('binary_sensor.pumpspy_local_vendor_reachable') %} + {% if age.total_seconds() < 420 %}Live + {% elif vendor == 'off' %}No data, vendor down + {% elif vendor == 'on' %}No data, vendor was OK + {% else %}No data{% endif %} + + # Deliberately not a permanent tile. Vendor reachability is on + # essentially all the time, and something that reads green for months + # is something you stop reading, so it appears only while it has + # anything to say. Plain markdown for the same reason as the alerts + # above: it should not depend on a HACS component loading. + - type: conditional + conditions: + - condition: state + entity: binary_sensor.pumpspy_local_vendor_reachable + state: "off" + card: + type: markdown + content: >- + ### ☁️ PumpSpy isn't answering + + + Your sump is still being watched. Everything on this dashboard + comes from the device over your own network, so the alerts, the + battery figures and the history are all unaffected. + + + What's stopped is the phone app. Nothing is getting through to + PumpSpy, so it can't alert you and it may well show the monitor + as offline. Until this clears, Home Assistant is the only thing + watching your basement, and that's worth knowing tonight rather + than the next time it rains. + + + It usually fixes itself within the hour. The device would + normally give up and stop reporting to anyone during an outage + this long, so this integration answers its login itself to keep + it talking. # ── The backup pump ran ──────────────────────────────────────────── # diff --git a/tests/test_dashboard.py b/tests/test_dashboard.py index c8fef99..30411e1 100644 --- a/tests/test_dashboard.py +++ b/tests/test_dashboard.py @@ -18,6 +18,11 @@ from homeassistant.util import slugify from custom_components.pumpspy_local import binary_sensor, button, event, sensor +from custom_components.pumpspy_local.const import ( + LOCAL_AUTH_ENTITY_NAME, + SERVICE_DEVICE_NAME, + VENDOR_ENTITY_NAME, +) DASHBOARD = ( Path(__file__).parent.parent / "dashboard" / "pumpspy-dashboard.yaml" @@ -35,6 +40,18 @@ # the rule to something a real id could slip through. EXAMPLE_DEVICE_ID = "123456789012345" +# The integration's own service device owns a couple of entities that describe +# the proxy rather than a pump, so no device id appears in their ids and the +# find-and-replace in the instructions never touches them. They are built by +# hand rather than from an entity description, so the rebuild below cannot see +# them, and the placeholder rule has to let them through explicitly -- naming +# them one by one rather than exempting anything that lacks a device id, which +# would exempt a typo too. +SERVICE_ENTITY_IDS = frozenset( + f"binary_sensor.{slugify(f'{SERVICE_DEVICE_NAME} {name}')}" + for name in (VENDOR_ENTITY_NAME, LOCAL_AUTH_ENTITY_NAME) +) + def _entity_ids_the_integration_creates() -> set[str]: """Rebuild the entity ids from the descriptions, as HA would.""" @@ -51,7 +68,7 @@ def _entity_ids_the_integration_creates() -> set[str]: f"{domain}.{slugify(f'PumpSpy {PLACEHOLDER} {description.name}')}" for domain, descriptions in by_domain.items() for description in descriptions - } + } | SERVICE_ENTITY_IDS def _referenced_entity_ids(node: object) -> set[str]: @@ -102,6 +119,25 @@ def test_every_entity_the_dashboard_names_is_one_we_create(dashboard): ) +def test_the_dashboard_can_tell_the_two_silences_apart(dashboard): + """The stale chips must say *which* failure this is. + + A vendor outage and a broken redirect both end with the display going + stale, and the vendor sensor is the only thing that separates them. It is + read from inside a Jinja template as well as from a card condition, and the + walk above only sees the latter, so this checks the raw text too. + """ + vendor = f"binary_sensor.{slugify(f'{SERVICE_DEVICE_NAME} {VENDOR_ENTITY_NAME}')}" + + assert vendor in _referenced_entity_ids(dashboard), ( + "no card conditions on vendor reachability" + ) + assert DASHBOARD.read_text().count(vendor) > 1, ( + "the liveness chip does not consult the vendor sensor, so a stale " + "dashboard still cannot say which silence it is" + ) + + def test_no_real_device_id_appears_anywhere_in_the_dashboard(): """Not just in entity ids -- anywhere, comments included. @@ -124,7 +160,7 @@ def test_the_dashboard_ships_without_a_real_device_id(dashboard): offenders = sorted( entity_id for entity_id in _referenced_entity_ids(dashboard) - if PLACEHOLDER not in entity_id + if PLACEHOLDER not in entity_id and entity_id not in SERVICE_ENTITY_IDS ) assert offenders == [], (