${!a && !this.previewFailed ? h`
@@ -5034,7 +5069,7 @@ let E = class extends C {
` : l}
${this.ringAlertVisible ? h`
-
+
${s(this.hass, "ring.alert")}
` : l}
@@ -5043,17 +5078,17 @@ let E = class extends C {
`;
}
previewEntityId() {
- return Yi(
+ return tn(
this.hass,
this.config,
- ie(this.config),
+ ne(this.config),
this.latestObservedPreviewSource
);
}
observePreviewMedia() {
- const e = Gi(
+ const e = Xi(
this.hass?.states[this.config.recording_entity]
- ), t = he(
+ ), t = ue(
this.config.snapshot_entity ? this.hass?.states[this.config.snapshot_entity] : void 0
);
if (!this.previewMarkersInitialized) {
@@ -5070,20 +5105,20 @@ let E = class extends C {
);
e && (e.hass = this.hass, e.showInline({
config: this.config,
- mode: ie(this.config),
+ mode: ne(this.config),
start: this.config.dashboard_start,
- ringingUntil: this.ringAlertVisible ? this.lastRingAlertAt + ae : void 0
+ ringingUntil: this.ringAlertVisible ? this.lastRingAlertAt + de : void 0
}), e.setInlineVisible(this.previewVisible));
}
scheduleViewerRestore() {
if (this.restoreViewerTimer !== void 0 || !this.config) return;
- const e = Ce();
- Ee(e, this.config) && (this.restoreViewerTimer = window.setTimeout(() => {
+ const e = Ee();
+ xe(e, this.config) && (this.restoreViewerTimer = window.setTimeout(() => {
if (this.restoreViewerTimer = void 0, !this.isConnected || !this.hass || !this.config) return;
- const t = Ce();
- if (!Ee(t, this.config)) return;
+ const t = Ee();
+ if (!xe(t, this.config)) return;
const i = this.renderRoot.querySelector(".preview") ?? void 0;
- this.config.dashboard_behavior === "interactive" && (this.renderRoot.querySelector("ring-view-dialog[inline]")?.stopInline(), this.inlinePausedForViewer = !0), ye(i ?? this, {
+ this.config.dashboard_behavior === "interactive" && (this.renderRoot.querySelector("ring-view-dialog[inline]")?.stopInline(), this.inlinePausedForViewer = !0), ke(i ?? this, {
config: this.config,
mode: t.mode,
opener: i,
@@ -5094,15 +5129,15 @@ let E = class extends C {
}
detectDoorbellEvent(e) {
const t = this.config?.doorbell_entity;
- if (!t || !e || !this.hass) return;
- const i = e.states[t], n = this.hass.states[t];
- if (!Nt(t, i?.state, n)) return;
+ if (!t || !this.hass) return;
+ const i = e?.states[t], n = this.hass.states[t];
+ if (!jt(t, i, n)) return;
const r = Date.now();
- r - this.lastRingAlertAt < 5e3 || (this.lastRingAlertAt = r, this.updateRingAlert());
+ this.lastRingAlertAt = r, this.updateRingAlert();
}
updateRingAlert() {
this.clearRingAlertTimer();
- const e = this.lastRingAlertAt + ae - Date.now();
+ const e = this.lastRingAlertAt + de - Date.now();
this.ringAlertVisible = this.lastRingAlertAt > 0 && e > 0, this.ringAlertVisible && this.isConnected && (this.ringAlertTimer = window.setTimeout(() => this.updateRingAlert(), e));
}
clearRingAlertTimer() {
@@ -5136,7 +5171,7 @@ let E = class extends C {
if (e) {
this.refreshPreview(!0), this.previewRefreshTimer = window.setInterval(() => {
this.refreshPreview(!0);
- }, ur);
+ }, mr);
return;
}
this.stopPreviewRefreshTimer(), this.previewRequestId += 1;
@@ -5149,13 +5184,13 @@ let E = class extends C {
if (this.isInCardPicker() || !this.previewVisible || !this.hass || !this.config) return;
const t = this.renderRoot.querySelector(".preview");
if (!t) return;
- const i = Math.max(1, window.devicePixelRatio || 1), n = t.clientWidth || t.getBoundingClientRect().width, r = n > 0 ? n : pr, o = t.clientHeight || t.getBoundingClientRect().height, a = Ut(this.config.aspect_ratio) ?? vr, d = o > 0 ? o : r / a, c = Math.ceil(r * i), u = Math.ceil(d * i);
- if (!e && this.lastPreviewSize && Math.abs(c - this.lastPreviewSize.width) < gt && Math.abs(u - this.lastPreviewSize.height) < gt)
+ const i = Math.max(1, window.devicePixelRatio || 1), n = t.clientWidth || t.getBoundingClientRect().width, r = n > 0 ? n : _r, o = t.clientHeight || t.getBoundingClientRect().height, a = Ft(this.config.aspect_ratio) ?? br, d = o > 0 ? o : r / a, c = Math.ceil(r * i), u = Math.ceil(d * i);
+ if (!e && this.lastPreviewSize && Math.abs(c - this.lastPreviewSize.width) < wt && Math.abs(u - this.lastPreviewSize.height) < wt)
return;
this.lastPreviewSize = { width: c, height: u };
const p = this.previewEntityId(), v = this.hass, y = ++this.previewRequestId;
try {
- const A = await En(v, p, c, u);
+ const A = await Mn(v, p, c, u);
if (y !== this.previewRequestId || !this.isConnected || !this.previewVisible || this.previewEntityId() !== p)
return;
this.lastPoster = A, this.previewFailed = !1;
@@ -5177,35 +5212,35 @@ let E = class extends C {
return !1;
}
};
-E.styles = Ln;
-I([
+E.styles = Vn;
+V([
_({ attribute: !1 })
], E.prototype, "hass", 2);
-I([
+V([
_({ reflect: !0 })
], E.prototype, "layout", 2);
-I([
+V([
_({ type: Boolean })
], E.prototype, "preview", 2);
-I([
+V([
g()
], E.prototype, "config", 2);
-I([
+V([
g()
], E.prototype, "previewFailed", 2);
-I([
+V([
g()
], E.prototype, "lastPoster", 2);
-I([
+V([
g()
], E.prototype, "ringAlertVisible", 2);
-E = I([
- W(Re)
+E = V([
+ j($e)
], E);
window.customCards = window.customCards || [];
-window.customCards.some((e) => e.type === Re) || window.customCards.push({
- type: Re,
- name: cn,
+window.customCards.some((e) => e.type === $e) || window.customCards.push({
+ type: $e,
+ name: pn,
description: s(void 0, "card.description"),
preview: !0,
getEntitySuggestion: (e, t) => {
@@ -5215,19 +5250,19 @@ window.customCards.some((e) => e.type === Re) || window.customCards.push({
);
return i ? {
config: {
- type: ze,
+ type: Ne,
recording_entity: t,
live_entity: i
}
} : null;
}
});
-var fr = Object.defineProperty, mr = Object.getOwnPropertyDescriptor, te = (e, t, i, n) => {
- for (var r = n > 1 ? void 0 : n ? mr(t, i) : t, o = e.length - 1, a; o >= 0; o--)
+var yr = Object.defineProperty, kr = Object.getOwnPropertyDescriptor, ie = (e, t, i, n) => {
+ for (var r = n > 1 ? void 0 : n ? kr(t, i) : t, o = e.length - 1, a; o >= 0; o--)
(a = e[o]) && (r = (n ? a(t, i, r) : a(r)) || r);
- return n && r && fr(t, i, r), r;
+ return n && r && yr(t, i, r), r;
};
-function _r(e, t) {
+function Ar(e, t) {
const i = [
{
name: "dashboard_behavior",
@@ -5411,14 +5446,14 @@ function _r(e, t) {
name: "dashboard_preview",
type: "expandable",
flatten: !0,
- iconPath: Bt,
+ iconPath: Zt,
schema: i
},
{
name: "viewer_behavior",
type: "expandable",
flatten: !0,
- iconPath: Yt,
+ iconPath: ti,
schema: [
{
name: "default_mode",
@@ -5445,7 +5480,7 @@ function _r(e, t) {
name: "snapshots",
type: "expandable",
flatten: !0,
- iconPath: mt,
+ iconPath: kt,
schema: [
{
name: "snapshot_entity",
@@ -5464,7 +5499,7 @@ function _r(e, t) {
name: "doorbell_features",
type: "expandable",
flatten: !0,
- iconPath: jt,
+ iconPath: Yt,
schema: [
{
name: "doorbell_entity",
@@ -5483,14 +5518,14 @@ function _r(e, t) {
name: "door_access",
type: "expandable",
flatten: !0,
- iconPath: _t,
+ iconPath: At,
schema: n
},
{
name: "card_appearance",
type: "expandable",
flatten: !0,
- iconPath: Qt,
+ iconPath: ei,
schema: [
{ name: "name", selector: { text: {} } },
{ name: "show_name", selector: { boolean: {} } },
@@ -5561,7 +5596,7 @@ function _r(e, t) {
}
];
}
-const br = {
+const Sr = {
recording_entity: "editor.recording_entity",
live_entity: "editor.live_entity",
snapshot_entity: "editor.snapshot_entity",
@@ -5595,7 +5630,7 @@ const br = {
preview_fallback: "editor.preview_fallback",
aspect_ratio: "editor.aspect_ratio",
fit_mode: "editor.fit_mode"
-}, wr = {
+}, Tr = {
recording_entity: "editor.helper_recording_entity",
live_entity: "editor.helper_live_entity",
dashboard_behavior: "editor.helper_dashboard_behavior",
@@ -5619,10 +5654,10 @@ const br = {
snapshot_entity: "editor.helper_snapshot_entity",
preview_fallback: "editor.helper_preview_fallback"
};
-let D = class extends C {
+let I = class extends C {
constructor() {
super(...arguments), this.nativeChecked = !1, this.nativeAvailable = !1, this.computeLabel = (e) => {
- const t = br[e.name];
+ const t = Sr[e.name];
if (!t) return;
const i = s(this.hass, t);
if (e.required || ![
@@ -5633,7 +5668,7 @@ let D = class extends C {
].includes(
e.name
)) return i;
- const n = xt(
+ const n = It(
this.hass,
"ui.panel.lovelace.editor.card.config.optional",
"common.optional"
@@ -5641,14 +5676,14 @@ let D = class extends C {
return `${i} (${n})`;
}, this.computeHelper = (e) => {
if (e.name === "snapshot_directory" && this.config) {
- const i = en(this.config.snapshot_directory);
+ const i = on(this.config.snapshot_directory);
return i === "public" ? s(this.hass, "editor.helper_snapshot_directory_public") : i === "custom" ? s(this.hass, "editor.helper_snapshot_directory_custom") : void 0;
}
- const t = wr[e.name];
+ const t = Tr[e.name];
return t ? s(this.hass, t) : void 0;
}, this.valueChanged = (e) => {
if (!this.config) return;
- const t = $e({
+ const t = Ce({
...this.config,
...e.detail.value
});
@@ -5674,16 +5709,16 @@ let D = class extends C {
};
}
setConfig(e) {
- this.config = $e(e);
+ this.config = Ce(e);
}
connectedCallback() {
- super.connectedCallback(), qt().then((e) => {
+ super.connectedCallback(), Kt().then((e) => {
this.isConnected && (this.nativeAvailable = e, this.nativeChecked = !0);
});
}
render() {
if (!this.hass || !this.config) return l;
- const e = Wi(this.hass, this.config).filter(
+ const e = Qi(this.hass, this.config).filter(
(t) => t.kind !== "compatibility" || this.nativeChecked && !this.nativeAvailable
);
return h`
@@ -5703,7 +5738,7 @@ let D = class extends C {
Ring cloud event works (official app receives it)
+ -> Google push registration for HA client FAILS: PHONE_REGISTRATION_ERROR
+ -> Ring push-token subscription never reached
+ -> FCM/MCS socket login never reached
+ -> python-ring-doorbell callback never called
+ -> HA event.front_door_ding remains unavailable / unchanged
+ -> Ring View automation and card have nothing to react to
+```
+
+The Ring project explicitly uses Firebase Cloud Messaging for realtime Ding and motion delivery; this is separate from its ordinary REST polling path. [Ring notification architecture and troubleshooting](https://github.com/dgreif/ring/wiki/Notification-Troubleshooting)
+
+## Exact versions and source examined
+
+The analysis used immutable releases corresponding to the live Home Assistant installation:
+
+| Component | Version / commit | Relevant source |
+|---|---|---|
+| Home Assistant Core | `2026.9.2`, `33c3e0cca60e73a8c4970ee677d75b8bc6464cdf` | [Ring manifest](https://github.com/home-assistant/core/blob/2026.9.2/homeassistant/components/ring/manifest.json), [coordinator](https://github.com/home-assistant/core/blob/2026.9.2/homeassistant/components/ring/coordinator.py), [config flow](https://github.com/home-assistant/core/blob/2026.9.2/homeassistant/components/ring/config_flow.py), [event entity](https://github.com/home-assistant/core/blob/2026.9.2/homeassistant/components/ring/event.py) |
+| `ring-doorbell` | `0.9.14`, `28fd01c47130350614d555b3b4e360aa9a9e4c71` | [event listener](https://github.com/python-ring-doorbell/python-ring-doorbell/blob/0.9.14/ring_doorbell/listen/eventlistener.py) |
+| `firebase-messaging` | `0.4.5`, `61231a66625ba9e16c7fb385071b27984b021290` | [registration](https://github.com/sdb9696/firebase-messaging/blob/0.4.5/firebase_messaging/fcmregister.py), [push client](https://github.com/sdb9696/firebase-messaging/blob/0.4.5/firebase_messaging/fcmpushclient.py) |
+
+Home Assistant 2026.9.2 pins `ring-doorbell==0.9.14` and `aiohttp==3.14.3`. The latest upstream branches were also checked on 2026-09-12. The important behavior is still present: current `firebase-messaging` increases GCM attempts from two to four but still repeats the same request; current `python-ring-doorbell` still uses the stale `started` boolean and immediate-return receiver start; current HA still has the one-shot listener start and data replacement in reauth/reconfigure. [Current firebase registration source](https://github.com/sdb9696/firebase-messaging/blob/main/firebase_messaging/fcmregister.py), [current Ring listener](https://github.com/python-ring-doorbell/python-ring-doorbell/blob/master/ring_doorbell/listen/eventlistener.py), [current HA Ring coordinator](https://github.com/home-assistant/core/blob/dev/homeassistant/components/ring/coordinator.py), [current HA Ring config flow](https://github.com/home-assistant/core/blob/dev/homeassistant/components/ring/config_flow.py)
+
+## Live-system evidence
+
+The account and integration were deliberately normalized before drawing a code conclusion:
+
+- The Ring integration was disabled while the old authorized-device backlog was removed.
+- Reconfiguration created a single fresh `ring-doorbell:HomeAssistant/ring-integration` authorized device.
+- Ring's normal API path loaded devices and cameras correctly after reconfiguration.
+- Listener startup attempts at approximately 14:30, 14:32, 14:33, and 14:40 CEST all failed at GCM registration.
+- Each installed-version attempt made two calls and returned `Error=PHONE_REGISTRATION_ERROR` both times.
+- `ring_doorbell.listen.eventlistener` logged “Starting event listener” but never “Started event listener.”
+- `event.front_door_ding` remained unavailable.
+- A real press produced an official Ring-app notification but no HA event or automation run.
+
+That sequence rules out stale Ring authorized sessions as the primary cause. It also shows the listener is failing before it can subscribe a push token to the Ring account.
+
+## Test methodology and results
+
+All experiments were isolated from the installed Ring View card and live Home Assistant configuration. The tests mock Google, Ring, and socket boundaries; they did not send synthetic registrations to Google's live service.
+
+### Baseline validation
+
+The first run used the newest unconstrained test dependencies. Both upstream suites failed because `aioresponses` could not construct an `aiohttp 3.14.3` response (`stream_writer` became a required argument). This was a test-tool compatibility problem, not treated as a product failure.
+
+The suites were rerun with their tagged lock-compatible versions (`aiohttp 3.11.18`, `aioresponses 0.7.8`, `pytest 8.3.5`, and related pinned packages):
+
+| Suite | Result |
+|---|---:|
+| `firebase-messaging` 0.4.5 upstream tests | 11 passed |
+| `ring-doorbell` 0.9.14 upstream tests | 40 passed |
+| Baseline total | **51 passed** |
+
+This proves the test harness and exact tagged sources work before adversarial cases are added. It also exposes a CI gap: Home Assistant runs `aiohttp 3.14.3`, while the library's historical test lock exercises an older `aiohttp`. A compatibility CI job using HA's current constraint would catch integration drift earlier.
+
+### Adversarial reproduction tests
+
+Twenty-six focused tests were run against unchanged source in a disposable Python 3.14.7 environment, matching HA's Python generation and `aiohttp 3.14.3`. The tests pass when they successfully reproduce the current hazardous behavior.
+
+| Area | Reproduced behavior |
+|---|---|
+| Google rejection | The same Android ID, security token, and GCM app ID are submitted twice for `PHONE_REGISTRATION_ERROR`; the code sleeps even after the final attempt. |
+| Error semantics | The provider error becomes a generic runtime error, preventing policy decisions upstream. |
+| Registration state | Failed registration does not persist the partial GCM identity, so a reload starts another fresh identity; a missing final FCM registration is wrapped in a truthy mapping and can be persisted as if valid. |
+| Resource cleanup | The registration helper is not closed when registration raises. Calling `stop()` before `start()` raises because its lock has not been created. |
+| Socket readiness | `FcmPushClient.start()` returns after scheduling tasks, before connection or MCS login. Initial connection exhaustion leaves the monitor alive and the state stuck at “starting connection.” |
+| Ring listener state | `RingEventListener.started` can be true while `FcmPushClient.is_started()` is false, and stays true after receiver death. |
+| Retry safety | Timeout/repeated start can duplicate callbacks and refresh tasks; one refresh task becomes unreachable and cannot be canceled by normal stop. |
+| Callback invariant | Stop/restart clears callbacks without resetting their counter, so the actual default callback is no longer ID 1 even though ID 1 alone is protected. |
+| Session maintenance | The 12-hour refresh algorithm first checks at 65,535 seconds (18:12:15), overshooting by 6:12:15, then exits permanently after that single refresh. |
+| Payload safety | A legacy Ding comparison uses `action.lower` instead of `action.lower()`; a missing current-payload `ding.id` raises through the Ring callback; a callback removing itself mutates the dictionary during iteration. |
+| HA config flow | Both reauth and reconfigure replace entry data and drop the stored `listen_token`. |
+| HA coordinator | A start exception aborts without callback, entity update, retry, or repair signal; a false return still installs a callback; unload skips partial listener cleanup; the removal callback is not idempotent. |
+
+Two public reports independently corroborate the highest-impact post-start failures:
+
+- The Ring listener can remain marked started after its FCM receiver shuts down, leaving HA event automations dead until reload. [python-ring-doorbell issue #526](https://github.com/python-ring-doorbell/python-ring-doorbell/issues/526)
+- New Ring payloads can omit `ding.id`; three such callback failures terminate the whole FCM receiver. [python-ring-doorbell issue #537](https://github.com/python-ring-doorbell/python-ring-doorbell/issues/537)
+
+### Candidate-fix validation
+
+A proof-of-concept patch was applied only to isolated copies of the three upstream projects. It was not installed into Home Assistant and did not alter this card project.
+
+The patch implemented typed terminal registration errors, guaranteed cleanup, safe pre-start stop, socket-readiness waiting, a lifecycle lock, dynamic health, idempotent start/stop, continuous session refresh, payload isolation, preserved HA credentials, coordinator health monitoring, bounded backoff, and start/stop race cancellation.
+
+| Validation | Result |
+|---|---:|
+| Original upstream suites after the candidate library changes | **51 passed** |
+| Candidate acceptance tests on Python 3.12 | **14 passed** |
+| Candidate acceptance tests on disposable Python 3.14.7 / HA-style environment | **21 passed** |
+| Rapid HA event-listener remove/re-add race | Passed; stale start and stop tasks were canceled, one final stop remained |
+| Static compile and diff whitespace checks | Passed |
+| Tagged Ruff checks for changed Firebase and Ring library files | Passed |
+
+These results validate the architecture, not production readiness. A real staging account must still confirm that the modified registration path interoperates with Google's live endpoint and that Ring accepts the resulting push token.
+
+## Detailed code findings
+
+### 1. `firebase-messaging`: terminal rejection is handled like a transient timeout
+
+`gcm_register()` creates one `gcm_app_id` before its retry loop and uses the same Android ID, security token, headers, and request body on every attempt. The installed release retries twice; current `main` retries four times. A deterministic `PHONE_REGISTRATION_ERROR` is therefore replayed without changing the property Google rejected. [0.4.5 registration loop](https://github.com/sdb9696/firebase-messaging/blob/0.4.5/firebase_messaging/fcmregister.py#L172-L231)
+
+The function returns `None`; the caller raises a generic message. The structured provider code is available only in logs. Home Assistant consequently cannot distinguish a timeout, rate limit, malformed request, invalid identity, or provider policy rejection.
+
+The safe behavior is:
+
+- Parse `Error=` into a typed exception with a stable `code` field.
+- Do not immediately repeat a known terminal identity error with the same identity.
+- Let callers use long, jittered backoff and expose a repair issue.
+- Keep ordinary bounded retries for network timeouts and 5xx errors.
+- Never include tokens or security credentials in normal logs.
+
+The proof-of-concept stops after one `PHONE_REGISTRATION_ERROR`, preserves its code, and lets HA begin at a 15-minute delay rather than creating a tight identity-registration storm.
+
+### 2. Registration is not transactional
+
+`fcm_install_and_register()` returns a truthy outer dictionary even when the inner registration result is `None`. That dictionary is then accepted and passed to the credentials callback. Later, `FcmPushClient` indexes `registration["token"]` and fails. [FCM installation wrapper](https://github.com/sdb9696/firebase-messaging/blob/0.4.5/firebase_messaging/fcmregister.py#L253-L262)
+
+Registration should be committed only if all required fields validate:
+
+- generated encryption keys;
+- GCM Android ID/security token/app ID/token;
+- Firebase installation token/FID/refresh token;
+- final FCM registration token.
+
+Partial state may be retained internally for a controlled retry, but it must not be published as complete credentials. If partial identities are persisted in the future, they need an explicit schema version and expiry so HA does not confuse them with usable credentials.
+
+### 3. FCM start does not mean ready
+
+`FcmPushClient.start()` schedules `_listen()` and `_do_monitor()` and returns immediately. Successful MCS login occurs later, when a `LoginResponse` changes `run_state` to `STARTED`. [push-client start](https://github.com/sdb9696/firebase-messaging/blob/0.4.5/firebase_messaging/fcmpushclient.py#L749-L791)
+
+`RingEventListener` wraps only the immediate scheduling call in its ten-second timeout, then sets its own `started=True`. The timeout therefore does not verify connection or login. A correct contract needs either:
+
+- `start()` to await readiness, or
+- a separate `wait_until_started()` / health event that Ring awaits inside its timeout.
+
+The candidate uses the second option to preserve API compatibility.
+
+### 4. Initial socket failure and later receiver death are invisible upstream
+
+If all initial MCS connection attempts fail, `_listen()` returns but leaves `do_listen=True`; the monitor remains alive in a state it does not handle. Later sequential connection or callback errors call `_terminate()`, but no health-change callback reaches `RingEventListener` or HA.
+
+`RingEventListener.started` is an unrelated boolean. HA's event entity reports availability solely from that boolean. [HA Ring event availability](https://github.com/home-assistant/core/blob/2026.9.2/homeassistant/components/ring/event.py#L111-L115)
+
+The minimum fix is a dynamic health predicate backed by the receiver's actual run state. A stronger API exposes a state enum and metadata:
+
+```text
+STOPPED -> REGISTERING -> SUBSCRIBING -> CONNECTING -> HEALTHY
+ | | |
+ +------------> BACKOFF <-------+
+```
+
+Useful diagnostics are `last_error_code`, `last_error_at`, `last_notification_at`, current retry delay, and whether the Ring token subscription succeeded. They must exclude secrets.
+
+### 5. Lifecycle operations are not idempotent or race-safe
+
+The listener lacks a lifecycle lock. A timeout occurs after the internal Ring callback is added but before `started=True`; retry adds it again. Repeated starts overwrite the only stored refresh-task reference, leaking the previous task. Stop clears callbacks but does not reset their counter, violating the hard-coded “ID 1 is default” rule.
+
+HA has a related race: removing the last entity schedules stop; adding an entity again schedules start, but neither operation cancels or serializes the other. The candidate serializes Ring start/stop and tracks/cancels stale HA start and stop tasks. It passed a controlled remove/re-add race test.
+
+### 6. Session refresh stops after one delayed run
+
+The refresh sleep is `1 + elapsed_since_refresh`, producing check times of 1, 3, 7, 15, … seconds. With a 43,200-second threshold, the first over-threshold check is at 65,535 seconds. The function then uses `break`, so it never refreshes a second time. [Ring session refresh loop](https://github.com/python-ring-doorbell/python-ring-doorbell/blob/0.9.14/ring_doorbell/listen/eventlistener.py#L204-L219)
+
+Use a monotonic deadline and a bounded health-check interval, refresh at the deadline, then continue the loop. The candidate refreshed exactly at the test deadlines twice and remained cancelable.
+
+### 7. One malformed message can kill every realtime entity
+
+The Ring parser performs unguarded JSON and key access. The Firebase client catches the exception as a callback failure and terminates after its sequential-error threshold. A payload variant from one camera can therefore disable Ding and motion events for every Ring device sharing the global receiver. The real-world missing-`ding.id` report demonstrates this exact blast radius. [Issue #537](https://github.com/python-ring-doorbell/python-ring-doorbell/issues/537)
+
+Parsing must be an isolation boundary:
+
+- Validate the envelope and required fields.
+- Use a deterministic synthetic event ID when safe, or skip only that message.
+- Log a sanitized schema summary, not the full token-bearing payload.
+- Catch failures per message and per callback.
+- Iterate over a snapshot of callbacks so a callback may unsubscribe itself.
+
+The listener must not terminate its transport because Ring added or omitted a payload field.
+
+### 8. Home Assistant discards durable push credentials
+
+On a successful initial FCM registration, HA intentionally writes `CONF_LISTEN_CREDENTIALS` (`listen_token`) into the config entry. [HA Ring setup](https://github.com/home-assistant/core/blob/2026.9.2/homeassistant/components/ring/__init__.py#L34-L65)
+
+Reauth and reconfigure later build a new data mapping containing only username, Ring API token, and hardware ID, then replace the entry data. The saved push credentials disappear. [HA reauth/reconfigure](https://github.com/home-assistant/core/blob/2026.9.2/homeassistant/components/ring/config_flow.py#L164-L235)
+
+Reauth should unquestionably preserve unrelated entry data. Reconfigure should also preserve the FCM credentials while rotating the Ring hardware ID; failed credential check-in already provides a natural path to full registration. If HA wants a manual push reset, it should be an explicit repair action rather than an incidental side effect of account reconfiguration.
+
+### 9. Home Assistant has no recovery state machine
+
+The coordinator awaits listener start once. An exception exits before callback installation and before entity updates. A false return still installs the callback and updates entities even though the listener is unavailable. Unload calls stop only when the stale boolean is true. [HA Ring listener coordinator](https://github.com/home-assistant/core/blob/2026.9.2/homeassistant/components/ring/coordinator.py#L162-L229)
+
+The coordinator should own one background state machine for the account:
+
+- Start only while at least one event entity is subscribed.
+- Propagate cancellation immediately.
+- On transient failure, use exponential backoff with jitter and a one-hour cap.
+- On `PHONE_REGISTRATION_ERROR`, avoid rapid identity rotation; begin with a substantially longer delay.
+- Update entity availability on every health transition.
+- Always clean up partial state on unload.
+- Create a user-visible repair issue after a sustained outage, including the error code and next retry but no credentials.
+- Automatically clear the issue after a confirmed healthy MCS login.
+
+## Recommended change sequence
+
+The fixes should be split at ownership boundaries so each project can release and test independently.
+
+### PR 1: `firebase-messaging`
+
+1. Add a typed registration exception with provider code and stage.
+2. Classify `PHONE_REGISTRATION_ERROR` as non-immediately-retryable for the same identity.
+3. Validate complete registration before invoking the credentials callback.
+4. Close the registration helper in `finally`.
+5. Make stop safe before, during, and after start.
+6. Terminate all tasks when initial connection attempts are exhausted.
+7. Add readiness/health waiting and an optional health-change callback.
+8. Add tests for terminal errors, partial results, cleanup, cancellation, and initial-connect exhaustion.
+
+### PR 2: `python-ring-doorbell`
+
+1. Require the Firebase release containing typed errors and readiness.
+2. Guard lifecycle operations with one lock and make start/stop idempotent.
+3. Do not mark started until MCS login is confirmed.
+4. Derive health from the receiver rather than a stale boolean.
+5. Clean up on every failed stage before allowing retry.
+6. Install internal callbacks only after readiness; track the actual default callback ID.
+7. Make session refresh continuous and deadline-based.
+8. Isolate malformed payloads and callback failures.
+9. Fix the legacy `action.lower()` comparison.
+10. Add restart, concurrent-start, receiver-death, malformed-payload, and callback-mutation tests.
+
+### PR 3: Home Assistant Core
+
+1. Preserve `listen_token` and unknown future entry fields during reauth/reconfigure.
+2. Replace the one-shot task with a cancelable, single-owner listener state machine.
+3. Use typed-error-aware, jittered backoff.
+4. Monitor health and update all Ring event entities when it changes.
+5. Always stop partial listener state on unload.
+6. Make entity listener-removal callbacks idempotent and cancel stale start/stop tasks.
+7. Add a repair issue for sustained realtime-event outage.
+8. Run Ring dependency tests against HA's actual `aiohttp` constraint in CI.
+
+## What should not be changed
+
+- Do not point the automation at the external chime or a different camera. The event source is the doorbell's Ding event, and that mapping is already correct.
+- Do not add card-level polling or URL-opening fallbacks to conceal listener failure. The card cannot reconstruct a realtime press that HA never received.
+- Do not repeatedly delete Ring authorized devices. That operates on a different registration domain and the controlled cleanup already disproved it as the primary fix.
+- Do not retry `PHONE_REGISTRATION_ERROR` every few seconds with newly generated identities. That can amplify throttling or identity-policy problems and makes diagnosis harder.
+- Do not install the proof-of-concept directly on the production HA host. It spans three versioned projects and needs staging with a disposable Ring/HA environment first.
+
+## Practical next step
+
+The highest-value first upstream change is the Firebase typed-error/cleanup/readiness patch, followed by the Ring lifecycle patch. HA can then consume reliable health and error semantics instead of guessing from a boolean. In parallel, the small HA credential-preservation fix can be submitted independently because it is low risk and prevents reauth from unnecessarily forcing a new Google registration.
+
+For this installation, a later successful retry would show that the Google rejection was temporary; it would not invalidate the lifecycle findings. If it fails again with the same provider code, further Ring reauthentication is unlikely to help. The next live diagnostic should record only the error code, stage, attempt time, and whether stored push credentials were present—never the credentials themselves.
+
+## Limitations
+
+- Google's legacy GCM endpoint does not provide a documented explanation with this response, and the project uses a reverse-engineered, unofficial Ring API. [python-ring-doorbell project statement](https://github.com/python-ring-doorbell/python-ring-doorbell)
+- The isolated tests deliberately did not call live Google or Ring endpoints, avoiding registration spam and account impact.
+- The candidate patch proves the proposed state-machine behavior under deterministic failure, timeout, cancellation, malformed-payload, and concurrency tests. It does not replace staging against live push delivery.
+- The official Ring app's internal implementation and credentials are not available for comparison.
+
+## Sources
+
+- [Home Assistant Core 2026.9.2 Ring integration](https://github.com/home-assistant/core/tree/2026.9.2/homeassistant/components/ring)
+- [Home Assistant current Ring integration](https://github.com/home-assistant/core/tree/dev/homeassistant/components/ring)
+- [`python-ring-doorbell` 0.9.14 event listener](https://github.com/python-ring-doorbell/python-ring-doorbell/blob/0.9.14/ring_doorbell/listen/eventlistener.py)
+- [`python-ring-doorbell` current event listener](https://github.com/python-ring-doorbell/python-ring-doorbell/blob/master/ring_doorbell/listen/eventlistener.py)
+- [`firebase-messaging` 0.4.5 registration source](https://github.com/sdb9696/firebase-messaging/blob/0.4.5/firebase_messaging/fcmregister.py)
+- [`firebase-messaging` 0.4.5 push-client source](https://github.com/sdb9696/firebase-messaging/blob/0.4.5/firebase_messaging/fcmpushclient.py)
+- [`firebase-messaging` current registration source](https://github.com/sdb9696/firebase-messaging/blob/main/firebase_messaging/fcmregister.py)
+- [Ring notification troubleshooting](https://github.com/dgreif/ring/wiki/Notification-Troubleshooting)
+- [Stale Ring listener health issue #526](https://github.com/python-ring-doorbell/python-ring-doorbell/issues/526)
+- [Missing `ding.id` receiver-termination issue #537](https://github.com/python-ring-doorbell/python-ring-doorbell/issues/537)
+- [Google Firebase duplicate-FID discussion #7025](https://github.com/firebase/firebase-android-sdk/issues/7025)
diff --git a/package-lock.json b/package-lock.json
index 6e6c100..d92b60e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "ring-view",
- "version": "0.10.0-beta.1",
+ "version": "0.10.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ring-view",
- "version": "0.10.0-beta.1",
+ "version": "0.10.0",
"license": "MIT",
"dependencies": {
"@mdi/js": "^7.4.47",
diff --git a/package.json b/package.json
index c2575de..702c4dd 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "ring-view",
- "version": "0.10.0-beta.1",
+ "version": "0.10.0",
"description": "A polished Home Assistant view for Ring recordings, Live, snapshots, talkback, and door access.",
"type": "module",
"main": "dist/ring-view.js",
diff --git a/src/ring-view-dialog.ts b/src/ring-view-dialog.ts
index 26edc2a..70bd8ea 100644
--- a/src/ring-view-dialog.ts
+++ b/src/ring-view-dialog.ts
@@ -182,7 +182,7 @@ export class RingViewDialog extends LitElement {
private returnUrl?: string;
private ringAlertTimer?: number;
private ringingUntil?: number;
- private lastDoorbellState?: string;
+ private lastDoorbellEntity?: HassEntity;
// Playback belongs to an element, not the dialog: every replacement video
// needs its own attempt, while repeated canplay events must not replay it.
private readonly recordingPlayback = new WeakSet();
@@ -248,8 +248,8 @@ export class RingViewDialog extends LitElement {
this.statusAnnouncement = "";
this.resetVisitorActions();
this.resetSnapshotAction();
- this.lastDoorbellState = this.config.doorbell_entity
- ? this.hass.states[this.config.doorbell_entity]?.state
+ this.lastDoorbellEntity = this.config.doorbell_entity
+ ? this.hass.states[this.config.doorbell_entity]
: undefined;
this.setRingingUntil(params.ringingUntil);
this.open = true;
@@ -293,8 +293,8 @@ export class RingViewDialog extends LitElement {
this.statusAnnouncement = "";
this.resetVisitorActions();
this.resetSnapshotAction();
- this.lastDoorbellState = this.config.doorbell_entity
- ? this.hass.states[this.config.doorbell_entity]?.state
+ this.lastDoorbellEntity = this.config.doorbell_entity
+ ? this.hass.states[this.config.doorbell_entity]
: undefined;
this.setRingingUntil(params.ringingUntil);
this.open = true;
@@ -2409,9 +2409,9 @@ export class RingViewDialog extends LitElement {
const entityId = this.config?.doorbell_entity;
if (!entityId || !this.hass) return;
const after = this.hass.states[entityId];
- const beforeState = previous?.states[entityId]?.state ?? this.lastDoorbellState;
- this.lastDoorbellState = after?.state;
- if (!isDoorbellRingTransition(entityId, beforeState, after)) return;
+ const before = previous?.states[entityId] ?? this.lastDoorbellEntity;
+ this.lastDoorbellEntity = after;
+ if (!isDoorbellRingTransition(entityId, before, after)) return;
this.setRingingUntil(Date.now() + RING_ALERT_DURATION_MS);
}
@@ -2472,7 +2472,7 @@ export class RingViewDialog extends LitElement {
this.resetVisitorActions();
this.resetSnapshotAction();
this.setRingingUntil();
- this.lastDoorbellState = undefined;
+ this.lastDoorbellEntity = undefined;
this.detachGlobalListeners();
if (document.fullscreenElement) void document.exitFullscreen().catch(() => undefined);
if (
diff --git a/src/ring-view.ts b/src/ring-view.ts
index e44cc1d..9441dba 100644
--- a/src/ring-view.ts
+++ b/src/ring-view.ts
@@ -222,7 +222,9 @@ export class RingView extends LitElement {
protected willUpdate(changed: PropertyValues): void {
if (!this.hass || !this.config) return;
this.observePreviewMedia();
- this.detectDoorbellEvent(changed.get("hass") as HomeAssistant | undefined);
+ if (changed.has("hass")) {
+ this.detectDoorbellEvent(changed.get("hass") as HomeAssistant | undefined);
+ }
if (this.isInCardPicker()) return;
const entityId = this.previewEntityId();
const fallbackPoster = posterUrl(this.hass, this.hass.states[entityId], entityId);
@@ -508,13 +510,12 @@ export class RingView extends LitElement {
private detectDoorbellEvent(previous?: HomeAssistant): void {
const entityId = this.config?.doorbell_entity;
- if (!entityId || !previous || !this.hass) return;
- const before = previous.states[entityId];
+ if (!entityId || !this.hass) return;
+ const before = previous?.states[entityId];
const after = this.hass.states[entityId];
- if (!isDoorbellRingTransition(entityId, before?.state, after)) return;
+ if (!isDoorbellRingTransition(entityId, before, after)) return;
const now = Date.now();
- if (now - this.lastRingAlertAt < 5_000) return;
this.lastRingAlertAt = now;
this.updateRingAlert();
}
diff --git a/src/translations/de.json b/src/translations/de.json
index 21e951d..3147b55 100644
--- a/src/translations/de.json
+++ b/src/translations/de.json
@@ -91,6 +91,7 @@
"card.preview_alt": "Vorschau für {name}",
"card.preview_unavailable": "Kameravorschau nicht verfügbar",
"card.description": "Letzte Aufnahme ansehen und einen separaten Live-Kamerastream starten.",
+ "activity.relative": "Aktivität · {time}",
"activity.accessible": "Letzte Aktivität, {time}",
"activity.title": "Letzte Aktivität: {time}",
"warning.unavailable": "{name} ist nicht verfügbar.",
diff --git a/src/translations/en.json b/src/translations/en.json
index dfcc344..0dbb617 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -91,6 +91,7 @@
"card.preview_alt": "{name} preview",
"card.preview_unavailable": "Camera preview unavailable",
"card.description": "View the latest recording and start a separate live camera stream.",
+ "activity.relative": "Activity · {time}",
"activity.accessible": "Last activity, {time}",
"activity.title": "Last activity: {time}",
"warning.unavailable": "{name} is unavailable.",
diff --git a/src/utilities/activity-time.ts b/src/utilities/activity-time.ts
index b95871a..35cc14b 100644
--- a/src/utilities/activity-time.ts
+++ b/src/utilities/activity-time.ts
@@ -175,7 +175,7 @@ export function formatActivityTime(
timeStyle: "short",
}).format(timestamp);
return {
- relative,
+ relative: localize(hass, "activity.relative", { time: relative }),
accessible: localize(hass, "activity.accessible", {
time: spokenRelative,
}),
diff --git a/src/utilities/doorbell.ts b/src/utilities/doorbell.ts
index 5399a1c..5fafc6c 100644
--- a/src/utilities/doorbell.ts
+++ b/src/utilities/doorbell.ts
@@ -1,29 +1,89 @@
import type { HassEntity } from "../types";
+import { parseTimestampValue } from "./activity-time";
const UNAVAILABLE_STATES = new Set(["unknown", "unavailable"]);
+const RECENT_RING_WINDOW_MS = 12_000;
+const FUTURE_CLOCK_TOLERANCE_MS = 5_000;
export function isDoorbellRingTransition(
entityId: string,
- previousState: string | undefined,
+ previous: HassEntity | undefined,
current: HassEntity | undefined,
+ now = Date.now(),
): boolean {
+ if (!current || UNAVAILABLE_STATES.has(current.state)) return false;
+
+ if (!previous || UNAVAILABLE_STATES.has(previous.state)) {
+ return isRecentRetainedRing(entityId, current, now);
+ }
+
+ if (entityId.startsWith("binary_sensor.")) {
+ if (previous.state === "off" && current.state === "on") return true;
+ if (previous.state !== "on" || current.state !== "on") return false;
+
+ const previousDing = ringMqttDingTimestamp(previous);
+ const currentDing = ringMqttDingTimestamp(current);
+ if (hasRingMqttDingMarker(previous) || hasRingMqttDingMarker(current)) {
+ return previousDing !== undefined
+ && currentDing !== undefined
+ && currentDing > previousDing;
+ }
+
+ const previousUpdate = parseTimestampValue(previous.last_updated);
+ const currentUpdate = parseTimestampValue(current.last_updated);
+ return previousUpdate !== undefined
+ && currentUpdate !== undefined
+ && currentUpdate > previousUpdate;
+ }
+
if (
- !current
- || previousState === undefined
- || UNAVAILABLE_STATES.has(current.state)
+ !entityId.startsWith("event.")
+ || (current.attributes.event_type !== undefined
+ && current.attributes.event_type !== "ring")
) {
return false;
}
+ const previousEvent = parseTimestampValue(previous.state);
+ const currentEvent = parseTimestampValue(current.state);
+ if (currentEvent === undefined) return false;
+ return previousEvent === undefined
+ ? isRecentTimestamp(currentEvent, now)
+ : currentEvent > previousEvent;
+}
+
+function isRecentRetainedRing(
+ entityId: string,
+ current: HassEntity,
+ now: number,
+): boolean {
if (entityId.startsWith("binary_sensor.")) {
- return previousState === "off" && current.state === "on";
+ if (current.state !== "on") return false;
+ return isRecentTimestamp(ringMqttDingTimestamp(current), now);
}
- return (
- entityId.startsWith("event.")
- && previousState !== "unavailable"
- && previousState !== current.state
+ return entityId.startsWith("event.")
&& (current.attributes.event_type === undefined
|| current.attributes.event_type === "ring")
- );
+ && isRecentTimestamp(parseTimestampValue(current.state), now);
+}
+
+function isRecentTimestamp(timestamp: number | undefined, now: number): boolean {
+ if (timestamp === undefined) return false;
+ const age = now - timestamp;
+ return age >= -FUTURE_CLOCK_TOLERANCE_MS && age <= RECENT_RING_WINDOW_MS;
+}
+
+function ringMqttDingTimestamp(entity: HassEntity): number | undefined {
+ const candidates = [
+ parseTimestampValue(entity.attributes.lastDingTime),
+ parseTimestampValue(entity.attributes.lastDing),
+ ].filter((value): value is number => value !== undefined);
+
+ return candidates.length > 0 ? Math.max(...candidates) : undefined;
+}
+
+function hasRingMqttDingMarker(entity: HassEntity): boolean {
+ return entity.attributes.lastDingTime !== undefined
+ || entity.attributes.lastDing !== undefined;
}
diff --git a/tests/browser/card.spec.ts b/tests/browser/card.spec.ts
index 7597c9c..c937b24 100644
--- a/tests/browser/card.spec.ts
+++ b/tests/browser/card.spec.ts
@@ -1409,7 +1409,7 @@ test("shows accessible activity time below the name without overlapping controls
const activity = page.locator(
"ring-view > ha-card ring-view-activity-time span",
);
- await expect(activity).toHaveText(/2 min.*ago/i);
+ await expect(activity).toHaveText(/^\s*Activity · 2 min.*ago/i);
await expect(activity).toHaveAttribute(
"aria-label",
"Last activity, 2 minutes ago",
@@ -1597,7 +1597,7 @@ test("localizes activity text without letting a longer format reach the controls
const card = page.locator("ring-view");
const activity = card.locator("ring-view-activity-time span");
const modes = card.getByRole("tablist", { name: "Kameraansicht" });
- await expect(activity).toHaveText(/vor 2 Std/i);
+ await expect(activity).toHaveText(/^\s*Aktivität · vor 2 Std/i);
await expect(activity).toHaveAttribute(
"aria-label",
"Letzte Aktivität, vor 2 Stunden",
diff --git a/tests/unit/activity-time.test.ts b/tests/unit/activity-time.test.ts
index ef2f708..81260ae 100644
--- a/tests/unit/activity-time.test.ts
+++ b/tests/unit/activity-time.test.ts
@@ -171,7 +171,7 @@ describe("last activity timestamps", () => {
now - 2 * 60 * 1_000,
now,
);
- expect(display.relative).toMatch(/2 min.*ago/i);
+ expect(display.relative).toMatch(/^Activity · 2 min.*ago/i);
expect(display.accessible).toBe("Last activity, 2 minutes ago");
expect(display.title).toMatch(/^Last activity: /);
});
@@ -185,7 +185,7 @@ describe("last activity timestamps", () => {
} satisfies HomeAssistant;
const now = Date.parse("2026-09-12T10:15:30Z");
const display = formatActivityTime(hass, now - 2 * 60 * 1_000, now);
- expect(display.relative).toContain("2 Min");
+ expect(display.relative).toMatch(/^Aktivität · vor 2 Min/);
expect(display.accessible).toBe("Letzte Aktivität, vor 2 Minuten");
expect(display.title).toMatch(/^Letzte Aktivität: /);
});
diff --git a/tests/unit/card-lifecycle.test.ts b/tests/unit/card-lifecycle.test.ts
index 8ef59a2..a92a71c 100644
--- a/tests/unit/card-lifecycle.test.ts
+++ b/tests/unit/card-lifecycle.test.ts
@@ -368,7 +368,9 @@ describe("card stream lifecycle", () => {
expect(TestCameraStream.active).toBe(1);
});
- it("shows a Ring-MQTT Ding only for an off-to-on transition", async () => {
+ it("shows Ring-MQTT Dings for off-to-on and a new marker while still on", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(Date.parse("2026-09-12T14:19:13Z"));
const doorbell: HassEntity = {
entity_id: "binary_sensor.front_door_ding",
state: "on",
@@ -382,17 +384,21 @@ describe("card stream lifecycle", () => {
});
card.hass = { ...hass, states: { ...hass.states, [doorbell.entity_id]: doorbell } };
document.body.append(card);
- await card.updateComplete;
+ await vi.advanceTimersByTimeAsync(0);
expect(card.shadowRoot?.querySelector(".ring-alert")).toBeNull();
card.hass = {
...card.hass,
states: {
...card.hass.states,
- [doorbell.entity_id]: { ...doorbell, state: "off" },
+ [doorbell.entity_id]: {
+ ...doorbell,
+ state: "off",
+ attributes: { ...doorbell.attributes, lastDingTime: "2026-09-12T14:18:00Z" },
+ },
},
};
- await card.updateComplete;
+ await vi.advanceTimersByTimeAsync(0);
expect(card.shadowRoot?.querySelector(".ring-alert")).toBeNull();
card.hass = {
@@ -406,10 +412,53 @@ describe("card stream lifecycle", () => {
},
},
};
- await card.updateComplete;
+ await vi.advanceTimersByTimeAsync(0);
expect(card.shadowRoot?.querySelector(".ring-alert")?.textContent).toContain(
"Someone is at the door",
);
+
+ await vi.advanceTimersByTimeAsync(2_000);
+ card.hass = {
+ ...card.hass,
+ states: {
+ ...card.hass.states,
+ [doorbell.entity_id]: {
+ ...doorbell,
+ state: "on",
+ attributes: { ...doorbell.attributes, lastDingTime: "2026-09-12T14:19:15Z" },
+ },
+ },
+ };
+ await vi.advanceTimersByTimeAsync(0);
+
+ // The second marker starts a fresh twelve-second alert even though the
+ // binary sensor never returned to off.
+ await vi.advanceTimersByTimeAsync(10_001);
+ expect(card.shadowRoot?.querySelector(".ring-alert")).not.toBeNull();
+ });
+
+ it.each([
+ ["fresh", "2026-09-12T14:20:05Z", true],
+ ["stale", "2026-09-12T14:19:59Z", false],
+ ])("handles a %s retained Ring-MQTT Ding on initial load", async (_, lastDingTime, visible) => {
+ vi.useFakeTimers();
+ vi.setSystemTime(Date.parse("2026-09-12T14:20:12Z"));
+ const doorbell: HassEntity = {
+ entity_id: "binary_sensor.front_door_ding",
+ state: "on",
+ attributes: { device_class: "occupancy", lastDingTime },
+ };
+ const card = document.createElement("ring-view");
+ card.setConfig({
+ recording_entity: "camera.recording",
+ live_entity: "camera.live",
+ doorbell_entity: doorbell.entity_id,
+ });
+ card.hass = { ...hass, states: { ...hass.states, [doorbell.entity_id]: doorbell } };
+ document.body.append(card);
+ await vi.advanceTimersByTimeAsync(0);
+
+ expect(card.shadowRoot?.querySelector(".ring-alert") !== null).toBe(visible);
});
it("updates Ring-MQTT activity when a same-device motion sibling changes", async () => {
@@ -908,6 +957,57 @@ describe("card stream lifecycle", () => {
dialog?.close();
});
+ it("shows a repeated Ring-MQTT Ding in Live while the sensor remains on", async () => {
+ const doorbell: HassEntity = {
+ entity_id: "binary_sensor.front_door_ding",
+ state: "on",
+ attributes: {
+ device_class: "occupancy",
+ lastDingTime: "2026-09-12T14:19:13Z",
+ },
+ };
+ const card = document.createElement("ring-view");
+ card.setConfig({
+ recording_entity: "camera.recording",
+ live_entity: "camera.live",
+ doorbell_entity: doorbell.entity_id,
+ default_mode: "live",
+ });
+ card.hass = {
+ ...hass,
+ states: { ...hass.states, [doorbell.entity_id]: doorbell },
+ };
+ document.body.append(card);
+ await card.updateComplete;
+ card.shadowRoot?.querySelector(".preview")?.click();
+ await flush();
+
+ const dialog = getDialog();
+ expect(dialog?.shadowRoot?.querySelector(".ring-indicator")).toBeNull();
+ dialog!.hass = {
+ ...hass,
+ states: {
+ ...hass.states,
+ [doorbell.entity_id]: {
+ ...doorbell,
+ attributes: {
+ ...doorbell.attributes,
+ lastDingTime: "2026-09-12T14:20:10Z",
+ },
+ },
+ },
+ };
+ await dialog!.updateComplete;
+ await flush();
+
+ expect(
+ dialog?.shadowRoot?.querySelector(".ring-indicator")?.getAttribute("aria-label"),
+ ).toBe("Someone is at the door");
+ expect(dialog?.shadowRoot?.querySelector(".doorbell-alert-layer")).toBeNull();
+ expect(TestCameraStream.active).toBe(1);
+ dialog?.close();
+ });
+
it("keeps a global camera error primary when a Ding arrives in Live", async () => {
const doorbell: HassEntity = {
entity_id: "event.front_door_ding",
diff --git a/tests/unit/doorbell.test.ts b/tests/unit/doorbell.test.ts
index 5a3c73b..8e81d70 100644
--- a/tests/unit/doorbell.test.ts
+++ b/tests/unit/doorbell.test.ts
@@ -2,46 +2,185 @@ import { describe, expect, it } from "vitest";
import type { HassEntity } from "../../src/types";
import { isDoorbellRingTransition } from "../../src/utilities/doorbell";
-function entity(entityId: string, state: string, eventType?: string): HassEntity {
+function entity(
+ entityId: string,
+ state: string,
+ eventType?: string,
+ attributes: HassEntity["attributes"] = {},
+): HassEntity {
return {
entity_id: entityId,
state,
- attributes: eventType === undefined ? {} : { event_type: eventType },
+ attributes: eventType === undefined ? attributes : { ...attributes, event_type: eventType },
};
}
describe("doorbell ring transitions", () => {
- it("accepts only off-to-on transitions for binary sensors", () => {
+ const now = Date.parse("2026-09-12T14:20:12Z");
+
+ it("accepts off-to-on binary-sensor transitions without stale recovery noise", () => {
+ const id = "binary_sensor.front_door_ding";
+ expect(isDoorbellRingTransition(id, entity(id, "off"), entity(id, "on"), now)).toBe(true);
+ expect(isDoorbellRingTransition(id, entity(id, "on"), entity(id, "on"), now)).toBe(false);
+ expect(isDoorbellRingTransition(id, entity(id, "on"), entity(id, "off"), now)).toBe(false);
+ expect(isDoorbellRingTransition(id, entity(id, "on"), entity(id, "unavailable"), now)).toBe(false);
+ expect(isDoorbellRingTransition(id, entity(id, "unavailable"), entity(id, "on"), now)).toBe(false);
+ });
+
+ it("recovers only fresh retained or reconnected Ring-MQTT Dings", () => {
+ const id = "binary_sensor.front_door_ding";
+ const fresh = entity(id, "on", undefined, {
+ lastDingTime: "2026-09-12T14:20:05Z",
+ });
+ const stale = entity(id, "on", undefined, {
+ lastDingTime: "2026-09-12T14:19:59Z",
+ });
+ const nearFuture = entity(id, "on", undefined, {
+ lastDing: (now + 4_000) / 1_000,
+ });
+ const tooFarFuture = entity(id, "on", undefined, {
+ lastDing: (now + 6_000) / 1_000,
+ });
+
+ expect(isDoorbellRingTransition(id, undefined, fresh, now)).toBe(true);
+ expect(isDoorbellRingTransition(id, undefined, stale, now)).toBe(false);
+ expect(isDoorbellRingTransition(id, entity(id, "unavailable"), fresh, now)).toBe(true);
+ expect(isDoorbellRingTransition(id, entity(id, "unknown"), stale, now)).toBe(false);
+ expect(isDoorbellRingTransition(id, undefined, nearFuture, now)).toBe(true);
+ expect(isDoorbellRingTransition(id, undefined, tooFarFuture, now)).toBe(false);
+ });
+
+ it("accepts only advancing Ring-MQTT Ding timestamps while the sensor remains on", () => {
const id = "binary_sensor.front_door_ding";
- expect(isDoorbellRingTransition(id, "off", entity(id, "on"))).toBe(true);
- expect(isDoorbellRingTransition(id, "on", entity(id, "on"))).toBe(false);
- expect(isDoorbellRingTransition(id, "on", entity(id, "off"))).toBe(false);
- expect(isDoorbellRingTransition(id, "unknown", entity(id, "on"))).toBe(false);
- expect(isDoorbellRingTransition(id, "unavailable", entity(id, "on"))).toBe(false);
+ const previous = entity(id, "on", undefined, {
+ lastDingTime: "2026-09-12T14:19:13Z",
+ lastDing: 1_789_222_753,
+ });
+
+ expect(isDoorbellRingTransition(
+ id,
+ previous,
+ entity(id, "on", undefined, {
+ lastDingTime: "2026-09-12T14:20:10Z",
+ lastDing: 1_789_222_810,
+ }),
+ now,
+ )).toBe(true);
+ expect(isDoorbellRingTransition(id, previous, { ...previous }, now)).toBe(false);
+ expect(isDoorbellRingTransition(
+ id,
+ previous,
+ entity(id, "on", undefined, {
+ lastDingTime: "2026-09-12T14:18:13Z",
+ lastDing: 1_789_222_693,
+ }),
+ now,
+ )).toBe(false);
+ expect(isDoorbellRingTransition(
+ id,
+ entity(id, "off", undefined, { lastDingTime: "2026-09-12T14:19:13Z" }),
+ entity(id, "off", undefined, { lastDingTime: "2026-09-12T14:20:10Z" }),
+ now,
+ )).toBe(false);
});
- it("accepts Ring events and ignores other event types", () => {
+ it("normalizes equivalent markers and compares both Ring-MQTT timestamp attributes", () => {
+ const id = "binary_sensor.front_door_ding";
+ const previous = entity(id, "on", undefined, {
+ lastDingTime: "2026-09-12T14:19:13Z",
+ lastDing: 1_789_222_753,
+ });
+
+ expect(isDoorbellRingTransition(
+ id,
+ previous,
+ entity(id, "on", undefined, {
+ lastDingTime: "2026-09-12T14:19:13+00:00",
+ lastDing: "1789222753",
+ }),
+ now,
+ )).toBe(false);
+ expect(isDoorbellRingTransition(
+ id,
+ previous,
+ entity(id, "on", undefined, { lastDing: 1_789_222_753 }),
+ now,
+ )).toBe(false);
+ expect(isDoorbellRingTransition(
+ id,
+ previous,
+ entity(id, "on", undefined, {
+ lastDingTime: "invalid",
+ lastDing: 1_789_222_810,
+ }),
+ now,
+ )).toBe(true);
+ expect(isDoorbellRingTransition(
+ id,
+ entity(id, "on"),
+ entity(id, "on", undefined, { lastDingTime: "2026-09-12T14:20:10Z" }),
+ now,
+ )).toBe(false);
+ });
+
+ it("uses last_updated only when neither binary-sensor state has a Ding marker", () => {
+ const id = "binary_sensor.front_door_ding";
+ const previous = {
+ ...entity(id, "on"),
+ last_updated: "2026-09-12T14:19:13Z",
+ };
+ const current = {
+ ...entity(id, "on"),
+ last_updated: "2026-09-12T14:20:10Z",
+ };
+
+ expect(isDoorbellRingTransition(id, previous, current, now)).toBe(true);
+ expect(isDoorbellRingTransition(id, current, { ...current }, now)).toBe(false);
+ expect(isDoorbellRingTransition(
+ id,
+ entity(id, "on", undefined, { lastDingTime: "2026-09-12T14:19:13Z" }),
+ current,
+ now,
+ )).toBe(false);
+ expect(isDoorbellRingTransition(
+ id,
+ { ...previous, attributes: { lastDingTime: "invalid" } },
+ { ...current, attributes: { lastDingTime: "invalid" } },
+ now,
+ )).toBe(false);
+ });
+
+ it("accepts only advancing Ring event timestamps and ignores other event types", () => {
const id = "event.front_door_ding";
expect(
- isDoorbellRingTransition(id, "2026-09-12T14:00:00Z", entity(id, "2026-09-12T14:01:00Z", "ring")),
+ isDoorbellRingTransition(id, entity(id, "2026-09-12T14:19:00Z"), entity(id, "2026-09-12T14:20:10Z", "ring"), now),
).toBe(true);
expect(
- isDoorbellRingTransition(id, "2026-09-12T14:00:00Z", entity(id, "2026-09-12T14:01:00Z", "motion")),
+ isDoorbellRingTransition(id, entity(id, "2026-09-12T14:19:00Z"), entity(id, "2026-09-12T14:20:10Z", "motion"), now),
).toBe(false);
expect(
- isDoorbellRingTransition(id, "2026-09-12T14:01:00Z", entity(id, "2026-09-12T14:01:00Z", "ring")),
+ isDoorbellRingTransition(id, entity(id, "2026-09-12T14:20:10Z"), entity(id, "2026-09-12T14:20:10Z", "ring"), now),
).toBe(false);
expect(
- isDoorbellRingTransition(id, "unknown", entity(id, "2026-09-12T14:01:00Z", "ring")),
+ isDoorbellRingTransition(id, entity(id, "2026-09-12T14:20:10Z"), entity(id, "2026-09-12T14:20:09Z", "ring"), now),
+ ).toBe(false);
+ expect(
+ isDoorbellRingTransition(id, entity(id, "2026-09-12T14:20:10Z"), entity(id, "2026-09-12T14:20:10+00:00", "ring"), now),
+ ).toBe(false);
+ expect(
+ isDoorbellRingTransition(id, undefined, entity(id, "2026-09-12T14:20:10Z", "ring"), now),
+ ).toBe(true);
+ expect(
+ isDoorbellRingTransition(id, entity(id, "unavailable"), entity(id, "2026-09-12T14:20:10Z", "ring"), now),
).toBe(true);
expect(
- isDoorbellRingTransition(id, "unavailable", entity(id, "2026-09-12T14:01:00Z", "ring")),
+ isDoorbellRingTransition(id, undefined, entity(id, "2026-09-12T14:19:59Z", "ring"), now),
).toBe(false);
});
- it("does not treat initial, unavailable, or unsupported states as a ring", () => {
- expect(isDoorbellRingTransition("event.ding", undefined, entity("event.ding", "now", "ring"))).toBe(false);
- expect(isDoorbellRingTransition("event.ding", "before", entity("event.ding", "unavailable", "ring"))).toBe(false);
- expect(isDoorbellRingTransition("sensor.ding", "off", entity("sensor.ding", "on"))).toBe(false);
+ it("does not treat invalid, unavailable, or unsupported entities as a ring", () => {
+ expect(isDoorbellRingTransition("event.ding", undefined, entity("event.ding", "not-a-date", "ring"), now)).toBe(false);
+ expect(isDoorbellRingTransition("event.ding", entity("event.ding", "before"), entity("event.ding", "unavailable", "ring"), now)).toBe(false);
+ expect(isDoorbellRingTransition("sensor.ding", entity("sensor.ding", "off"), entity("sensor.ding", "on"), now)).toBe(false);
});
});