Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 52 additions & 2 deletions dashboard/pumpspy-dashboard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: >-
Expand All @@ -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 ────────────────────────────────────────────
#
Expand Down
40 changes: 38 additions & 2 deletions tests/test_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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."""
Expand All @@ -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]:
Expand Down Expand Up @@ -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.

Expand All @@ -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 == [], (
Expand Down
Loading