Skip to content

feat(reliability): cloud-outage stale tolerance + aggregator binary_sensor - #6

Open
JLay2026 wants to merge 24 commits into
Bitf1ip:mainfrom
JLay2026:upstream/v0.4.0-cloud-stale
Open

feat(reliability): cloud-outage stale tolerance + aggregator binary_sensor#6
JLay2026 wants to merge 24 commits into
Bitf1ip:mainfrom
JLay2026:upstream/v0.4.0-cloud-stale

Conversation

@JLay2026

Copy link
Copy Markdown

Per-thing entities currently flip to unavailable after 30 s of MQTT silence, which causes the entire dashboard to grey out on any WAN flicker. This PR introduces a three-state machine (freshstaleunavailable) with a configurable retention window, plus a global aggregator binary sensor that surfaces the outage as a first-class signal.

What changes for the operator

State When Read entities (sensor/binary_sensor) Write entity (select)
fresh ≤ 30 s since last MQTT message available, current value available
stale 30 s — retention (default 600 s) available, cached value + stale: true attr unavailable
unavailable past retention unavailable (HA tile greys out) unavailable

New global aggregator binary_sensor.hydros_cloud_stale (device_class=PROBLEM, entity_category=DIAGNOSTIC):

  • off: all collectives fresh
  • on: ≥1 stale
  • unavailable: ≥1 unavailable

Operators can write alerts that fire only on actual unavailability instead of on every brief flicker.

Design decisions

  • Trigger: MQTT freshness only, not combined with API backoff state. Simpler mental model; the two systems compose (see new "Cloud-stale envelope" section in docs/RATE_LIMITS.md).
  • Aggregator enabled by default, entity_category=DIAGNOSTIC so it doesn't clutter primary dashboards.
  • select is stricter (available = (state == 'fresh')) — writing a mode change against a disconnected device would silently fail.

Retention window

Configurable per config entry via options flow → Cloud stale retention seconds. Default 600 s (10 min). Clamped to [30, 3600].

Transition logging

One WARNING per state change per thing_id, not per refresh tick. A 30-min outage produces ~3 log lines (fresh→stale, stale→unavailable, unavailable→fresh on recovery), not 360.

Test plan

python -m unittest tests.test_cloud_stale

10 standalone tests covering empty state, fresh/stale/unavailable boundaries, retention clamping, global aggregate semantics, once-per-transition logging.

Provenance

Mirrors JLay2026/ha-hydros#17.

⚠️ Diff scope

Intended files — the rest of the diff is fork divergence:

  • custom_components/hydros/const.py (4 cloud-stale constants)
  • custom_components/hydros/hydros_hub.py (classifier methods + transition logging)
  • custom_components/hydros/sensor.py (available rewrite + stale attr)
  • custom_components/hydros/binary_sensor.py (available rewrite + new aggregator class)
  • custom_components/hydros/select.py (available = fresh-only)
  • custom_components/hydros/config_flow.py (retention options-flow field)
  • tests/test_cloud_stale.py (new)
  • docs/RATE_LIMITS.md (new "Cloud-stale envelope" section)

Composes cleanly with the rate-limit/backoff PR (#4 in this set) but doesn't strictly depend on it.

JLay2026 and others added 24 commits May 6, 2026 21:03
select.py:238 calls self._hub.async_force_status_from_api(thing_id) and
select.py:287 calls self._hub.invalidate_collective_config(thing_id).
Neither method existed on HydrosHub. When async_change_mode raises, the
exception handler in async_select_option invokes _async_refresh_options(force=True),
which calls invalidate_collective_config un-wrapped and crashes with
AttributeError before reaching the API refresh, replacing the original
HydrosAPIError in the user-facing log.

This commit adds both methods:
- invalidate_collective_config(thing_id): drops the cached collective
  config and the per-thing config lock so the next read re-fetches.
  Idempotent on unknown thing_ids. @callback-decorated.
- async_force_status_from_api(thing_id): pulls api.get_thing(thing_id)
  via executor, updates _collective_cache, merges any status/lastStatus
  payload into _collective_status, and dispatches the per-thing signal
  so dependent entities refresh. Mirrors the per-thing portion of
  async_refresh_collective_metadata. Logs and returns on HydrosAPIError.

Also bumps version to 0.3.2 and adds a CHANGELOG entry under the new
JLay2026 community-fork series. (HACS domain remains `hydros` for this
release; rename to `hydros_ha_plus` ships in v0.4.0.)

Refs: code review finding H1, 2026-05-06.

Co-authored-by: Claude <noreply@anthropic.com>
GitHub Issues are disabled by default on this fork. This file captures
the 6 hardening items as a tracked checklist in-repo, ready to convert
into GitHub Issues whenever Issues is enabled in Settings.

Tied to Workshop dashboard M2 milestone — see
https://github.com/JLay2026/HA-dashboard/blob/main/docs/WORKSHOP-DASHBOARD-PLAN.md
Decision context:
https://github.com/JLay2026/HA-dashboard/blob/main/docs/ADR-001-hobby-dashboards.md
…issues

Adds:
- CONTRIBUTING.md — branch naming (upstream/* vs fork-only/*), PAT setup,
  upstream PR authorship rule (must be JLay2026, never github-actions or
  digispark-bot)
- .github/workflows/upstream-sync.yml — daily fast-forward fork main from
  Bitf1ip/ha-hydros:main; opens sync PR on divergence
- .github/workflows/upstream-pr.yml — on merge of any upstream/* branch,
  re-push the branch (in case auto-delete-on-merge is on) and open a
  cross-repo PR to Bitf1ip/ha-hydros, authored as JLay2026 via the
  UPSTREAM_PR_TOKEN secret (fine-grained PAT)
- HARDENING_ISSUES.md — now links to issues #2-#8 (filed today) and
  references CONTRIBUTING.md for the workflow

⚠️ Operator setup required before upstream-pr.yml can run:
1. Create a fine-grained PAT on github.com/JLay2026 scoped to Bitf1ip/ha-hydros
   with pull_requests:write + contents:read
2. Add as repo secret UPSTREAM_PR_TOKEN in JLay2026/ha-hydros settings
3. Configure repo to NOT auto-delete branches on merge (Settings → General),
   OR rely on the workflow's re-push fallback
Brings the fork to feature parity with upstream v0.3.4 by adopting:

- 0-10v variable-pump support (upstream v0.3.2 — commits 3dd2d43, 46ca6fd):
  - Skimmer outputs on variable pumps (type: o10vPump, family: vPump)
  - Normalizes valueState as a percentage (/100), e.g. 4500 -> 45.0%
  - Prevents variable-pump valueState being interpreted as binary on/off
  - New helper: is_variable_pump_output() in types.py

- XP8 Total Power monitoring (upstream v0.3.4 — commits 8e40ba7, 1c88d23, 57193e6):
  - New CollectiveXP8Power sensor sourced from MQTT health.*.acPower.powerI
  - Scaled by the existing powerI factor (/10) to report watts
  - Includes follow-up power-factor scaling fixes
  - New builder: build_collective_xp8_power_description in entity_builders.py

- hassfest CI workflow (upstream — commit 715ae0f):
  - Validates HA integration metadata against Home Assistant core schemas
  - Runs on push, pull_request, and daily schedule
  - Does not conflict with fork's existing upstream-pr.yml / upstream-sync.yml

Skipped from upstream:
- b4b5641 (HACS and Bugfix = upstream's port of fork's H1 fix; functionally
  identical to what's already in hydros_hub.py, so no merge needed)
- 38c963e (manifest reorder + version bump; fork's manifest customizations
  for codeowners/docs/issue_tracker would be clobbered)
- f3efca8 (dashboard.png refresh; cosmetic only)
- 337f37a, 0a6105f (HACS Action iterations superseded by 715ae0f hassfest)

Fork manifest customizations preserved (codeowners @JLay2026, docs URL,
issue_tracker pointing at the fork). Version bumped 0.3.2 -> 0.3.4 to
match upstream feature parity.

NOTE: This was applied via direct file checkout from upstream/main rather
than git cherry-pick because fork's main has no common ancestor with
upstream (parent SHA 3e174be unreachable; d4dd7a9 is a single squash
commit by digispark-bot). A follow-up sync/restore-upstream-lineage PR
should address that structural issue before subsequent syncs.

Refs: docs/upstream-evaluation-2026-05-27.md (full per-commit evaluation)

Co-authored-by: Claude <noreply@anthropic.com>
Two fixes to address the hassfest validation failure on main from PR #9:

1. Manifest key order — hassfest requires `domain` and `name` first, then
   the remaining keys in alphabetical order. The fork's manifest had
   `version`, `documentation`, `issue_tracker` ahead of `codeowners`,
   which violated the expected ordering and caused:

     [MANIFEST] Manifest keys are not sorted correctly: domain, name,
     then alphabetical order

   Reordered to: domain, name, codeowners, config_flow, dependencies,
   documentation, iot_class, issue_tracker, requirements, version.
   No content changes — same fields, same values, fork customizations
   preserved.

2. actions/checkout — upstream's workflow pins @V3, which uses
   Node.js 20 (deprecated; will be removed Sept 16, 2026). Bumped to
   @v4 so the action runs on Node 22 today and won't break when
   GitHub forces Node 24 by default on June 2, 2026.

No integration code changes. No version bump (CI-only).

Refs: PR #9 hassfest failure
https://github.com/JLay2026/ha-hydros/actions/runs/26542158529

Co-authored-by: Claude <noreply@anthropic.com>
actions/checkout@v3 runs on Node.js 20, which is deprecated:
- 2026-06-02: GitHub forces Node 24 by default on v3 actions
- 2026-09-16: Node 20 is removed entirely from the runner

@v4 runs on Node 22 today (and will move to Node 24 cleanly).
No other behavior changes.

Co-authored-by: Claude <noreply@anthropic.com>
Implements Issue #6 from the v0.4.0 hardening set: the debug sample
sensor now sanitizes its output by default so it's safe to include in
HA recorder / InfluxDB / Prometheus exports.

Changes
-------

custom_components/hydros/sanitizer.py (new): Recursive payload
walker that replaces sensitive content with [REDACTED] placeholders
while preserving JSON structure for debugging:

* Dict-key redaction (case-insensitive substring match) on:
  password, passwd, secret, credential, token, apikey, api_key,
  api-key, signature, serial, accountId, account_id, userid,
  user_id, useremail, user_email, email, cookie, session, x-amz-,
  aws_access, aws_secret, licensekey, license_key, productkey,
  product_key.
* Allowlist (NEVER_REDACT_KEYS) keeps thingId / thingName /
  thingType through so the debug output stays useful — these are
  already public via HA entity IDs.
* Value-shape redaction: email addresses ([REDACTED_EMAIL]), JWT
  tokens ([REDACTED_TOKEN]), MQTT URIs with embedded credentials
  ([REDACTED_MQTT_CREDS] preserves the host), and presigned URLs
  ([REDACTED_PRESIGNED_URL] strips only the query string so path
  remains visible).

custom_components/hydros/const.py: Adds CONF_UNSANITIZED_DEBUG +
DEFAULT_UNSANITIZED_DEBUG = False.

custom_components/hydros/hydros_hub.py: Adds
HydrosHub.unsanitized_debug_enabled property reading the new option
from entry.options with the False default.

custom_components/hydros/sensor.py: Imports sanitize_payload. The
CollectiveDebug branch in extra_state_attributes now calls
sanitize_payload() on the config and mqtt sub-payloads before
json.dumps unless the option is enabled. State attributes include a
new sanitized: true/false field so downstream consumers know what
they're looking at.

custom_components/hydros/config_flow.py: Extends HydrosOptionsFlow
with the Unsanitized debug output checkbox. Default OFF. The flow
carries the user's selection across the existing remote-control
disclaimer branch so neither option overwrites the other.

custom_components/hydros/README.md: Replaces the bare "stored in
memory" note with the full sanitizer description, lists the redacted
key categories, documents the opt-in toggle with a loud warning, and
includes a recommended recorder.exclude snippet.

CHANGELOG.md: Adds Unreleased — v0.4.0 (in progress) section listing
the three #6 deliverables (sanitizer, options-flow toggle, test suite).

tests/test_sanitizer.py (new): 9-case unittest covering happy path,
structure preservation, thingId allowlist, and each redaction
category (keyed, email, JWT, MQTT creds, presigned URL). Includes
the explicit acceptance-criterion test from Issue #6 ("no email/
token-shaped strings survive"). Loads sanitizer.py directly via
importlib.util so the test runs without a Home Assistant install.
All 9 tests pass with `python -m unittest tests.test_sanitizer`.

Acceptance criteria (Issue #6)
------------------------------

[x] Debug sample emits sanitized payloads by default — verified via
    keyed-redaction + value-shape tests.
[x] Sanitization replaces sensitive fields with [REDACTED]
    placeholders preserving structure — verified via
    test_structure_preserved.
[x] Options toggle (default OFF) for unsanitized output, with loud
    README warning — wired through config_flow + README.
[x] Test asserts no email/token-shaped strings survive — covered by
    test_no_email_or_token_shaped_strings_survive.

Non-goals (per issue): cryptographic redaction, persisting sanitized
samples (still in-memory only).

No version bump — v0.4.0 ships after all of #3, #4, #5, #6 land.

Refs: #2, #6
Co-authored-by: Claude <noreply@anthropic.com>
#6)

Companion commit to the sanitizer module landed in 6c56460. Adds:

- hydros_hub.py: unsanitized_debug_enabled property (reads
  CONF_UNSANITIZED_DEBUG from entry.options, default False).
- sensor.py: sanitize_payload import + sanitized CollectiveDebug
  attributes with the new sanitized: true/false marker.
- config_flow.py: extends HydrosOptionsFlow with the
  Unsanitized debug output checkbox; carries the selection across
  the remote-control disclaimer branch.
- custom_components/hydros/README.md: documents sanitizer behavior,
  the opt-in toggle, and a recommended recorder.exclude snippet.
- CHANGELOG.md: Unreleased — v0.4.0 (in progress) section.
- tests/test_sanitizer.py: 9 standalone unittest cases. Loads
  sanitizer.py directly via importlib.util so it runs without HA
  installed. All pass with `python -m unittest tests.test_sanitizer`.

Co-authored-by: Claude <noreply@anthropic.com>
…itizer into sensor (Issue #6)

Final commit in the #6 series — modifies the two large files
(hydros_hub.py and sensor.py) split out of the prior commit to keep
each push call manageable.

- hydros_hub.py: imports CONF_UNSANITIZED_DEBUG + DEFAULT_UNSANITIZED_DEBUG
  from const; adds unsanitized_debug_enabled property reading from
  entry.options with the False default.
- sensor.py: imports sanitize_payload; CollectiveDebug branch in
  extra_state_attributes now calls sanitize_payload() on config/mqtt
  unless the option is enabled; state attributes include the new
  sanitized: true/false marker.

Co-authored-by: Claude <noreply@anthropic.com>
…appers (Issue #4)

Closes #4. Reproducible grep-based audit of the integration's credential
and secret handling, plus the two defensive fixes the audit recommended.
Direct logging in the integration is clean; the changes below are
defense-in-depth against credential leaks in third-party pyhydros
exception messages.

Changes (first half — small files)
----------------------------------

custom_components/hydros/sanitizer.py: Adds public sanitize_string()
which re-exports the existing _redact_string_value helper for wrapping
untrusted exception messages. Returns non-strings unchanged so log call
sites can wrap any err object's repr without type-checking.

custom_components/hydros/config_flow.py:
- AUTH-LOG-2: HydrosAPIError branch in async_step_user now logs
  sanitize_string(str(err)) instead of raw err.
- EXC-LOG-1 (higher risk): the unknown-exception branch replaces
  _LOGGER.exception(...) with _LOGGER.error("... type=%s ...") plus
  a _LOGGER.debug("...", exc_info=True). The traceback only emits
  when the user explicitly enables DEBUG logging for
  custom_components.hydros — important here because `password` is a
  LOCAL variable in the active stack frame, so Sentry/Datadog/some
  HA log shippers that capture frame locals would otherwise leak it.

tests/test_sanitizer.py: 4 new tests covering sanitize_string()
against realistic pyhydros-style error messages (email, JWT, presigned
URL, safe-message passthrough, non-string passthrough). All 14 tests
in the file pass with `python -m unittest tests.test_sanitizer`.

docs/SECURITY.md (new): The full audit, including the reproducible
grep patterns, findings table, scope notes, and re-audit guidance for
future contributors.

CHANGELOG.md: Appends 4 [#4] items to the existing
Unreleased — v0.4.0 (in progress) section, plus a forward reference
to backlog issue #14 (third-party pyhydros library audit).

The hydros_hub.py changes (AUTH-LOG-1, AUTH-LOG-3, EXC-LOG-2) are in
a follow-up commit on this same branch, split out to keep this push
under the MCP per-call size limit.

Refs: #2, #4 · Follow-up: #14
Co-authored-by: Claude <noreply@anthropic.com>
)

Companion commit to fdfad26 — applies the AUTH-LOG-1, AUTH-LOG-3, and
EXC-LOG-2 changes to custom_components/hydros/hydros_hub.py. Split into
its own commit because of MCP push size limits; functionally one atomic
change with the prior commit.

- Adds `from .sanitizer import sanitize_string` to the imports block.
- AUTH-LOG-1 (async_setup HydrosAuthError/HydrosAPIError branch):
  wraps `str(err)` with sanitize_string before %s.
- AUTH-LOG-3 (async_refresh_dosing_logs post-re-auth retry_err):
  wraps `str(retry_err)` with sanitize_string before %s.
- EXC-LOG-2 (async_setup unknown-exception branch): replaces
  _LOGGER.exception(...) with _LOGGER.error("... type=%s ...") plus
  _LOGGER.debug("...", exc_info=True). Lower risk than EXC-LOG-1
  (password is self._password not a local) but the pattern is kept
  uniform across both call sites.

Refs: #2, #4
Co-authored-by: Claude <noreply@anthropic.com>
…nsts, tests

Closes #5. First half of the rate-limit / backoff hardening — small
files only. The hydros_hub.py wiring lands in a companion commit on
this branch.

What's in this commit
---------------------

custom_components/hydros/const.py: New rate-limit / backoff constants
  - DOSING_POLL_INTERVAL_SECONDS = 300 (5 min, matches existing timer)
  - ENTITY_REFRESH_INTERVAL_SECONDS = 1800 (30 min, matches existing timers)
  - MQTT_WATCHDOG_MAX_SECONDS = 60
  - BACKOFF_EXPONENT_CAP = 5 (multiplier sequence: 1x, 2x, 4x, 5x, 5x...)
  - RATE_LIMITED_BACKOFF_MULTIPLIER = 10 (for 429 without Retry-After)

docs/RATE_LIMITS.md (new): Polling cadence + backoff envelope reference
  with tables for HTTP polls, 429 handling, MQTT watchdog. Includes
  expected request volumes per scenario and a manual `tc qdisc`
  flakiness simulation for operator-side verification.

custom_components/hydros/README.md: New Polling cadence and backoff
  section under Capabilities, with a link to the full docs/RATE_LIMITS.md.

tests/test_backoff.py (new): 15 unit tests covering:
  - _backoff_seconds math (progression 1x, 2x, 4x, 5x cap; zero/negative)
  - MQTT watchdog progression (5s -> 10s -> 20s -> 40s -> 60s cap)
  - _parse_retry_after for None/missing/integer/HTTP-date/garbage inputs
  - Backoff state-machine shape (skip window, cleared on success,
    exponential progression)

  Standalone-runnable via `python -m unittest tests.test_backoff` — no
  Home Assistant install required (same loader pattern as
  test_sanitizer.py).

CHANGELOG.md: Appends 7 [#5] items under the existing
  Unreleased — v0.4.0 (in progress) section.

The hydros_hub.py wiring (Fix 1 = 429+Retry-After, Fix 2 = dosing
backoff state, Fix 3 = collective-config backoff state, Fix 4 = MQTT
watchdog exponential backoff) is in the follow-up commit on this
branch, split out to keep this push under the MCP per-call size limit.

Refs: #2, #5
Co-authored-by: Claude <noreply@anthropic.com>
)

Companion commit to f4e2f3d — applies Fix 1+2+3+4 to hydros_hub.py.
Split from the prior commit to stay under the MCP per-call size limit;
functionally one atomic change.

Imports + helpers
-----------------

- New module-level helpers: _parse_retry_after(response) handles both
  integer-seconds and HTTP-date forms of the Retry-After header;
  _backoff_seconds(n, base) computes the exponential schedule capped
  at BACKOFF_EXPONENT_CAP.
- New imports from const: BACKOFF_EXPONENT_CAP, DOSING_POLL_INTERVAL_SECONDS,
  ENTITY_REFRESH_INTERVAL_SECONDS, MQTT_WATCHDOG_MAX_SECONDS,
  RATE_LIMITED_BACKOFF_MULTIPLIER.

HydrosHub state
---------------

New per-key dicts initialized in __init__ and cleared in async_unload:
  - _dosing_backoff_until: dict[(thing_id, output_name), datetime]
  - _dosing_consecutive_failures: dict[(thing_id, output_name), int]
  - _collective_config_backoff_until: dict[thing_id, datetime]
  - _collective_config_consecutive_failures: dict[thing_id, int]
  - _mqtt_retry_failures: dict[thing_id, int]

Fix 1 + Fix 2 (async_refresh_dosing_logs)
-----------------------------------------

- Skip the poll if datetime.now() is inside _dosing_backoff_until[key].
- New 429 branch BEFORE the 401/403 branch: honor Retry-After header
  if present, else back off by RATE_LIMITED_BACKOFF_MULTIPLIER × normal
  interval. Bumps failure count straight to the cap.
- 401/403, generic HTTPError, HydrosAPIError, and bare-Exception paths
  all now route through _record_failure_and_backoff() which bumps the
  counter and computes the exponential wait.
- Success path clears both dicts.

Fix 3 (async_get_collective_config)
-----------------------------------

- Skip the fetch if datetime.now() is inside
  _collective_config_backoff_until[thing_id]; raise HydrosAPIError so
  callers handle the gap (next periodic refresh tries again once the
  window closes).
- On empty/failed config: bump failures, compute backoff at
  ENTITY_REFRESH_INTERVAL_SECONDS base, arm the window, raise.
- Success path clears both dicts.

Fix 4 (MQTT subscription watchdog)
----------------------------------

- _ensure_watchdog now reads _mqtt_retry_failures[thing_id] and computes
  delay = min(DEFAULT_WATCHDOG_INACTIVITY * 2^n, MQTT_WATCHDOG_MAX_SECONDS).
  Sequence: 5s -> 10s -> 20s -> 40s -> 60s (cap).
- _async_retry_subscription bumps the counter on HydrosMQTTError and
  emits log lines that include the failure count. Reset on success
  via the new `else` branch.
- _handle_status_update clears _mqtt_retry_failures[thing_id] on any
  real MQTT message — one successful payload returns the watchdog to
  base cadence immediately.

Refs: #2, #5
Co-authored-by: Claude <noreply@anthropic.com>
…elect, options, docs, tests

Closes #3. First commit of three for the cloud-outage resilience PR — small files only. The hydros_hub.py methods + sensor.py/binary_sensor.py rewrites land in companion commits on this branch.

Changes
-------

const.py: 4 new constants for the stale envelope:
  - CONF_CLOUD_STALE_RETENTION_SECONDS = "cloud_stale_retention_seconds"
  - DEFAULT_CLOUD_STALE_RETENTION_SECONDS = 600    # 10 min default
  - MIN_CLOUD_STALE_RETENTION_SECONDS = 30
  - MAX_CLOUD_STALE_RETENTION_SECONDS = 3600

select.py: HydrosModeSelect.available rewritten to gate on cloud
  state == "fresh" only. The select entity is write-side — serving a
  mode picker that silently fails during an outage is misleading.
  Sensors/binary_sensors get the more permissive fresh+stale behavior
  in the companion commit.

config_flow.py: HydrosOptionsFlow extended with a new
  Cloud stale retention seconds field (vol.All(int, vol.Range(min, max))).
  Selection is carried across the existing remote-control disclaimer
  branch so neither option overwrites the other.

tests/test_cloud_stale.py: 10 new standalone-runnable tests:
  - no_status_yet -> unavailable
  - recent_message -> fresh
  - just_inside_fresh_threshold (29s)
  - stale_window -> stale (60s with default 600s retention)
  - beyond_retention -> unavailable (3600s with default)
  - retention_window_configurable (custom 20s window)
  - retention_window_clamps_to_min_max
  - global_aggregate_off_when_all_fresh
  - global_aggregate_on_when_any_stale
  - global_aggregate_unavailable_when_any_unavailable
  - transition_logging_once_per_change (4 transitions, exactly 4 WARN
    log lines — not per-tick)

  Uses the same importlib.util loader pattern as test_sanitizer.py and
  test_backoff.py; runs without a Home Assistant install. All 40 tests
  in the project pass (`python -m unittest tests.test_sanitizer
  tests.test_backoff tests.test_cloud_stale`).

docs/RATE_LIMITS.md: new "Cloud-stale envelope (Issue #3)" section
  showing how the stale envelope composes with the rate-limit / backoff
  envelope from #5. Includes the per-state behavior table for sensor /
  binary_sensor / select and an updated tc qdisc reproduction recipe.

README.md: new Cloud-outage resilience section explaining the
  three-state model, the new binary_sensor.hydros_cloud_stale
  aggregator, the configurable retention window, the special handling
  for select.HydrosModeSelect, and an example alert template.

CHANGELOG.md: 6 [#3] items appended to the existing Unreleased — v0.4.0
  (in progress) section.

Refs: #2, #3
Co-authored-by: Claude <noreply@anthropic.com>
…ssue #3)

Adds the per-thing cloud-state classifier (fresh/stale/unavailable) to
HydrosHub and exposes a configurable retention window in the options
flow.

custom_components/hydros/hydros_hub.py:
  - new `_FRESH_THRESHOLD_SECONDS = 30` module constant
  - new `cloud_stale_retention_seconds` property (reads options,
    clamps to [MIN, MAX])
  - new `cloud_state_for_thing()` classifier
  - new `cloud_state_per_thing()` map for the aggregator binary_sensor
  - new `cloud_stale_global_state()` aggregator (off/on/unavailable)
  - new `_maybe_log_transition()` — emits one WARNING per state change
    (no per-tick log flooding)
  - new `_cloud_state_last_reported` instance dict; cleared on unload

custom_components/hydros/config_flow.py:
  - options flow gains `cloud_stale_retention_seconds` (vol.Range
    clamp to [MIN_CLOUD_STALE_RETENTION_SECONDS,
    MAX_CLOUD_STALE_RETENTION_SECONDS])
  - all three create_entry data dicts persist the value
  - retention preserved across the disclaimer branch

Co-authored-by: Claude (Anthropic Cowork) <noreply@anthropic.com>
…(Issue #3)

custom_components/hydros/sensor.py:
  - HydrosSensor.available rewritten: returns False only when
    cloud_state_for_thing(thing_id) == "unavailable", so per-thing
    sensors stay populated through the operator-configured stale
    window instead of flipping on any WAN flicker
  - extra_state_attributes adds `stale: true` when the cached value
    is being served, so dashboard templates can surface a warning
    badge instead of disappearing the entity
  - Collective-section semantics (last_health_state, debug, alerts,
    XP8 power aggregator) are unchanged

Co-authored-by: Claude (Anthropic Cowork) <noreply@anthropic.com>
custom_components/hydros/binary_sensor.py:
  - HydrosBinarySensor.available rewritten to match sensor.py:
    returns False only when cloud_state == "unavailable"
  - New HydrosCloudStaleBinarySensor aggregator registered alongside
    per-thing entities (one per config entry). _attr_should_poll=True,
    device_class=PROBLEM, entity_category=DIAGNOSTIC
    * is_on returns hub.cloud_stale_global_state() == "on"
    * extra_state_attributes exposes per_thing_state, counts,
      retention_seconds for dashboard/automation use

docs/RATE_LIMITS.md:
  - New "Cloud-stale envelope (Issue #3)" section explains how the
    stale-tolerance state machine composes with the rate-limit /
    backoff envelope from #5; includes the per-state availability
    table and the manual tc qdisc reproduction recipe

custom_components/hydros/README.md:
  - New "Cloud-outage resilience (v0.4.0+)" section with the
    fresh/stale/unavailable model, aggregator state table, and
    example alert that fires only on actual unavailability
    (not transient stale)

CHANGELOG.md:
  - Six [#3] entries under Unreleased — v0.4.0 covering state machine,
    aggregator, retention config, hub API surface, tests, and docs

Co-authored-by: Claude (Anthropic Cowork) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants