From 16bb4777496e78d019965c3b43497c2b61cded2b Mon Sep 17 00:00:00 2001 From: Graham Matuszewski Date: Mon, 24 Aug 2026 21:27:05 -0400 Subject: [PATCH] Say which silence it is when the dashboard goes quiet A vendor outage and a broken redirect end the same way here: every chip freezes and the display cannot tell you which one happened. The vendor sensor from #19 is the only thing that separates them, and the dashboard was not reading it. The liveness chip now names the suspect when it goes stale, and a card appears while the vendor is unreachable to say that the sump is still being watched but the phone app is deaf. Neither takes up space while things are working, which is deliberate: reachability is on nearly all the time, and a tile that reads green for months is one you stop reading. The chip's wording keeps the past tense. Nothing is forwarded while the device is quiet, so the verdict freezes with it, and what it reports is the last answer we got rather than a live one. That answer was taken at about the moment the reports stopped, which is the question being asked. Closes #28 --- dashboard/pumpspy-dashboard.yaml | 54 ++++++++++++++++++++++++++++++-- tests/test_dashboard.py | 40 +++++++++++++++++++++-- 2 files changed, 90 insertions(+), 4 deletions(-) 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 == [], (