From 0346c0a293d09d5b54ceddc96301e4620f892352 Mon Sep 17 00:00:00 2001 From: Thomas Gregg <2538782+thomasgregg@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:26:01 +0200 Subject: [PATCH 1/5] Detect repeated Ring-MQTT doorbell presses --- CHANGELOG.md | 5 + dist/ring-view.js | 583 +++++++++++++++--------------- src/ring-view-dialog.ts | 18 +- src/ring-view.ts | 2 +- src/utilities/doorbell.ts | 35 +- tests/unit/card-lifecycle.test.ts | 88 ++++- tests/unit/doorbell.test.ts | 70 +++- 7 files changed, 484 insertions(+), 317 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff6f0eb..c7b9ebe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to this project are documented here. ## [Unreleased] +### Fixed + +- Detect repeated Ring-MQTT doorbell presses from a changed `lastDingTime` or + `lastDing` attribute while the configured Ding sensor remains `on`. + ## [0.10.0-beta.1] - 2026-09-13 ### Added diff --git a/dist/ring-view.js b/dist/ring-view.js index 578154a..f9e35fd 100644 --- a/dist/ring-view.js +++ b/dist/ring-view.js @@ -1,6 +1,6 @@ -var _e = "M11,15H13V17H11V15M11,7H13V13H11V7M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20Z", ft = "M10,21H14A2,2 0 0,1 12,23A2,2 0 0,1 10,21M21,19V20H3V19L5,17V11C5,7.9 7.03,5.17 10,4.29C10,4.19 10,4.1 10,4A2,2 0 0,1 12,2A2,2 0 0,1 14,4C14,4.1 14,4.19 14,4.29C16.97,5.17 19,7.9 19,11V17L21,19M17,11A5,5 0 0,0 12,6A5,5 0 0,0 7,11V18H17V11M19.75,3.19L18.33,4.61C20.04,6.3 21,8.6 21,11H23C23,8.07 21.84,5.25 19.75,3.19M1,11H3C3,8.6 3.96,6.3 5.67,4.61L4.25,3.19C2.16,5.25 1,8.07 1,11Z", Bt = "M9,12C9,11.19 9.3,10.5 9.89,9.89C10.5,9.3 11.19,9 12,9C12.81,9 13.5,9.3 14.11,9.89C14.7,10.5 15,11.19 15,12C15,12.81 14.7,13.5 14.11,14.11C13.5,14.7 12.81,15 12,15C11.19,15 10.5,14.7 9.89,14.11C9.3,13.5 9,12.81 9,12M5.53,8.44L7.31,10.22L5.53,12L7.31,13.78L5.53,15.56L2,12L5.53,8.44M8.44,18.47L10.22,16.69L12,18.47L13.78,16.69L15.56,18.47L12,22L8.44,18.47M18.47,15.56L16.69,13.78L18.47,12L16.69,10.22L18.47,8.44L22,12L18.47,15.56M15.56,5.53L13.78,7.31L12,5.53L10.22,7.31L8.44,5.53L12,2L15.56,5.53Z", mt = "M20,4H16.83L15,2H9L7.17,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6A2,2 0 0,0 20,4M20,18H4V6H8.05L9.88,4H14.12L15.95,6H20V18M12,7A5,5 0 0,0 7,12A5,5 0 0,0 12,17A5,5 0 0,0 17,12A5,5 0 0,0 12,7M12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15Z", Ke = "M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M12 20C7.59 20 4 16.41 4 12S7.59 4 12 4 20 7.59 20 12 16.41 20 12 20M16.59 7.58L10 14.17L7.41 11.59L6 13L10 17L18 9L16.59 7.58Z", Kt = "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", Wt = "M16,11H18V13H16V11M12,3H19C20.11,3 21,3.89 21,5V19H22V21H2V19H10V5C10,3.89 10.89,3 12,3M12,5V19H19V5H12Z", We = "M12,3C10.89,3 10,3.89 10,5H3V19H2V21H22V19H21V5C21,3.89 20.11,3 19,3H12M12,5H19V19H12V5M5,11H7V13H5V11Z", jt = "M14 15C14 16.11 13.11 17 12 17S10 16.11 10 15 10.9 13 12 13 14 13.9 14 15M18 4V20C18 21.1 17.11 22 16 22H8C6.9 22 6 21.11 6 20V4C6 2.9 6.9 2 8 2H16C17.11 2 18 2.9 18 4M10.5 7C10.5 7.83 11.17 8.5 12 8.5S13.5 7.83 13.5 7 12.83 5.5 12 5.5 10.5 6.17 10.5 7M16 10H8V20H16V10Z", Zt = "M5,5H10V7H7V10H5V5M14,5H19V10H17V7H14V5M17,14H19V19H14V17H17V14M10,17V19H5V14H7V17H10Z", Gt = "M13.5,8H12V13L16.28,15.54L17,14.33L13.5,12.25V8M13,3A9,9 0 0,0 4,12H1L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,20 10.5,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3", je = "M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z", _t = "M10 13C11.1 13 12 13.89 12 15C12 16.11 11.11 17 10 17S8 16.11 8 15 8.9 13 10 13M18 1C15.24 1 13 3.24 13 6V8H4C2.9 8 2 8.9 2 10V20C2 21.1 2.9 22 4 22H16C17.1 22 18 21.1 18 20V10C18 8.9 17.1 8 16 8H15V6C15 4.34 16.34 3 18 3S21 4.34 21 6V8H23V6C23 3.24 20.76 1 18 1M16 10V20H4V10H16Z", bt = "M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z", wt = "M19,11C19,12.19 18.66,13.3 18.1,14.28L16.87,13.05C17.14,12.43 17.3,11.74 17.3,11H19M15,11.16L9,5.18V5A3,3 0 0,1 12,2A3,3 0 0,1 15,5V11L15,11.16M4.27,3L21,19.73L19.73,21L15.54,16.81C14.77,17.27 13.91,17.58 13,17.72V21H11V17.72C7.72,17.23 5,14.41 5,11H6.7C6.7,14 9.24,16.1 12,16.1C12.81,16.1 13.6,15.91 14.31,15.58L12.65,13.92L12,14A3,3 0 0,1 9,11V10.28L3,4.27L4.27,3Z", Qt = "M12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2C17.5,2 22,6 22,11A6,6 0 0,1 16,17H14.2C13.9,17 13.7,17.2 13.7,17.5C13.7,17.6 13.8,17.7 13.8,17.8C14.2,18.3 14.4,18.9 14.4,19.5C14.5,20.9 13.4,22 12,22M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C12.3,20 12.5,19.8 12.5,19.5C12.5,19.3 12.4,19.2 12.4,19.1C12,18.6 11.8,18.1 11.8,17.5C11.8,16.1 12.9,15 14.3,15H16A4,4 0 0,0 20,11C20,7.1 16.4,4 12,4M6.5,10C7.3,10 8,10.7 8,11.5C8,12.3 7.3,13 6.5,13C5.7,13 5,12.3 5,11.5C5,10.7 5.7,10 6.5,10M9.5,6C10.3,6 11,6.7 11,7.5C11,8.3 10.3,9 9.5,9C8.7,9 8,8.3 8,7.5C8,6.7 8.7,6 9.5,6M14.5,6C15.3,6 16,6.7 16,7.5C16,8.3 15.3,9 14.5,9C13.7,9 13,8.3 13,7.5C13,6.7 13.7,6 14.5,6M17.5,10C18.3,10 19,10.7 19,11.5C19,12.3 18.3,13 17.5,13C16.7,13 16,12.3 16,11.5C16,10.7 16.7,10 17.5,10Z", yt = "M8,5.14V19.14L19,12.14L8,5.14Z", Yt = "M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M10,16.5L16,12L10,7.5V16.5Z"; +var _e = "M11,15H13V17H11V15M11,7H13V13H11V7M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20Z", _t = "M10,21H14A2,2 0 0,1 12,23A2,2 0 0,1 10,21M21,19V20H3V19L5,17V11C5,7.9 7.03,5.17 10,4.29C10,4.19 10,4.1 10,4A2,2 0 0,1 12,2A2,2 0 0,1 14,4C14,4.1 14,4.19 14,4.29C16.97,5.17 19,7.9 19,11V17L21,19M17,11A5,5 0 0,0 12,6A5,5 0 0,0 7,11V18H17V11M19.75,3.19L18.33,4.61C20.04,6.3 21,8.6 21,11H23C23,8.07 21.84,5.25 19.75,3.19M1,11H3C3,8.6 3.96,6.3 5.67,4.61L4.25,3.19C2.16,5.25 1,8.07 1,11Z", Wt = "M9,12C9,11.19 9.3,10.5 9.89,9.89C10.5,9.3 11.19,9 12,9C12.81,9 13.5,9.3 14.11,9.89C14.7,10.5 15,11.19 15,12C15,12.81 14.7,13.5 14.11,14.11C13.5,14.7 12.81,15 12,15C11.19,15 10.5,14.7 9.89,14.11C9.3,13.5 9,12.81 9,12M5.53,8.44L7.31,10.22L5.53,12L7.31,13.78L5.53,15.56L2,12L5.53,8.44M8.44,18.47L10.22,16.69L12,18.47L13.78,16.69L15.56,18.47L12,22L8.44,18.47M18.47,15.56L16.69,13.78L18.47,12L16.69,10.22L18.47,8.44L22,12L18.47,15.56M15.56,5.53L13.78,7.31L12,5.53L10.22,7.31L8.44,5.53L12,2L15.56,5.53Z", bt = "M20,4H16.83L15,2H9L7.17,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6A2,2 0 0,0 20,4M20,18H4V6H8.05L9.88,4H14.12L15.95,6H20V18M12,7A5,5 0 0,0 7,12A5,5 0 0,0 12,17A5,5 0 0,0 17,12A5,5 0 0,0 12,7M12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15Z", Ke = "M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M12 20C7.59 20 4 16.41 4 12S7.59 4 12 4 20 7.59 20 12 16.41 20 12 20M16.59 7.58L10 14.17L7.41 11.59L6 13L10 17L18 9L16.59 7.58Z", jt = "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", Zt = "M16,11H18V13H16V11M12,3H19C20.11,3 21,3.89 21,5V19H22V21H2V19H10V5C10,3.89 10.89,3 12,3M12,5V19H19V5H12Z", We = "M12,3C10.89,3 10,3.89 10,5H3V19H2V21H22V19H21V5C21,3.89 20.11,3 19,3H12M12,5H19V19H12V5M5,11H7V13H5V11Z", Gt = "M14 15C14 16.11 13.11 17 12 17S10 16.11 10 15 10.9 13 12 13 14 13.9 14 15M18 4V20C18 21.1 17.11 22 16 22H8C6.9 22 6 21.11 6 20V4C6 2.9 6.9 2 8 2H16C17.11 2 18 2.9 18 4M10.5 7C10.5 7.83 11.17 8.5 12 8.5S13.5 7.83 13.5 7 12.83 5.5 12 5.5 10.5 6.17 10.5 7M16 10H8V20H16V10Z", Qt = "M5,5H10V7H7V10H5V5M14,5H19V10H17V7H14V5M17,14H19V19H14V17H17V14M10,17V19H5V14H7V17H10Z", Yt = "M13.5,8H12V13L16.28,15.54L17,14.33L13.5,12.25V8M13,3A9,9 0 0,0 4,12H1L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,20 10.5,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3", je = "M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z", wt = "M10 13C11.1 13 12 13.89 12 15C12 16.11 11.11 17 10 17S8 16.11 8 15 8.9 13 10 13M18 1C15.24 1 13 3.24 13 6V8H4C2.9 8 2 8.9 2 10V20C2 21.1 2.9 22 4 22H16C17.1 22 18 21.1 18 20V10C18 8.9 17.1 8 16 8H15V6C15 4.34 16.34 3 18 3S21 4.34 21 6V8H23V6C23 3.24 20.76 1 18 1M16 10V20H4V10H16Z", yt = "M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z", kt = "M19,11C19,12.19 18.66,13.3 18.1,14.28L16.87,13.05C17.14,12.43 17.3,11.74 17.3,11H19M15,11.16L9,5.18V5A3,3 0 0,1 12,2A3,3 0 0,1 15,5V11L15,11.16M4.27,3L21,19.73L19.73,21L15.54,16.81C14.77,17.27 13.91,17.58 13,17.72V21H11V17.72C7.72,17.23 5,14.41 5,11H6.7C6.7,14 9.24,16.1 12,16.1C12.81,16.1 13.6,15.91 14.31,15.58L12.65,13.92L12,14A3,3 0 0,1 9,11V10.28L3,4.27L4.27,3Z", Jt = "M12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2C17.5,2 22,6 22,11A6,6 0 0,1 16,17H14.2C13.9,17 13.7,17.2 13.7,17.5C13.7,17.6 13.8,17.7 13.8,17.8C14.2,18.3 14.4,18.9 14.4,19.5C14.5,20.9 13.4,22 12,22M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C12.3,20 12.5,19.8 12.5,19.5C12.5,19.3 12.4,19.2 12.4,19.1C12,18.6 11.8,18.1 11.8,17.5C11.8,16.1 12.9,15 14.3,15H16A4,4 0 0,0 20,11C20,7.1 16.4,4 12,4M6.5,10C7.3,10 8,10.7 8,11.5C8,12.3 7.3,13 6.5,13C5.7,13 5,12.3 5,11.5C5,10.7 5.7,10 6.5,10M9.5,6C10.3,6 11,6.7 11,7.5C11,8.3 10.3,9 9.5,9C8.7,9 8,8.3 8,7.5C8,6.7 8.7,6 9.5,6M14.5,6C15.3,6 16,6.7 16,7.5C16,8.3 15.3,9 14.5,9C13.7,9 13,8.3 13,7.5C13,6.7 13.7,6 14.5,6M17.5,10C18.3,10 19,10.7 19,11.5C19,12.3 18.3,13 17.5,13C16.7,13 16,12.3 16,11.5C16,10.7 16.7,10 17.5,10Z", At = "M8,5.14V19.14L19,12.14L8,5.14Z", Xt = "M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M10,16.5L16,12L10,7.5V16.5Z"; const re = globalThis, Pe = re.ShadowRoot && (re.ShadyCSS === void 0 || re.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, Le = /* @__PURE__ */ Symbol(), Ze = /* @__PURE__ */ new WeakMap(); -let kt = class { +let St = class { constructor(t, i, n) { if (this._$cssResult$ = !0, n !== Le) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); this.cssText = t, this.t = i; @@ -18,14 +18,14 @@ let kt = class { return this.cssText; } }; -const Jt = (e) => new kt(typeof e == "string" ? e : e + "", void 0, Le), K = (e, ...t) => { +const ei = (e) => new St(typeof e == "string" ? e : e + "", void 0, Le), K = (e, ...t) => { const i = e.length === 1 ? e[0] : t.reduce((n, r, o) => n + ((a) => { if (a._$cssResult$ === !0) return a.cssText; if (typeof a == "number") return a; throw Error("Value passed to 'css' function must be a 'css' function result: " + a + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); })(r) + e[o + 1], e[0]); - return new kt(i, e, Le); -}, Xt = (e, t) => { + return new St(i, e, Le); +}, ti = (e, t) => { if (Pe) e.adoptedStyleSheets = t.map((i) => i instanceof CSSStyleSheet ? i : i.styleSheet); else for (const i of t) { const n = document.createElement("style"), r = re.litNonce; @@ -34,12 +34,12 @@ const Jt = (e) => new kt(typeof e == "string" ? e : e + "", void 0, Le), K = (e, }, Ge = Pe ? (e) => e : (e) => e instanceof CSSStyleSheet ? ((t) => { let i = ""; for (const n of t.cssRules) i += n.cssText; - return Jt(i); + return ei(i); })(e) : e; -const { is: ei, defineProperty: ti, getOwnPropertyDescriptor: ii, getOwnPropertyNames: ni, getOwnPropertySymbols: ri, getPrototypeOf: oi } = Object, ve = globalThis, Qe = ve.trustedTypes, si = Qe ? Qe.emptyScript : "", ai = ve.reactiveElementPolyfillSupport, Z = (e, t) => e, de = { toAttribute(e, t) { +const { is: ii, defineProperty: ni, getOwnPropertyDescriptor: ri, getOwnPropertyNames: oi, getOwnPropertySymbols: si, getPrototypeOf: ai } = Object, ve = globalThis, Qe = ve.trustedTypes, di = Qe ? Qe.emptyScript : "", ci = ve.reactiveElementPolyfillSupport, Z = (e, t) => e, de = { toAttribute(e, t) { switch (t) { case Boolean: - e = e ? si : null; + e = e ? di : null; break; case Object: case Array: @@ -64,7 +64,7 @@ const { is: ei, defineProperty: ti, getOwnPropertyDescriptor: ii, getOwnProperty } } return i; -} }, Me = (e, t) => !ei(e, t), Ye = { attribute: !0, type: String, converter: de, reflect: !1, useDefault: !1, hasChanged: Me }; +} }, Me = (e, t) => !ii(e, t), Ye = { attribute: !0, type: String, converter: de, reflect: !1, useDefault: !1, hasChanged: Me }; Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), ve.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); let N = class extends HTMLElement { static addInitializer(t) { @@ -76,11 +76,11 @@ let N = class extends HTMLElement { static createProperty(t, i = Ye) { if (i.state && (i.attribute = !1), this._$Ei(), this.prototype.hasOwnProperty(t) && ((i = Object.create(i)).wrapped = !0), this.elementProperties.set(t, i), !i.noAccessor) { const n = /* @__PURE__ */ Symbol(), r = this.getPropertyDescriptor(t, n, i); - r !== void 0 && ti(this.prototype, t, r); + r !== void 0 && ni(this.prototype, t, r); } } static getPropertyDescriptor(t, i, n) { - const { get: r, set: o } = ii(this.prototype, t) ?? { get() { + const { get: r, set: o } = ri(this.prototype, t) ?? { get() { return this[i]; }, set(a) { this[i] = a; @@ -95,13 +95,13 @@ let N = class extends HTMLElement { } static _$Ei() { if (this.hasOwnProperty(Z("elementProperties"))) return; - const t = oi(this); + const t = ai(this); t.finalize(), t.l !== void 0 && (this.l = [...t.l]), this.elementProperties = new Map(t.elementProperties); } static finalize() { if (this.hasOwnProperty(Z("finalized"))) return; if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(Z("properties"))) { - const i = this.properties, n = [...ni(i), ...ri(i)]; + const i = this.properties, n = [...oi(i), ...si(i)]; for (const r of n) this.createProperty(r, i[r]); } const t = this[Symbol.metadata]; @@ -147,7 +147,7 @@ let N = class extends HTMLElement { } createRenderRoot() { const t = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); - return Xt(t, this.constructor.elementStyles), t; + return ti(t, this.constructor.elementStyles), t; } connectedCallback() { this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(!0), this._$EO?.forEach((t) => t.hostConnected?.()); @@ -247,43 +247,43 @@ let N = class extends HTMLElement { firstUpdated(t) { } }; -N.elementStyles = [], N.shadowRootOptions = { mode: "open" }, N[Z("elementProperties")] = /* @__PURE__ */ new Map(), N[Z("finalized")] = /* @__PURE__ */ new Map(), ai?.({ ReactiveElement: N }), (ve.reactiveElementVersions ??= []).push("2.1.2"); -const De = globalThis, Je = (e) => e, ce = De.trustedTypes, Xe = ce ? ce.createPolicy("lit-html", { createHTML: (e) => e }) : void 0, At = "$lit$", L = `lit$${Math.random().toFixed(9).slice(2)}$`, St = "?" + L, di = `<${St}>`, O = document, G = () => O.createComment(""), Q = (e) => e === null || typeof e != "object" && typeof e != "function", Ie = Array.isArray, ci = (e) => Ie(e) || typeof e?.[Symbol.iterator] == "function", be = `[ +N.elementStyles = [], N.shadowRootOptions = { mode: "open" }, N[Z("elementProperties")] = /* @__PURE__ */ new Map(), N[Z("finalized")] = /* @__PURE__ */ new Map(), ci?.({ ReactiveElement: N }), (ve.reactiveElementVersions ??= []).push("2.1.2"); +const De = globalThis, Je = (e) => e, ce = De.trustedTypes, Xe = ce ? ce.createPolicy("lit-html", { createHTML: (e) => e }) : void 0, Tt = "$lit$", L = `lit$${Math.random().toFixed(9).slice(2)}$`, Rt = "?" + L, li = `<${Rt}>`, O = document, G = () => O.createComment(""), Q = (e) => e === null || typeof e != "object" && typeof e != "function", Ie = Array.isArray, hi = (e) => Ie(e) || typeof e?.[Symbol.iterator] == "function", be = `[ \f\r]`, j = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, et = /-->/g, tt = />/g, V = RegExp(`>|${be}(?:([^\\s"'>=/]+)(${be}*=${be}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`, "g"), it = /'/g, nt = /"/g, Tt = /^(?:script|style|textarea|title)$/i, li = (e) => (t, ...i) => ({ _$litType$: e, strings: t, values: i }), h = li(1), M = /* @__PURE__ */ Symbol.for("lit-noChange"), l = /* @__PURE__ */ Symbol.for("lit-nothing"), rt = /* @__PURE__ */ new WeakMap(), U = O.createTreeWalker(O, 129); -function Rt(e, t) { +\f\r"'\`<>=]|("|')|))|$)`, "g"), it = /'/g, nt = /"/g, $t = /^(?:script|style|textarea|title)$/i, ui = (e) => (t, ...i) => ({ _$litType$: e, strings: t, values: i }), h = ui(1), M = /* @__PURE__ */ Symbol.for("lit-noChange"), l = /* @__PURE__ */ Symbol.for("lit-nothing"), rt = /* @__PURE__ */ new WeakMap(), U = O.createTreeWalker(O, 129); +function Ct(e, t) { if (!Ie(e) || !e.hasOwnProperty("raw")) throw Error("invalid template strings array"); return Xe !== void 0 ? Xe.createHTML(t) : t; } -const hi = (e, t) => { +const pi = (e, t) => { const i = e.length - 1, n = []; let r, o = t === 2 ? "" : t === 3 ? "" : "", a = j; for (let d = 0; d < i; d++) { const c = e[d]; let u, p, v = -1, y = 0; - for (; y < c.length && (a.lastIndex = y, p = a.exec(c), p !== null); ) y = a.lastIndex, a === j ? p[1] === "!--" ? a = et : p[1] !== void 0 ? a = tt : p[2] !== void 0 ? (Tt.test(p[2]) && (r = RegExp("" ? (a = r ?? j, v = -1) : p[1] === void 0 ? v = -2 : (v = a.lastIndex - p[2].length, u = p[1], a = p[3] === void 0 ? V : p[3] === '"' ? nt : it) : a === nt || a === it ? a = V : a === et || a === tt ? a = j : (a = V, r = void 0); + for (; y < c.length && (a.lastIndex = y, p = a.exec(c), p !== null); ) y = a.lastIndex, a === j ? p[1] === "!--" ? a = et : p[1] !== void 0 ? a = tt : p[2] !== void 0 ? ($t.test(p[2]) && (r = RegExp("" ? (a = r ?? j, v = -1) : p[1] === void 0 ? v = -2 : (v = a.lastIndex - p[2].length, u = p[1], a = p[3] === void 0 ? V : p[3] === '"' ? nt : it) : a === nt || a === it ? a = V : a === et || a === tt ? a = j : (a = V, r = void 0); const A = a === V && e[d + 1].startsWith("/>") ? " " : ""; - o += a === j ? c + di : v >= 0 ? (n.push(u), c.slice(0, v) + At + c.slice(v) + L + A) : c + L + (v === -2 ? d : A); + o += a === j ? c + li : v >= 0 ? (n.push(u), c.slice(0, v) + Tt + c.slice(v) + L + A) : c + L + (v === -2 ? d : A); } - return [Rt(e, o + (e[i] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; + return [Ct(e, o + (e[i] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; }; class Y { constructor({ strings: t, _$litType$: i }, n) { let r; this.parts = []; let o = 0, a = 0; - const d = t.length - 1, c = this.parts, [u, p] = hi(t, i); + const d = t.length - 1, c = this.parts, [u, p] = pi(t, i); if (this.el = Y.createElement(u, n), U.currentNode = this.el.content, i === 2 || i === 3) { const v = this.el.content.firstChild; v.replaceWith(...v.childNodes); } for (; (r = U.nextNode()) !== null && c.length < d; ) { if (r.nodeType === 1) { - if (r.hasAttributes()) for (const v of r.getAttributeNames()) if (v.endsWith(At)) { + if (r.hasAttributes()) for (const v of r.getAttributeNames()) if (v.endsWith(Tt)) { const y = p[a++], A = r.getAttribute(v).split(L), F = /([.?@])?(.*)/.exec(y); - c.push({ type: 1, index: o, name: F[2], strings: A, ctor: F[1] === "." ? pi : F[1] === "?" ? vi : F[1] === "@" ? gi : ge }), r.removeAttribute(v); + c.push({ type: 1, index: o, name: F[2], strings: A, ctor: F[1] === "." ? gi : F[1] === "?" ? fi : F[1] === "@" ? mi : ge }), r.removeAttribute(v); } else v.startsWith(L) && (c.push({ type: 6, index: o }), r.removeAttribute(v)); - if (Tt.test(r.tagName)) { + if ($t.test(r.tagName)) { const v = r.textContent.split(L), y = v.length - 1; if (y > 0) { r.textContent = ce ? ce.emptyScript : ""; @@ -291,7 +291,7 @@ class Y { r.append(v[y], G()); } } - } else if (r.nodeType === 8) if (r.data === St) c.push({ type: 2, index: o }); + } else if (r.nodeType === 8) if (r.data === Rt) c.push({ type: 2, index: o }); else { let v = -1; for (; (v = r.data.indexOf(L, v + 1)) !== -1; ) c.push({ type: 7, index: o }), v += L.length - 1; @@ -310,7 +310,7 @@ function B(e, t, i = e, n) { const o = Q(t) ? void 0 : t._$litDirective$; return r?.constructor !== o && (r?._$AO?.(!1), o === void 0 ? r = void 0 : (r = new o(e), r._$AT(e, i, n)), n !== void 0 ? (i._$Co ??= [])[n] = r : i._$Cl = r), r !== void 0 && (t = B(e, r._$AS(e, t.values), r, n)), t; } -class ui { +class vi { constructor(t, i) { this._$AV = [], this._$AN = void 0, this._$AD = t, this._$AM = i; } @@ -327,7 +327,7 @@ class ui { for (; c !== void 0; ) { if (a === c.index) { let u; - c.type === 2 ? u = new ee(o, o.nextSibling, this, t) : c.type === 1 ? u = new c.ctor(o, c.name, c.strings, this, t) : c.type === 6 && (u = new fi(o, this, t)), this._$AV.push(u), c = n[++d]; + c.type === 2 ? u = new ee(o, o.nextSibling, this, t) : c.type === 1 ? u = new c.ctor(o, c.name, c.strings, this, t) : c.type === 6 && (u = new _i(o, this, t)), this._$AV.push(u), c = n[++d]; } a !== c?.index && (o = U.nextNode(), a++); } @@ -357,7 +357,7 @@ class ee { return this._$AB; } _$AI(t, i = this) { - t = B(this, t, i), Q(t) ? t === l || t == null || t === "" ? (this._$AH !== l && this._$AR(), this._$AH = l) : t !== this._$AH && t !== M && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : ci(t) ? this.k(t) : this._(t); + t = B(this, t, i), Q(t) ? t === l || t == null || t === "" ? (this._$AH !== l && this._$AR(), this._$AH = l) : t !== this._$AH && t !== M && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : hi(t) ? this.k(t) : this._(t); } O(t) { return this._$AA.parentNode.insertBefore(t, this._$AB); @@ -369,10 +369,10 @@ class ee { this._$AH !== l && Q(this._$AH) ? this._$AA.nextSibling.data = t : this.T(O.createTextNode(t)), this._$AH = t; } $(t) { - const { values: i, _$litType$: n } = t, r = typeof n == "number" ? this._$AC(t) : (n.el === void 0 && (n.el = Y.createElement(Rt(n.h, n.h[0]), this.options)), n); + const { values: i, _$litType$: n } = t, r = typeof n == "number" ? this._$AC(t) : (n.el === void 0 && (n.el = Y.createElement(Ct(n.h, n.h[0]), this.options)), n); if (this._$AH?._$AD === r) this._$AH.p(i); else { - const o = new ui(r, this), a = o.u(this.options); + const o = new vi(r, this), a = o.u(this.options); o.p(i), this.T(a), this._$AH = o; } } @@ -422,7 +422,7 @@ class ge { t === l ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? ""); } } -class pi extends ge { +class gi extends ge { constructor() { super(...arguments), this.type = 3; } @@ -430,7 +430,7 @@ class pi extends ge { this.element[this.name] = t === l ? void 0 : t; } } -class vi extends ge { +class fi extends ge { constructor() { super(...arguments), this.type = 4; } @@ -438,7 +438,7 @@ class vi extends ge { this.element.toggleAttribute(this.name, !!t && t !== l); } } -class gi extends ge { +class mi extends ge { constructor(t, i, n, r, o) { super(t, i, n, r, o), this.type = 5; } @@ -451,7 +451,7 @@ class gi extends ge { typeof this._$AH == "function" ? this._$AH.call(this.options?.host ?? this.element, t) : this._$AH.handleEvent(t); } } -class fi { +class _i { constructor(t, i, n) { this.element = t, this.type = 6, this._$AN = void 0, this._$AM = i, this.options = n; } @@ -462,9 +462,9 @@ class fi { B(this, t); } } -const mi = De.litHtmlPolyfillSupport; -mi?.(Y, ee), (De.litHtmlVersions ??= []).push("3.3.3"); -const _i = (e, t, i) => { +const bi = De.litHtmlPolyfillSupport; +bi?.(Y, ee), (De.litHtmlVersions ??= []).push("3.3.3"); +const wi = (e, t, i) => { const n = i?.renderBefore ?? t; let r = n._$litPart$; if (r === void 0) { @@ -484,7 +484,7 @@ let C = class extends N { } update(t) { const i = this.render(); - this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = _i(i, this.renderRoot, this.renderOptions); + this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = wi(i, this.renderRoot, this.renderOptions); } connectedCallback() { super.connectedCallback(), this._$Do?.setConnected(!0); @@ -497,10 +497,10 @@ let C = class extends N { } }; C._$litElement$ = !0, C.finalized = !0, Ve.litElementHydrateSupport?.({ LitElement: C }); -const bi = Ve.litElementPolyfillSupport; -bi?.({ LitElement: C }); +const yi = Ve.litElementPolyfillSupport; +yi?.({ LitElement: C }); (Ve.litElementVersions ??= []).push("4.2.2"); -const $t = { ATTRIBUTE: 1 }, Ue = (e) => (...t) => ({ _$litDirective$: e, values: t }); +const Et = { ATTRIBUTE: 1 }, Ue = (e) => (...t) => ({ _$litDirective$: e, values: t }); let He = class { constructor(t) { } @@ -517,9 +517,9 @@ let He = class { return this.render(...i); } }; -const Ct = "important", wi = " !" + Ct, ke = Ue(class extends He { +const xt = "important", ki = " !" + xt, ke = Ue(class extends He { constructor(e) { - if (super(e), e.type !== $t.ATTRIBUTE || e.name !== "style" || e.strings?.length > 2) throw Error("The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute."); + if (super(e), e.type !== Et.ATTRIBUTE || e.name !== "style" || e.strings?.length > 2) throw Error("The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute."); } render(e) { return Object.keys(e).reduce((t, i) => { @@ -535,8 +535,8 @@ const Ct = "important", wi = " !" + Ct, ke = Ue(class extends He { const r = t[n]; if (r != null) { this.ft.add(n); - const o = typeof r == "string" && r.endsWith(wi); - n.includes("-") || o ? i.setProperty(n, o ? r.slice(0, -11) : r, o ? Ct : "") : i[n] = r; + const o = typeof r == "string" && r.endsWith(ki); + n.includes("-") || o ? i.setProperty(n, o ? r.slice(0, -11) : r, o ? xt : "") : i[n] = r; } } return M; @@ -547,7 +547,7 @@ const W = (e) => (t, i) => { customElements.define(e, t); }) : customElements.define(e, t); }; -const yi = { attribute: !0, type: String, converter: de, reflect: !1, hasChanged: Me }, ki = (e = yi, t, i) => { +const Ai = { attribute: !0, type: String, converter: de, reflect: !1, hasChanged: Me }, Si = (e = Ai, t, i) => { const { kind: n, metadata: r } = i; let o = globalThis.litPropertyMetadata.get(r); if (o === void 0 && globalThis.litPropertyMetadata.set(r, o = /* @__PURE__ */ new Map()), n === "setter" && ((e = Object.create(e)).wrapped = !0), o.set(i.name, e), n === "accessor") { @@ -569,7 +569,7 @@ const yi = { attribute: !0, type: String, converter: de, reflect: !1, hasChanged throw Error("Unsupported decorator location: " + n); }; function _(e) { - return (t, i) => typeof i == "object" ? ki(e, t, i) : ((n, r, o) => { + return (t, i) => typeof i == "object" ? Si(e, t, i) : ((n, r, o) => { const a = r.hasOwnProperty(o); return r.constructor.createProperty(o, n), a ? Object.getOwnPropertyDescriptor(r, o) : void 0; })(e, t, i); @@ -577,7 +577,7 @@ function _(e) { function g(e) { return _({ ...e, state: !0, attribute: !1 }); } -const Ai = ` +const Ti = ` @@ -608,7 +608,7 @@ const Ai = ` -`, Si = { +`, Ri = { "common.camera": "Kamera", "common.close": "Schließen", "common.last_recording": "Letzte Aufnahme", @@ -817,7 +817,7 @@ const Ai = ` "u" ? void 0 : document.documentElement.lang, i = typeof navigator > "u" ? void 0 : navigator.language; return e?.language || e?.locale?.language || t || i || "en"; } -function Et(e) { - return Ri(e).trim().toLowerCase().split(/[-_]/)[0] === "de" ? "de" : "en"; +function Pt(e) { + return Ci(e).trim().toLowerCase().split(/[-_]/)[0] === "de" ? "de" : "en"; } function s(e, t, i = {}) { - return (ot[Et(e)][t] ?? ot.en[t]).replace( + return (ot[Pt(e)][t] ?? ot.en[t]).replace( /\{([a-z_]+)\}/gi, (r, o) => o in i ? String(i[o]) : r ); } -function xt(e, t, i) { +function Lt(e, t, i) { return e?.localize?.(t) || s(e, i); } -function $i(e) { +function Ei(e) { return e?.platform === "ring" ? "official_ring" : e?.platform === "mqtt" ? "mqtt" : "other"; } function z(e, t, i) { @@ -1056,79 +1056,79 @@ function z(e, t, i) { entityId: i, entity: e.states[i], registry: n, - provider: $i(n), + provider: Ei(n), deviceId: n?.device_id ?? void 0 }; } -function Pt(e, t) { +function Mt(e, t) { const i = e.entities?.[t]?.device_id; return i ? Object.values(e.entities ?? {}).filter((n) => n.device_id === i && n.disabled_by == null && !!e.states[n.entity_id]).map((n) => n.entity_id).sort() : []; } function le(e, t, i) { - const n = Pt(e, t).filter((r) => { + const n = Mt(e, t).filter((r) => { const o = e.states[r], a = e.entities?.[r]; return !!(o && a && i(r, o, a)); }); return n.length === 1 ? n[0] : void 0; } -const Ci = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:?\d{2})?$/i, Ei = /^-?\d+(?:\.\d+)?$/, xi = 1e11, Pi = [ +const xi = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:?\d{2})?$/i, Pi = /^-?\d+(?:\.\d+)?$/, Li = 1e11, Mi = [ "lastDingTime", "lastMotionTime", "lastDing", "lastMotion" -], Lt = /* @__PURE__ */ new Set(["unknown", "unavailable"]); -function Li(e) { +], Dt = /* @__PURE__ */ new Set(["unknown", "unavailable"]); +function Di(e) { const t = Number(e[1]), i = Number(e[2]), n = Number(e[3]), r = Number(e[4]), o = Number(e[5]), a = Number(e[6] ?? 0), d = new Date(Date.UTC(t, i, 0)).getUTCDate(); return i >= 1 && i <= 12 && n >= 1 && n <= d && r >= 0 && r <= 23 && o >= 0 && o <= 59 && a >= 0 && a <= 59; } -function Mt(e) { +function It(e) { if (typeof e != "string" && typeof e != "number") return; const t = String(e).trim(); if (!t || ["unknown", "unavailable", "none", "null"].includes(t.toLowerCase())) return; - if (Ei.test(t)) { + if (Pi.test(t)) { const o = Number(t); if (!Number.isFinite(o)) return; - const a = Math.abs(o) < xi ? o * 1e3 : o; + const a = Math.abs(o) < Li ? o * 1e3 : o; return Number.isFinite(new Date(a).getTime()) ? a : void 0; } - const i = t.match(Ci); - if (!i || !Li(i)) return; + const i = t.match(xi); + if (!i || !Di(i)) return; const n = t.includes(" ") ? t.replace(" ", "T") : t, r = Date.parse(n); return Number.isFinite(r) ? r : void 0; } -function Mi(e) { - return Mt(e?.state); +function Ii(e) { + return It(e?.state); } function oe(e) { const t = e.filter((i) => i !== void 0); return t.length > 0 ? Math.max(...t) : void 0; } function st(e) { - if (!(!e || Lt.has(e.state))) + if (!(!e || Dt.has(e.state))) return oe( - Pi.map( - (t) => Mt(e.attributes[t]) + Mi.map( + (t) => It(e.attributes[t]) ) ); } function H(e, t) { - const i = e.states[t], n = i && !Lt.has(i.state), r = n ? [Mi(i)] : []; + const i = e.states[t], n = i && !Dt.has(i.state), r = n ? [Ii(i)] : []; if (!t.startsWith("binary_sensor.") || (n && r.push(st(i)), z(e, "activity", t).provider !== "mqtt")) return oe(r); - for (const a of Pt(e, t)) + for (const a of Mt(e, t)) a === t || !a.startsWith("binary_sensor.") || z(e, "activity", a).provider !== "mqtt" || r.push(st(e.states[a])); return oe(r); } -function Di(e) { +function Vi(e) { const t = Math.abs(e); return t < 60 ? { unit: "second", seconds: 1 } : t < 3600 ? { unit: "minute", seconds: 60 } : t < 86400 ? { unit: "hour", seconds: 3600 } : t < 2629746 ? { unit: "day", seconds: 86400 } : t < 31556952 ? { unit: "month", seconds: 2629746 } : { unit: "year", seconds: 31556952 }; } -function Ii(e, t) { +function Ui(e, t) { const i = Math.trunc(e / t); return Object.is(i, -0) ? 0 : i; } -function Dt(e, t, i = Date.now()) { - const n = Et(e), r = (t - i) / 1e3, { unit: o, seconds: a } = Di(r), d = Ii(r, a), c = new Intl.RelativeTimeFormat(n, { +function Vt(e, t, i = Date.now()) { + const n = Pt(e), r = (t - i) / 1e3, { unit: o, seconds: a } = Vi(r), d = Ui(r, a), c = new Intl.RelativeTimeFormat(n, { numeric: "auto", style: "short" }).format(d, o), u = n === "en" ? c.replace(/\b(sec|min|hr)\./g, "$1") : c, p = new Intl.RelativeTimeFormat(n, { @@ -1146,12 +1146,12 @@ function Dt(e, t, i = Date.now()) { title: s(e, "activity.title", { time: v }) }; } -var Vi = Object.defineProperty, Ui = Object.getOwnPropertyDescriptor, Oe = (e, t, i, n) => { - for (var r = n > 1 ? void 0 : n ? Ui(t, i) : t, o = e.length - 1, a; o >= 0; o--) +var Hi = Object.defineProperty, Oi = Object.getOwnPropertyDescriptor, Oe = (e, t, i, n) => { + for (var r = n > 1 ? void 0 : n ? Oi(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 && Vi(t, i, r), r; + return n && r && Hi(t, i, r), r; }; -const Hi = 3e4; +const zi = 3e4; let J = class extends C { disconnectedCallback() { this.clearRefreshTimer(), super.disconnectedCallback(); @@ -1159,7 +1159,7 @@ let J = class extends C { render() { const e = this.hass && this.entityId ? H(this.hass, this.entityId) : void 0; if (e === void 0) return l; - const t = Dt(this.hass, e); + const t = Vt(this.hass, e); return h` ${t.relative} @@ -1174,7 +1174,7 @@ let J = class extends C { composed: !0 }) ); - }, Hi)); + }, zi)); } clearRefreshTimer() { this.refreshTimer !== void 0 && (window.clearTimeout(this.refreshTimer), this.refreshTimer = void 0); @@ -1211,24 +1211,24 @@ Oe([ J = Oe([ W("ring-view-activity-time") ], J); -const Oi = [ +const Fi = [ "video_url", "recordingUrl", "recording_url" -], zi = 3e4; -function Fi(e) { +], qi = 3e4; +function Ni(e) { const t = e.trim().toLowerCase(); return t === "" || t.includes("") || t.includes(""); } function fe(e) { if (e) - for (const t of Oi) { + for (const t of Fi) { const i = e.attributes[t]; - if (!(typeof i != "string" || Fi(i))) + if (!(typeof i != "string" || Ni(i))) return i.trim(); } } -function qi(e) { +function Bi(e) { let t; try { t = new URL(e, "http://homeassistant.local"); @@ -1256,8 +1256,8 @@ function qi(e) { function we(e, t = Date.now()) { const i = fe(e); if (!i) return !1; - const n = qi(i); - return n === void 0 || n - t > zi; + const n = Bi(i); + return n === void 0 || n - t > qi; } function Ae(e) { if (!e) return; @@ -1282,26 +1282,26 @@ function at(e, t) { const i = e.states[t]?.state; return i !== void 0 && i !== "unavailable" && i !== "unknown"; } -function It(e, t) { +function Ut(e, t) { return t.recording_entity.startsWith("camera.") ? t.recording_entity : at(e, t.snapshot_entity) ? t.snapshot_entity : (at(e, t.live_entity), t.live_entity); } -const Ni = 2, Bi = 1; +const Ki = 2, Wi = 1; function w(e) { return !e || e.state === "unavailable" || e.state === "unknown"; } -function Vt(e) { - return (Number(e?.attributes.supported_features ?? 0) & Ni) !== 0; +function Ht(e) { + return (Number(e?.attributes.supported_features ?? 0) & Ki) !== 0; } function Te(e, t) { - return z(e, "live", t).provider === "official_ring" && Vt(e.states[t]); + return z(e, "live", t).provider === "official_ring" && Ht(e.states[t]); } function se(e) { - return (Number(e?.attributes.supported_features ?? 0) & Bi) !== 0; + return (Number(e?.attributes.supported_features ?? 0) & Wi) !== 0; } -function Ki(e) { +function ji(e) { return !!(fe(e) || e?.attributes.entity_picture); } -function Wi(e, t) { +function Zi(e, t) { if (!e || !t) return []; const i = e.states[t.recording_entity], n = e.states[t.live_entity], r = []; if (w(i) ? r.push({ @@ -1309,7 +1309,7 @@ function Wi(e, t) { message: s(e, "warning.unavailable", { name: T(i, t.recording_entity) }) - }) : Ki(i) || r.push({ + }) : ji(i) || r.push({ kind: "recording", message: s(e, "warning.recording_media") }), w(n) ? r.push({ @@ -1317,7 +1317,7 @@ function Wi(e, t) { message: s(e, "warning.unavailable", { name: T(n, t.live_entity) }) - }) : Vt(n) ? t.two_way_audio && !Te(e, t.live_entity) && r.push({ + }) : Ht(n) ? t.two_way_audio && !Te(e, t.live_entity) && r.push({ kind: "talkback", message: s(e, "warning.talkback_unsupported") }) : r.push({ @@ -1388,7 +1388,7 @@ function Wi(e, t) { function T(e, t) { return e?.attributes.friendly_name || t; } -const ji = [ +const Gi = [ "timestamp", "capture_timestamp", "captured_at", @@ -1396,7 +1396,7 @@ const ji = [ "last_recording_at", "recording_timestamp" ]; -function Zi(e) { +function Qi(e) { if (typeof e == "number") return !Number.isFinite(e) || e <= 0 ? void 0 : e < 1e12 ? e * 1e3 : e; if (typeof e != "string" || e.trim() === "") return; @@ -1408,18 +1408,18 @@ function Zi(e) { } function he(e) { if (e) - for (const t of ji) { - const i = Zi(e.attributes[t]); + for (const t of Gi) { + const i = Qi(e.attributes[t]); if (i !== void 0) return i; } } -function Gi(e) { +function Yi(e) { return Ae(e); } function x(e, t) { - return It(e, t); + return Ut(e, t); } -function Qi(e, t, i) { +function Ji(e, t, i) { const n = t.recording_entity, r = t.snapshot_entity; if (!r) return x(e, t); const o = e.states[n], a = e.states[r], d = !w(o), c = !w(a); @@ -1435,26 +1435,26 @@ function Qi(e, t, i) { } return t.preview_fallback === "snapshot" ? r : x(e, t); } -function Yi(e, t, i, n) { +function Xi(e, t, i, n) { switch (t.preview_source) { case "live": return t.live_entity; case "snapshot": return t.snapshot_entity ?? x(e, t); case "newest": - return Qi(e, t, n); + return Ji(e, t, n); case "default": return i === "live" ? t.live_entity : x(e, t); default: return x(e, t); } } -const Ji = "/media/ring-view"; +const en = "/media/ring-view"; function me(e) { const t = e.trim(); return t.length > 1 ? t.replace(/\/+$/, "") : t; } -function Xi(e) { +function tn(e) { if (typeof e != "string" || e.trim() === "") return "required"; const t = me(e); if (!t.startsWith("/")) return "absolute"; @@ -1462,20 +1462,20 @@ function Xi(e) { if (/[\u0000-\u001f\u007f]/.test(t) || t.includes("{{") || t.includes("}}") || t.split("/").some((i) => i === "." || i === "..")) return "unsafe"; } -function en(e) { +function nn(e) { const t = me(e); if (t === "/config/www" || t.startsWith("/config/www/")) return "public"; if (!(t === "/media" || t.startsWith("/media/"))) return "custom"; } -function tn(e, t) { +function rn(e, t) { return t.snapshot_entity && !w(e.states[t.snapshot_entity]) ? t.snapshot_entity : w(e.states[t.live_entity]) ? void 0 : t.live_entity; } function dt(e, t) { return he(e.states[t]); } -function nn(e, t) { +function on(e, t) { if (z(e, "snapshot", t).provider !== "mqtt") return; const i = le( @@ -1489,7 +1489,7 @@ function nn(e, t) { (n, r, o) => n.startsWith("button.") && o.platform === "mqtt" ); } -function rn(e, t) { +function sn(e, t) { if (z(e, "snapshot", t).provider !== "mqtt") return; const i = le( @@ -1503,17 +1503,17 @@ function rn(e, t) { (n, r, o) => n.startsWith("camera.") && o.platform === "mqtt" ); } -function on(e, t) { +function an(e, t) { return z(e, "snapshot", t).provider === "mqtt"; } -function sn(e, t) { +function dn(e, t) { const i = e.states[t.live_entity]; return (t.name || T(i, t.live_entity.split(".", 2)[1] || "ring-camera")).normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "") || "ring-camera"; } function q(e, t) { return e.find((i) => i.type === t)?.value ?? "00"; } -function an(e, t) { +function cn(e, t) { const i = Number.isFinite(e.getTime()) ? e : /* @__PURE__ */ new Date(0), n = { timeZone: t, year: "numeric", @@ -1536,11 +1536,11 @@ function an(e, t) { `${q(r, "hour")}-${q(r, "minute")}-${q(r, "second")}-${o}` ].join("_"); } -function dn(e, t, i = /* @__PURE__ */ new Date()) { - const n = me(t.snapshot_directory), r = an(i, e.config?.time_zone); - return `${n}/${sn(e, t)}_${r}.jpg`; +function ln(e, t, i = /* @__PURE__ */ new Date()) { + const n = me(t.snapshot_directory), r = cn(i, e.config?.time_zone); + return `${n}/${dn(e, t)}_${r}.jpg`; } -const ze = "custom:ring-view", Re = "ring-view", cn = "Ring View", b = { +const ze = "custom:ring-view", Re = "ring-view", hn = "Ring View", b = { default_mode: "last_recording", remember_last_mode: !1, autoplay_recording: !0, @@ -1557,19 +1557,19 @@ const ze = "custom:ring-view", Re = "ring-view", cn = "Ring View", b = { preview_source: "last_recording", preview_fallback: "last_recording", show_snapshot_button: !1, - snapshot_directory: Ji, + snapshot_directory: en, aspect_ratio: "16:9", fit_mode: "cover" -}, ln = /* @__PURE__ */ new Set(["last_recording", "live"]), hn = /* @__PURE__ */ new Set([ +}, un = /* @__PURE__ */ new Set(["last_recording", "live"]), pn = /* @__PURE__ */ new Set([ "last_recording", "live", "default", "snapshot", "newest" -]), un = /* @__PURE__ */ new Set(["last_recording", "snapshot"]), pn = /* @__PURE__ */ new Set(["auto", "16:9", "4:3", "1:1"]), vn = /* @__PURE__ */ new Set(["cover", "contain"]), gn = /* @__PURE__ */ new Set(["unlock", "open"]), fn = /* @__PURE__ */ new Set(["live_only", "all_views"]), mn = /* @__PURE__ */ new Set([ +]), vn = /* @__PURE__ */ new Set(["last_recording", "snapshot"]), gn = /* @__PURE__ */ new Set(["auto", "16:9", "4:3", "1:1"]), fn = /* @__PURE__ */ new Set(["cover", "contain"]), mn = /* @__PURE__ */ new Set(["unlock", "open"]), _n = /* @__PURE__ */ new Set(["live_only", "all_views"]), bn = /* @__PURE__ */ new Set([ "viewer_only", "dashboard_and_viewer" -]), _n = /* @__PURE__ */ new Set(["open_viewer", "interactive"]), bn = /* @__PURE__ */ new Set(["on_demand", "last_recording", "live"]), wn = ["event.", "binary_sensor."]; +]), wn = /* @__PURE__ */ new Set(["open_viewer", "interactive"]), yn = /* @__PURE__ */ new Set(["on_demand", "last_recording", "live"]), kn = ["event.", "binary_sensor."]; function ct(e, t) { if (typeof e != "string" || !e.startsWith("camera.")) throw new Error( @@ -1578,51 +1578,51 @@ function ct(e, t) { }) ); } -function yn(e) { +function An(e) { if (typeof e != "string" || !e.startsWith("camera.") && !e.startsWith("select.")) throw new Error(s(void 0, "config.recording_source_required")); } -function kn(e) { +function Sn(e) { if (!e || typeof e != "object") throw new Error(s(void 0, "config.invalid")); - if (yn(e.recording_entity), ct(e.live_entity, "config.live_entity"), e.snapshot_entity !== void 0 && e.snapshot_entity !== "" && ct(e.snapshot_entity, "config.snapshot_entity"), e.last_activity_entity !== void 0 && e.last_activity_entity !== "" && (typeof e.last_activity_entity != "string" || !/^[a-z0-9_]+\.[a-z0-9_]+$/i.test(e.last_activity_entity))) + if (An(e.recording_entity), ct(e.live_entity, "config.live_entity"), e.snapshot_entity !== void 0 && e.snapshot_entity !== "" && ct(e.snapshot_entity, "config.snapshot_entity"), e.last_activity_entity !== void 0 && e.last_activity_entity !== "" && (typeof e.last_activity_entity != "string" || !/^[a-z0-9_]+\.[a-z0-9_]+$/i.test(e.last_activity_entity))) throw new Error(s(void 0, "config.last_activity_entity")); - if (e.default_mode && !ln.has(e.default_mode)) + if (e.default_mode && !un.has(e.default_mode)) throw new Error(s(void 0, "config.default_mode")); - if (e.preview_source && !hn.has(e.preview_source)) + if (e.preview_source && !pn.has(e.preview_source)) throw new Error(s(void 0, "config.preview_source")); - if (e.preview_fallback && !un.has(e.preview_fallback)) + if (e.preview_fallback && !vn.has(e.preview_fallback)) throw new Error(s(void 0, "config.preview_fallback")); if (e.snapshot_directory !== void 0) { - const t = Xi(e.snapshot_directory); + const t = tn(e.snapshot_directory); if (t) throw new Error( s(void 0, `config.snapshot_directory_${t}`) ); } - if (e.aspect_ratio && !pn.has(e.aspect_ratio)) + if (e.aspect_ratio && !gn.has(e.aspect_ratio)) throw new Error(s(void 0, "config.aspect_ratio")); - if (e.fit_mode && !vn.has(e.fit_mode)) + if (e.fit_mode && !fn.has(e.fit_mode)) throw new Error(s(void 0, "config.fit_mode")); - if (e.doorbell_entity !== void 0 && e.doorbell_entity !== "" && (typeof e.doorbell_entity != "string" || !wn.some((t) => e.doorbell_entity.startsWith(t)))) + if (e.doorbell_entity !== void 0 && e.doorbell_entity !== "" && (typeof e.doorbell_entity != "string" || !kn.some((t) => e.doorbell_entity.startsWith(t)))) throw new Error(s(void 0, "config.doorbell_entity")); if (e.door_entity !== void 0 && e.door_entity !== "" && (typeof e.door_entity != "string" || !e.door_entity.startsWith("lock."))) throw new Error(s(void 0, "config.door_entity")); if (e.door_contact_entity !== void 0 && e.door_contact_entity !== "" && (typeof e.door_contact_entity != "string" || !e.door_contact_entity.startsWith("binary_sensor."))) throw new Error(s(void 0, "config.door_contact_entity")); - if (e.door_action && !gn.has(e.door_action)) + if (e.door_action && !mn.has(e.door_action)) throw new Error(s(void 0, "config.door_action")); - if (e.door_control_visibility && !fn.has(e.door_control_visibility)) + if (e.door_control_visibility && !_n.has(e.door_control_visibility)) throw new Error(s(void 0, "config.door_control_visibility")); - if (e.door_control_location && !mn.has(e.door_control_location)) + if (e.door_control_location && !bn.has(e.door_control_location)) throw new Error(s(void 0, "config.door_control_location")); - if (e.dashboard_behavior && !_n.has(e.dashboard_behavior)) + if (e.dashboard_behavior && !wn.has(e.dashboard_behavior)) throw new Error(s(void 0, "config.dashboard_behavior")); - if (e.dashboard_start && !bn.has(e.dashboard_start)) + if (e.dashboard_start && !yn.has(e.dashboard_start)) throw new Error(s(void 0, "config.dashboard_start")); } function $e(e) { - return kn(e), { + return Sn(e), { type: e.type ?? ze, recording_entity: e.recording_entity, live_entity: e.live_entity, @@ -1656,7 +1656,7 @@ function $e(e) { grid_options: e.grid_options }; } -function Ut(e) { +function Ot(e) { switch (e) { case "16:9": return 16 / 9; @@ -1668,11 +1668,11 @@ function Ut(e) { return; } } -function An(e) { +function Tn(e) { return e === "auto" ? "16 / 9" : e.replace(":", " / "); } const Fe = "ring-view-live-entity", qe = "ring-view-recording-entity", Ne = "ring-view-mode", ue = "ring-view-ringing-until"; -function Ht() { +function zt() { return `${window.location.pathname}${window.location.search}${window.location.hash}`; } function Ce(e = window.location.search) { @@ -1690,11 +1690,11 @@ function Ce(e = window.location.search) { function Ee(e, t) { return e?.liveEntity === t.live_entity && e.recordingEntity === t.recording_entity; } -function Sn(e, t) { +function Rn(e, t) { const i = new URL(e, window.location.origin); return i.searchParams.set(Fe, t.liveEntity), i.searchParams.set(qe, t.recordingEntity), i.searchParams.set(Ne, t.mode), t.ringingUntil !== void 0 && t.ringingUntil > Date.now() ? i.searchParams.set(ue, String(t.ringingUntil)) : i.searchParams.delete(ue), `${i.pathname}${i.search}${i.hash}`; } -function Ot(e) { +function Ft(e) { const t = new URL(e, window.location.origin); return t.searchParams.delete(Fe), t.searchParams.delete(qe), t.searchParams.delete(Ne), t.searchParams.delete(ue), `${t.pathname}${t.search}${t.hash}`; } @@ -1716,23 +1716,23 @@ function xe(e, t) { } const Be = "ring-view-dialog", ae = 12e3; let X; -function Tn(e) { +function $n(e) { X = e; } -function Rn(e) { +function Cn(e) { X === e && (X = void 0); } -function $n(e) { +function En(e) { return !e.restored || !X?.isOpenFor(e) ? !1 : (X.adoptRestoredDialog(e), !0); } function ye(e, t) { - if ($n(t)) return; - const i = Ot(Ht()); + if (En(t)) return; + const i = Ft(zt()); xe(i), e.dispatchEvent( new CustomEvent("show-dialog", { detail: { dialogTag: Be, - dialogImport: () => Promise.resolve().then(() => cr), + dialogImport: () => Promise.resolve().then(() => lr), dialogParams: { ...t, returnUrl: i }, addHistory: !0 }, @@ -1741,19 +1741,19 @@ function ye(e, t) { }) ); } -const Cn = 9e3, lt = /* @__PURE__ */ new WeakMap(); -function zt(e, t, i) { +const xn = 9e3, lt = /* @__PURE__ */ new WeakMap(); +function qt(e, t, i) { const n = t?.attributes.entity_picture; return typeof n == "string" && n.length > 0 ? e.hassUrl(n) : e.hassUrl(`/api/camera_proxy/${encodeURIComponent(i)}`); } -async function En(e, t, i, n) { - const r = await xn(e, t), o = r.includes("?") ? "&" : "?"; +async function Pn(e, t, i, n) { + const r = await Ln(e, t), o = r.includes("?") ? "&" : "?"; return `${r}${o}width=${ht(i)}&height=${ht(n)}`; } function ht(e) { return Math.max(1, Math.ceil(Number.isFinite(e) ? e : 1)); } -async function xn(e, t) { +async function Ln(e, t) { let i = lt.get(e); i || (i = /* @__PURE__ */ new Map(), lt.set(e, i)); const n = Date.now(), r = i.get(t); @@ -1767,7 +1767,7 @@ async function xn(e, t) { return e.hassUrl(a.path); }); i.set(t, { - expiresAt: n + Cn, + expiresAt: n + xn, promise: o }); try { @@ -1779,17 +1779,17 @@ async function xn(e, t) { function ut(e) { return e === "live" ? h`` : h` `; } -function Pn(e, t) { +function Mn(e, t) { return s( t, e === "live" ? "common.live" : "common.last_recording" ); } -const Ln = K` +const Dn = K` :host { display: block; min-width: 0; @@ -1942,7 +1942,7 @@ const Ln = K` display: none; } } -`, Mn = K` +`, In = K` :host { position: fixed; inset: 0; @@ -2718,7 +2718,7 @@ const Ln = K` :host([inline]) .state-layer.with-visitor-controls .spinner { flex: 0 0 auto; } -`, Dn = K` +`, Vn = K` :host { display: block; min-width: 0; @@ -2735,29 +2735,29 @@ const Ln = K` gap: 8px; margin: 0 0 12px; } -`, In = "ring-view:mode:"; -function Ft(e) { - return `${In}${e.recording_entity}|${e.live_entity}`; +`, Un = "ring-view:mode:"; +function Nt(e) { + return `${Un}${e.recording_entity}|${e.live_entity}`; } function ie(e) { if (!e.remember_last_mode) return e.default_mode; try { - const t = window.localStorage.getItem(Ft(e)); + const t = window.localStorage.getItem(Nt(e)); return t === "live" || t === "last_recording" ? t : e.default_mode; } catch { return e.default_mode; } } -function Vn(e, t) { +function Hn(e, t) { if (e.remember_last_mode) try { - window.localStorage.setItem(Ft(e), t); + window.localStorage.setItem(Nt(e), t); } catch { } } const $ = Ue(class extends He { constructor(e) { - if (super(e), e.type !== $t.ATTRIBUTE || e.name !== "class" || e.strings?.length > 2) throw Error("`classMap()` can only be used in the `class` attribute and must be the only part in the attribute."); + if (super(e), e.type !== Et.ATTRIBUTE || e.name !== "class" || e.strings?.length > 2) throw Error("`classMap()` can only be used in the `class` attribute and must be the only part in the attribute."); } render(e) { return " " + Object.keys(e).filter((t) => e[t]).join(" ") + " "; @@ -2777,7 +2777,7 @@ const $ = Ue(class extends He { return M; } }); -const Un = {}, Hn = (e, t = Un) => e._$AH = t; +const On = {}, zn = (e, t = On) => e._$AH = t; const pt = Ue(class extends He { constructor() { super(...arguments), this.key = l; @@ -2786,16 +2786,16 @@ const pt = Ue(class extends He { return this.key = e, t; } update(e, [t, i]) { - return t !== this.key && (Hn(e), this.key = t), i; + return t !== this.key && (zn(e), this.key = t), i; } }); -var On = Object.defineProperty, zn = Object.getOwnPropertyDescriptor, P = (e, t, i, n) => { - for (var r = n > 1 ? void 0 : n ? zn(t, i) : t, o = e.length - 1, a; o >= 0; o--) +var Fn = Object.defineProperty, qn = Object.getOwnPropertyDescriptor, P = (e, t, i, n) => { + for (var r = n > 1 ? void 0 : n ? qn(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 && On(t, i, r), r; + return n && r && Fn(t, i, r), r; }; -const pe = "ha-camera-stream", Fn = 8e3, vt = "ring-view-native-layout"; -async function qn(e, t) { +const pe = "ha-camera-stream", Nn = 8e3, vt = "ring-view-native-layout"; +async function Bn(e, t) { if (customElements.get(e)) return !0; let i; try { @@ -2811,13 +2811,13 @@ async function qn(e, t) { i !== void 0 && window.clearTimeout(i); } } -async function qt() { +async function Bt() { if (customElements.get(pe)) return !0; try { await (await window.loadCardHelpers?.())?.importMoreInfoControl?.("camera"); } catch { } - return qn(pe, Fn); + return Bn(pe, Nn); } let R = class extends C { constructor() { @@ -2873,7 +2873,7 @@ let R = class extends C { e && this.attachEventListeners(e); } async loadNativeComponent() { - const e = await qt(); + const e = await Bt(); if (this.isConnected) { if (!e) { this.dispatchFailure("component-unavailable"); @@ -2990,14 +2990,14 @@ P([ R = P([ W("ring-view-native-camera-adapter") ], R); -const Nn = (e) => e ?? l; -var Bn = Object.defineProperty, Kn = Object.getOwnPropertyDescriptor, S = (e, t, i, n) => { - for (var r = n > 1 ? void 0 : n ? Kn(t, i) : t, o = e.length - 1, a; o >= 0; o--) +const Kn = (e) => e ?? l; +var Wn = Object.defineProperty, jn = Object.getOwnPropertyDescriptor, S = (e, t, i, n) => { + for (var r = n > 1 ? void 0 : n ? jn(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 && Bn(t, i, r), r; + return n && r && Wn(t, i, r), r; }; -const Wn = 3e3; -function jn(e, t) { +const Zn = 3e3; +function Gn(e, t) { if (t instanceof DOMException) { if (t.name === "NotAllowedError") return s(e, "talkback.permission_denied"); if (t.name === "NotFoundError") return s(e, "talkback.no_microphone"); @@ -3048,7 +3048,7 @@ let k = class extends C { e !== this.connectionToken || this.localStream !== i || (this.cancelActivePress(), this.localStream = void 0, this.microphoneState = "failed", this.showStatusMessage(s(this.hass, "talkback.microphone_ended"), "error")); }), await this.audioSender.replaceTrack(n), e !== this.connectionToken ? (i.getTracks().forEach((r) => r.stop()), !1) : (this.localStream?.getTracks().forEach((r) => r.stop()), this.localStream = i, this.microphoneState = "ready", this.showStatusMessage(""), !0); } catch (n) { - return i?.getTracks().forEach((r) => r.stop()), e !== this.connectionToken || (this.microphoneState = "failed", this.showStatusMessage(jn(this.hass, n), "error")), !1; + return i?.getTracks().forEach((r) => r.stop()), e !== this.connectionToken || (this.microphoneState = "failed", this.showStatusMessage(Gn(this.hass, n), "error")), !1; } })(); this.microphoneRequest = t; @@ -3134,13 +3134,13 @@ let k = class extends C { autoplay playsinline controls - poster=${Nn(this.poster)} + poster=${Kn(this.poster)} .muted=${this.actualMuted} @playing=${this.handlePlaying} > ${this.playbackBlocked ? h` ` : l} @@ -3158,7 +3158,7 @@ let k = class extends C { @keydown=${this.handleTalkKeyDown} @keyup=${this.handleTalkKeyUp} > - ${this.icon(e ? bt : wt)} + ${this.icon(e ? yt : kt)} ${r} ` : l} @@ -3335,7 +3335,7 @@ let k = class extends C { }) ), e && (this.statusMessageTimeout = window.setTimeout(() => { this.statusMessageTimeout = void 0, this.showStatusMessage(""); - }, Wn)); + }, Zn)); } dispatchTalkbackState() { const e = { @@ -3553,11 +3553,28 @@ S([ k = S([ W("ring-view-ring-webrtc-player") ], k); -const Zn = /* @__PURE__ */ new Set(["unknown", "unavailable"]); -function Nt(e, t, i) { - return !i || t === void 0 || Zn.has(i.state) ? !1 : e.startsWith("binary_sensor.") ? t === "off" && i.state === "on" : e.startsWith("event.") && t !== "unavailable" && t !== i.state && (i.attributes.event_type === void 0 || i.attributes.event_type === "ring"); +const gt = /* @__PURE__ */ new Set(["unknown", "unavailable"]); +function Kt(e, t, i) { + if (!i || !t || gt.has(i.state)) + return !1; + if (e.startsWith("binary_sensor.")) { + if (gt.has(t.state)) return !1; + if (t.state === "off" && i.state === "on") return !0; + if (t.state !== "on" || i.state !== "on") return !1; + const n = ft(t), r = ft(i); + return n !== void 0 && r !== void 0 && n !== r; + } + return e.startsWith("event.") && t.state !== "unavailable" && t.state !== i.state && (i.attributes.event_type === void 0 || i.attributes.event_type === "ring"); +} +function ft(e) { + const t = e.attributes.lastDingTime; + if (typeof t == "string" && t.trim() !== "") + return `time:${t}`; + const i = e.attributes.lastDing; + if (typeof i == "string" && i.trim() !== "" || typeof i == "number" && Number.isFinite(i)) + return `epoch:${i}`; } -class Gn { +class Qn { constructor() { this.generation = 0; } @@ -3581,19 +3598,19 @@ class Gn { this.clearTimeout(), this.generation += 1; } } -var Qn = Object.defineProperty, Yn = Object.getOwnPropertyDescriptor, m = (e, t, i, n) => { - for (var r = n > 1 ? void 0 : n ? Yn(t, i) : t, o = e.length - 1, a; o >= 0; o--) +var Yn = Object.defineProperty, Jn = Object.getOwnPropertyDescriptor, m = (e, t, i, n) => { + for (var r = n > 1 ? void 0 : n ? Jn(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 && Qn(t, i, r), r; + return n && r && Yn(t, i, r), r; }; -const Jn = 20, Xn = 2500, er = 2500, tr = 900, ir = 2e3, nr = 3e3, rr = 2e3, or = 3e3, sr = 15e3, ar = 15e3; -function dr(e) { +const Xn = 20, er = 2500, tr = 2500, ir = 900, nr = 2e3, rr = 3e3, or = 2e3, sr = 3e3, ar = 15e3, dr = 15e3; +function cr(e) { const t = e instanceof Error ? e.message.toLowerCase() : String(e).toLowerCase(); return /timeout|timed out/.test(t) ? "snapshot.timeout" : /unauthorized|forbidden|permission denied/.test(t) ? "snapshot.permission_failed" : /cannot write|can't write|no access to path|read-only|readonly/.test(t) ? "snapshot.write_failed" : /connection|websocket|service api unavailable/.test(t) ? "snapshot.connection_failed" : /no image|could not provide|not supported|snapshot unavailable/.test(t) ? "snapshot.camera_failed" : /refresh control unavailable/.test(t) ? "snapshot.refresh_unavailable" : "snapshot.failed"; } let f = class extends C { constructor() { - super(...arguments), this.open = !1, this.inline = !1, this.ringing = !1, this.mode = "last_recording", this.mediaStatus = "idle", this.session = 0, this.suspended = !1, this.recordingMuted = !1, this.recordingStarted = !0, this.recordingControlsVisible = !0, this.recordingEnded = !1, this.liveMuted = !0, this.recordingVideoFailed = !1, this.recordingPreparationActive = !1, this.retryCount = 0, this.statusAnnouncement = "", this.talkbackReady = !1, this.talkbackRequesting = !1, this.talkbackTalking = !1, this.doorActionStatus = "idle", this.snapshotActionStatus = "idle", this.inlineStarted = !0, this.lifecycle = new Gn(), this.recordingPlayback = /* @__PURE__ */ new WeakSet(), this.recordingPaused = !1, this.automaticLiveRetry = !0, this.pageUnloading = !1, this.talkKeyboardPressed = !1, this.doorKeyboardPressed = !1, this.doorActionToken = 0, this.snapshotActionToken = 0, this.recordingRefreshAttempted = !1, this.recordingRefreshToken = 0, this.inlineVisible = !1, this.inlineActive = !1, this.takeSnapshot = async () => { + super(...arguments), this.open = !1, this.inline = !1, this.ringing = !1, this.mode = "last_recording", this.mediaStatus = "idle", this.session = 0, this.suspended = !1, this.recordingMuted = !1, this.recordingStarted = !0, this.recordingControlsVisible = !0, this.recordingEnded = !1, this.liveMuted = !0, this.recordingVideoFailed = !1, this.recordingPreparationActive = !1, this.retryCount = 0, this.statusAnnouncement = "", this.talkbackReady = !1, this.talkbackRequesting = !1, this.talkbackTalking = !1, this.doorActionStatus = "idle", this.snapshotActionStatus = "idle", this.inlineStarted = !0, this.lifecycle = new Qn(), this.recordingPlayback = /* @__PURE__ */ new WeakSet(), this.recordingPaused = !1, this.automaticLiveRetry = !0, this.pageUnloading = !1, this.talkKeyboardPressed = !1, this.doorKeyboardPressed = !1, this.doorActionToken = 0, this.snapshotActionToken = 0, this.recordingRefreshAttempted = !1, this.recordingRefreshToken = 0, this.inlineVisible = !1, this.inlineActive = !1, this.takeSnapshot = async () => { const e = this.hass, t = this.config, i = this.snapshotEntityId(); if (!e || !t || !i || !this.shouldShowSnapshotAction() || this.snapshotActionStatus === "working") return; @@ -3602,8 +3619,8 @@ let f = class extends C { try { if (e.connection?.connected === !1 || !e.callService) throw new Error("Home Assistant service API unavailable"); - const r = nn(e, i); - if (on(e, i) && !r) + const r = on(e, i); + if (an(e, i) && !r) throw new Error("Ring-MQTT snapshot refresh control unavailable"); if (r) { const a = this.waitForSnapshotUpdate( @@ -3629,19 +3646,19 @@ let f = class extends C { if (await e.callService( "camera", "snapshot", - { filename: dn(e, t) }, + { filename: ln(e, t) }, { entity_id: i } ), n !== this.snapshotActionToken || !this.open) return; const o = s(this.hass, "snapshot.saved"); this.snapshotActionStatus = "success", this.snapshotFeedbackMessage = o, this.clearViewerFeedback("snapshot"), this.statusAnnouncement = o, this.snapshotFeedbackTimer = window.setTimeout(() => { n === this.snapshotActionToken && (this.snapshotFeedbackTimer = void 0, this.snapshotFeedbackMessage = void 0, this.snapshotActionStatus = "idle"); - }, rr); + }, or); } catch (r) { if (n !== this.snapshotActionToken || !this.open) return; - const o = s(this.hass, dr(r)); + const o = s(this.hass, cr(r)); this.snapshotActionStatus = "error", this.snapshotFeedbackMessage = o, this.setViewerFeedback("snapshot", o, "error"), this.statusAnnouncement = o, this.snapshotFeedbackTimer = window.setTimeout(() => { n === this.snapshotActionToken && (this.snapshotFeedbackTimer = void 0, this.snapshotFeedbackMessage = void 0, this.snapshotActionStatus = "idle"); - }, or); + }, sr); } }, this.handleTalkbackState = (e) => { e.currentTarget === this.renderRoot.querySelector("ring-view-ring-webrtc-player") && (e.detail.ready || this.cancelTalkPress(e.currentTarget), this.talkbackReady = e.detail.ready, this.talkbackRequesting = e.detail.requesting, this.talkbackTalking = e.detail.talking); @@ -3819,7 +3836,7 @@ let f = class extends C { this.adoptRestoredDialog(e), this.syncUrl(); return; } - this.open && this.finishClose(!1, !1), this.inline = !1, this.config = e.config, this.opener = e.opener, this.returnUrl = e.returnUrl ?? Ot(Ht()), this.mode = e.mode, this.liveMuted = e.restored && this.mode === "live" ? !0 : this.config.live_muted, this.recordingStarted = this.mode === "live" || this.config.autoplay_recording, this.resetMediaAttempt(), this.automaticLiveRetry = !e.restored, this.pageUnloading = !1, this.suspended = !1, this.statusAnnouncement = "", this.resetVisitorActions(), this.resetSnapshotAction(), this.lastDoorbellState = this.config.doorbell_entity ? this.hass.states[this.config.doorbell_entity]?.state : void 0, this.setRingingUntil(e.ringingUntil), this.open = !0, Tn(this), this.syncUrl(), this.attachGlobalListeners(), e.restored && this.mode === "live" ? this.prepareAutomaticLiveResume() : this.startMedia(), this.updateComplete.then(() => this.focusInitialControl()); + this.open && this.finishClose(!1, !1), this.inline = !1, this.config = e.config, this.opener = e.opener, this.returnUrl = e.returnUrl ?? Ft(zt()), this.mode = e.mode, this.liveMuted = e.restored && this.mode === "live" ? !0 : this.config.live_muted, this.recordingStarted = this.mode === "live" || this.config.autoplay_recording, this.resetMediaAttempt(), this.automaticLiveRetry = !e.restored, this.pageUnloading = !1, this.suspended = !1, this.statusAnnouncement = "", this.resetVisitorActions(), this.resetSnapshotAction(), this.lastDoorbellEntity = this.config.doorbell_entity ? this.hass.states[this.config.doorbell_entity] : void 0, this.setRingingUntil(e.ringingUntil), this.open = !0, $n(this), this.syncUrl(), this.attachGlobalListeners(), e.restored && this.mode === "live" ? this.prepareAutomaticLiveResume() : this.startMedia(), this.updateComplete.then(() => this.focusInitialControl()); } } showInline(e) { @@ -3828,7 +3845,7 @@ let f = class extends C { this.setRingingUntil(e.ringingUntil); return; } - this.open && this.finishClose(!1, !1), this.inline = !0, this.config = e.config, this.mode = e.start === "live" ? "live" : e.start === "last_recording" ? "last_recording" : e.mode, this.liveMuted = this.config.dashboard_live_muted, this.inlineStarted = e.start !== "on_demand", this.recordingStarted = this.mode === "live" || this.inlineStarted, this.resetMediaAttempt(), this.automaticLiveRetry = !0, this.pageUnloading = !1, this.suspended = !this.inlineActive, this.statusAnnouncement = "", this.resetVisitorActions(), this.resetSnapshotAction(), this.lastDoorbellState = this.config.doorbell_entity ? this.hass.states[this.config.doorbell_entity]?.state : void 0, this.setRingingUntil(e.ringingUntil), this.open = !0, this.attachGlobalListeners(), this.inlineStarted && this.inlineActive && this.startMedia(); + this.open && this.finishClose(!1, !1), this.inline = !0, this.config = e.config, this.mode = e.start === "live" ? "live" : e.start === "last_recording" ? "last_recording" : e.mode, this.liveMuted = this.config.dashboard_live_muted, this.inlineStarted = e.start !== "on_demand", this.recordingStarted = this.mode === "live" || this.inlineStarted, this.resetMediaAttempt(), this.automaticLiveRetry = !0, this.pageUnloading = !1, this.suspended = !this.inlineActive, this.statusAnnouncement = "", this.resetVisitorActions(), this.resetSnapshotAction(), this.lastDoorbellEntity = this.config.doorbell_entity ? this.hass.states[this.config.doorbell_entity] : void 0, this.setRingingUntil(e.ringingUntil), this.open = !0, this.attachGlobalListeners(), this.inlineStarted && this.inlineActive && this.startMedia(); } } setInlineVisible(e) { @@ -3898,14 +3915,14 @@ let f = class extends C { this.hass, this.inline ? "viewer.expand_aria" : "viewer.close_aria" )} - title=${this.inline ? s(this.hass, "viewer.expand_aria") : xt( + title=${this.inline ? s(this.hass, "viewer.expand_aria") : Lt( this.hass, "ui.common.close", "common.close" )} @click=${this.inline ? this.expand : this.close} > - ${this.icon(this.inline ? Zt : Kt)} + ${this.icon(this.inline ? Qt : jt)} ${this.renderModeSwitch()} @@ -3929,7 +3946,7 @@ let f = class extends C { aria-label=${e} title=${e} > - ${this.icon(ft)} + ${this.icon(_t)} ${e} `; @@ -3975,7 +3992,7 @@ let f = class extends C { } renderSnapshotAction() { if (!this.shouldShowSnapshotAction()) return l; - const e = this.snapshotEntityId(), t = this.hass?.connection?.connected !== !1, i = this.snapshotActionStatus === "working", n = i || !e || !t, r = t ? e ? this.snapshotActionStatus === "working" ? "snapshot.saving" : this.snapshotActionStatus === "success" ? "snapshot.saved" : this.snapshotActionStatus === "error" ? this.snapshotFeedbackMessage ? void 0 : "snapshot.failed" : "snapshot.take" : "snapshot.unavailable" : "snapshot.connection_failed", o = r ? s(this.hass, r) : this.snapshotFeedbackMessage, a = this.snapshotActionStatus === "working" ? je : this.snapshotActionStatus === "success" ? Ke : this.snapshotActionStatus === "error" ? _e : mt; + const e = this.snapshotEntityId(), t = this.hass?.connection?.connected !== !1, i = this.snapshotActionStatus === "working", n = i || !e || !t, r = t ? e ? this.snapshotActionStatus === "working" ? "snapshot.saving" : this.snapshotActionStatus === "success" ? "snapshot.saved" : this.snapshotActionStatus === "error" ? this.snapshotFeedbackMessage ? void 0 : "snapshot.failed" : "snapshot.take" : "snapshot.unavailable" : "snapshot.connection_failed", o = r ? s(this.hass, r) : this.snapshotFeedbackMessage, a = this.snapshotActionStatus === "working" ? je : this.snapshotActionStatus === "success" ? Ke : this.snapshotActionStatus === "error" ? _e : bt; return h` ` : l} @@ -4154,7 +4171,7 @@ let f = class extends C { } doorActionIcon() { const e = this.doorEntity(), t = this.doorContactState(); - return t === "open" ? We : t === "closed" ? Wt : t === "unknown" || w(e) || e?.state === "jammed" || this.config?.door_action === "open" && !se(e) || this.doorActionStatus === "error" ? _e : this.doorActionStatus === "working" ? je : this.doorActionStatus === "success" ? Ke : this.config?.door_action === "open" ? We : _t; + return t === "open" ? We : t === "closed" ? Zt : t === "unknown" || w(e) || e?.state === "jammed" || this.config?.door_action === "open" && !se(e) || this.doorActionStatus === "error" ? _e : this.doorActionStatus === "working" ? je : this.doorActionStatus === "success" ? Ke : this.config?.door_action === "open" ? We : wt; } talkbackPlayer() { return this.shadowRoot?.querySelector("ring-view-ring-webrtc-player") ?? null; @@ -4165,7 +4182,7 @@ let f = class extends C { beginDoorHold() { this.doorHoldTimer !== void 0 || this.doorActionDisabled() || (this.clearDoorFeedback(), this.doorActionStatus = "holding", this.doorHoldTimer = window.setTimeout(() => { this.doorHoldTimer = void 0, this.doorPointerId = void 0, this.doorKeyboardPressed = !1, this.executeDoorAction(); - }, tr)); + }, ir)); } cancelDoorHold() { this.doorHoldTimer !== void 0 && (window.clearTimeout(this.doorHoldTimer), this.doorHoldTimer = void 0), this.doorPointerId = void 0, this.doorKeyboardPressed = !1, this.doorActionStatus === "holding" && (this.doorActionStatus = "idle"); @@ -4187,7 +4204,7 @@ let f = class extends C { ); this.doorActionStatus = "success", this.clearViewerFeedback("door"), this.statusAnnouncement = r, this.doorFeedbackTimer = window.setTimeout(() => { n === this.doorActionToken && (this.doorFeedbackTimer = void 0, this.clearViewerFeedback("door"), this.doorActionStatus = "idle"); - }, ir); + }, nr); } catch { if (n !== this.doorActionToken || !this.open) return; const r = s( @@ -4196,7 +4213,7 @@ let f = class extends C { ); this.doorActionStatus = "error", this.setViewerFeedback("door", r, "error"), this.statusAnnouncement = r, this.doorFeedbackTimer = window.setTimeout(() => { n === this.doorActionToken && (this.doorFeedbackTimer = void 0, this.clearViewerFeedback("door"), this.doorActionStatus = "idle"); - }, nr); + }, rr); } } clearDoorFeedback() { @@ -4212,7 +4229,7 @@ let f = class extends C { this.viewerFeedback?.source === e && (this.viewerFeedback = void 0); } renderMedia() { - const e = this.activeEntity(), t = this.activeEntityId(), i = w(e), n = !i && !this.suspended && (this.mode === "live" || this.recordingStarted), r = Ut(this.config.aspect_ratio), o = this.mode === "last_recording" ? It(this.hass, this.config) : t, a = zt( + const e = this.activeEntity(), t = this.activeEntityId(), i = w(e), n = !i && !this.suspended && (this.mode === "live" || this.recordingStarted), r = Ot(this.config.aspect_ratio), o = this.mode === "last_recording" ? Ut(this.hass, this.config) : t, a = qt( this.hass, this.hass.states[o], o @@ -4376,7 +4393,7 @@ let f = class extends C {
@@ -4481,7 +4498,7 @@ let f = class extends C { this.inline && !this.inlineStarted && (this.inlineStarted = !0, this.recordingStarted = !0, this.resetMediaAttempt(), this.startMedia()); return; } - this.resetVisitorActions(), this.resetSnapshotAction(), this.clearAutomaticLiveRecovery(), this.lifecycle.dispose(), this.releaseInlineLive(), this.automaticLiveRetry = !0, this.mode = e, Vn(this.config, e), this.liveMuted = this.inline ? this.config.dashboard_live_muted : this.config.live_muted, this.inlineStarted = !0, this.recordingStarted = this.inline ? !0 : e === "live" || this.config.autoplay_recording, this.resetMediaAttempt(), this.statusAnnouncement = s( + this.resetVisitorActions(), this.resetSnapshotAction(), this.clearAutomaticLiveRecovery(), this.lifecycle.dispose(), this.releaseInlineLive(), this.automaticLiveRetry = !0, this.mode = e, Hn(this.config, e), this.liveMuted = this.inline ? this.config.dashboard_live_muted : this.config.live_muted, this.inlineStarted = !0, this.recordingStarted = this.inline ? !0 : e === "live" || this.config.autoplay_recording, this.resetMediaAttempt(), this.statusAnnouncement = s( this.hass, e === "live" ? "viewer.mode_selected_live" : "viewer.mode_selected_recording" ), this.syncUrl(), this.startMedia(); @@ -4522,7 +4539,7 @@ let f = class extends C { } this.mode === "last_recording" && this.resetRecordingControls(), this.recordingPreparationActive = !1, this.recordingFailureDetail = void 0, this.mediaStatus = "pending", this.session = this.lifecycle.next(), this.lifecycle.scheduleTimeout( () => this.failMedia(), - Jn * 1e3 + Xn * 1e3 ); } resetMediaAttempt() { @@ -4570,7 +4587,7 @@ let f = class extends C { return this.finishPendingRecordingUpdate("cancelled"), new Promise((i) => { const n = window.setTimeout(() => { this.finishPendingRecordingUpdate("timeout"); - }, ar); + }, dr); this.pendingRecordingUpdate = { entityId: e, baseline: t, timer: n, resolve: i }; }); } @@ -4610,7 +4627,7 @@ let f = class extends C { scheduleRecordingControlsHide(e, t) { this.clearRecordingControlsTimer(), this.recordingControlsTimer = window.setTimeout(() => { this.recordingControlsTimer = void 0, this.isCurrentRecording(e, t) && (e.controls = !1, this.recordingControlsVisible = !1); - }, er); + }, tr); } resetRecordingControls() { this.clearRecordingControlsTimer(), this.recordingControlsVisible = !0, this.recordingEnded = !1, this.recordingPaused = !1; @@ -4655,7 +4672,7 @@ let f = class extends C { return; } if (this.automaticLiveRetry && this.mode === "live" && this.retryCount < 1) { - this.retryCount += 1, this.scheduleLiveReconnect(Xn); + this.retryCount += 1, this.scheduleLiveReconnect(er); return; } this.lifecycle.dispose(), this.releaseInlineLive(), this.session = this.lifecycle.current(), this.mediaStatus = "error", this.statusAnnouncement = this.mode === "live" ? s(this.hass, "viewer.live_failed") : s(this.hass, "viewer.recording_unavailable"); @@ -4710,8 +4727,8 @@ let f = class extends C { detectDoorbellEvent(e) { const t = this.config?.doorbell_entity; if (!t || !this.hass) return; - const i = this.hass.states[t], n = e?.states[t]?.state ?? this.lastDoorbellState; - this.lastDoorbellState = i?.state, Nt(t, n, i) && this.setRingingUntil(Date.now() + ae); + const i = this.hass.states[t], n = e?.states[t] ?? this.lastDoorbellEntity; + this.lastDoorbellEntity = i, Kt(t, n, i) && this.setRingingUntil(Date.now() + ae); } setRingingUntil(e) { this.ringAlertTimer !== void 0 && (window.clearTimeout(this.ringAlertTimer), this.ringAlertTimer = void 0); @@ -4726,7 +4743,7 @@ let f = class extends C { } syncUrl() { if (this.inline || !this.open || !this.config || !this.returnUrl) return; - const e = Sn(this.returnUrl, { + const e = Rn(this.returnUrl, { liveEntity: this.config.live_entity, recordingEntity: this.config.recording_entity, mode: this.mode, @@ -4739,7 +4756,7 @@ let f = class extends C { const i = this.inline; this.clearAutomaticLiveRecovery(); const n = this.opener, r = this.returnUrl, o = this.config; - this.renderRoot.querySelector("ring-view-ring-webrtc-player")?.stopTalking(), this.lifecycle.dispose(), this.releaseInlineLive(), this.session = this.lifecycle.current(), this.mediaStatus = "idle", this.open = !1, Rn(this), this.suspended = !1, this.resetMediaAttempt(), this.automaticLiveRetry = !0, this.recordingStarted = !0, this.statusAnnouncement = "", this.resetVisitorActions(), this.resetSnapshotAction(), this.setRingingUntil(), this.lastDoorbellState = void 0, this.detachGlobalListeners(), document.fullscreenElement && document.exitFullscreen().catch(() => { + this.renderRoot.querySelector("ring-view-ring-webrtc-player")?.stopTalking(), this.lifecycle.dispose(), this.releaseInlineLive(), this.session = this.lifecycle.current(), this.mediaStatus = "idle", this.open = !1, Cn(this), this.suspended = !1, this.resetMediaAttempt(), this.automaticLiveRetry = !0, this.recordingStarted = !0, this.statusAnnouncement = "", this.resetVisitorActions(), this.resetSnapshotAction(), this.setRingingUntil(), this.lastDoorbellEntity = void 0, this.detachGlobalListeners(), document.fullscreenElement && document.exitFullscreen().catch(() => { }), t && r && o && Ee(Ce(), o) && xe(r, null), i || this.dispatchEvent( new CustomEvent("viewer-closed", { bubbles: !0, composed: !0 }) ), t && this.dispatchEvent( @@ -4771,7 +4788,7 @@ let f = class extends C { !this.inline || !this.open || this.mode !== "live" || (this.resetVisitorActions(), this.clearAutomaticLiveRecovery(), this.lifecycle.dispose(), this.releaseInlineLive(), this.session = this.lifecycle.current(), this.automaticLiveRetry = !1, this.mediaStatus = "awaiting-resume", this.statusAnnouncement = s(this.hass, "viewer.resume_live")); } }; -f.styles = Mn; +f.styles = In; m([ _({ attribute: !1 }) ], f.prototype, "hass", 2); @@ -4859,19 +4876,19 @@ m([ f = m([ W(Be) ], f); -const ne = /* @__PURE__ */ new Map(), cr = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const ne = /* @__PURE__ */ new Map(), lr = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, get RingViewDialog() { return f; } }, Symbol.toStringTag, { value: "Module" })); -var lr = Object.defineProperty, hr = Object.getOwnPropertyDescriptor, I = (e, t, i, n) => { - for (var r = n > 1 ? void 0 : n ? hr(t, i) : t, o = e.length - 1, a; o >= 0; o--) +var hr = Object.defineProperty, ur = Object.getOwnPropertyDescriptor, I = (e, t, i, n) => { + for (var r = n > 1 ? void 0 : n ? ur(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 && lr(t, i, r), r; + return n && r && hr(t, i, r), r; }; -const ur = 1e4, pr = 640, vr = 16 / 9, gt = 16, gr = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent( - Ai +const pr = 1e4, vr = 640, gr = 16 / 9, mt = 16, fr = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent( + Ti )}`; let E = class extends C { constructor() { @@ -4908,7 +4925,7 @@ let E = class extends C { }; } static async getConfigElement() { - return await Promise.resolve().then(() => yr), document.createElement("ring-view-editor"); + return await Promise.resolve().then(() => kr), document.createElement("ring-view-editor"); } static getStubConfig(e) { const t = Object.keys(e?.states ?? {}).filter((d) => d.startsWith("camera.")), i = t.find( @@ -4919,7 +4936,7 @@ let E = class extends C { (d) => e ? Se(e, d) : !1 ), o = n ?? r ?? t.find( (d) => d !== i && (Number(e?.states[d]?.attributes.supported_features ?? 0) & 2) === 0 - ) ?? t.find((d) => d !== i) ?? t[0] ?? "camera.latest_recording", a = e && o === r ? rn(e, o) : void 0; + ) ?? t.find((d) => d !== i) ?? t[0] ?? "camera.latest_recording", a = e && o === r ? sn(e, o) : void 0; return { type: ze, recording_entity: o, @@ -4963,7 +4980,7 @@ let E = class extends C { } willUpdate(e) { if (!this.hass || !this.config || (this.observePreviewMedia(), this.detectDoorbellEvent(e.get("hass")), this.isInCardPicker())) return; - const t = this.previewEntityId(), i = zt(this.hass, this.hass.states[t], t); + const t = this.previewEntityId(), i = qt(this.hass, this.hass.states[t], t); (t !== this.activePreviewEntityId || i !== this.lastFallbackPoster) && (this.activePreviewEntityId = t, this.lastFallbackPoster = i, this.lastPoster = i, this.lastPreviewSize = void 0, this.previewRequestId += 1, this.previewFailed = !1); } updated(e) { @@ -4976,7 +4993,7 @@ let E = class extends C { this.hass.states[this.config.recording_entity], s(this.hass, "common.camera") ), n = this.ringAlertVisible ? "live" : ie(this.config), r = this.isInCardPicker(), o = r || this.preview, a = r ? !1 : w(t), d = { - "--ring-view-aspect-ratio": An(this.config.aspect_ratio), + "--ring-view-aspect-ratio": Tn(this.config.aspect_ratio), "--ring-view-fit-mode": this.config.fit_mode }; if (this.config.dashboard_behavior === "interactive" && !o) @@ -4991,9 +5008,9 @@ let E = class extends C { `; - const c = !o, u = this.config.last_activity_entity ? H(this.hass, this.config.last_activity_entity) : void 0, p = u === void 0 ? void 0 : Dt(this.hass, u), v = c ? s(this.hass, "card.open_viewer", { + const c = !o, u = this.config.last_activity_entity ? H(this.hass, this.config.last_activity_entity) : void 0, p = u === void 0 ? void 0 : Vt(this.hass, u), v = c ? s(this.hass, "card.open_viewer", { name: i, - mode: Pn(n, this.hass) + mode: Mn(n, this.hass) }) : s(this.hass, "card.preview_alt", { name: i }), y = p ? `${v}. ${p.accessible}.` : v; return h` @@ -5012,7 +5029,7 @@ let E = class extends C { > ${!a && !this.previewFailed ? h` ${s(this.hass, @@ -5034,7 +5051,7 @@ let E = class extends C { ` : l} ${this.ringAlertVisible ? h`
${s(this.hass, "ring.alert")}
` : l} @@ -5043,7 +5060,7 @@ let E = class extends C { `; } previewEntityId() { - return Yi( + return Xi( this.hass, this.config, ie(this.config), @@ -5051,7 +5068,7 @@ let E = class extends C { ); } observePreviewMedia() { - const e = Gi( + const e = Yi( this.hass?.states[this.config.recording_entity] ), t = he( this.config.snapshot_entity ? this.hass?.states[this.config.snapshot_entity] : void 0 @@ -5096,7 +5113,7 @@ let E = class extends C { 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 (!Kt(t, i, n)) return; const r = Date.now(); r - this.lastRingAlertAt < 5e3 || (this.lastRingAlertAt = r, this.updateRingAlert()); } @@ -5136,7 +5153,7 @@ let E = class extends C { if (e) { this.refreshPreview(!0), this.previewRefreshTimer = window.setInterval(() => { this.refreshPreview(!0); - }, ur); + }, pr); return; } this.stopPreviewRefreshTimer(), this.previewRequestId += 1; @@ -5149,13 +5166,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 : vr, o = t.clientHeight || t.getBoundingClientRect().height, a = Ot(this.config.aspect_ratio) ?? gr, 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) < mt && Math.abs(u - this.lastPreviewSize.height) < mt) 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 Pn(v, p, c, u); if (y !== this.previewRequestId || !this.isConnected || !this.previewVisible || this.previewEntityId() !== p) return; this.lastPoster = A, this.previewFailed = !1; @@ -5177,7 +5194,7 @@ let E = class extends C { return !1; } }; -E.styles = Ln; +E.styles = Dn; I([ _({ attribute: !1 }) ], E.prototype, "hass", 2); @@ -5205,7 +5222,7 @@ E = I([ window.customCards = window.customCards || []; window.customCards.some((e) => e.type === Re) || window.customCards.push({ type: Re, - name: cn, + name: hn, description: s(void 0, "card.description"), preview: !0, getEntitySuggestion: (e, t) => { @@ -5222,12 +5239,12 @@ window.customCards.some((e) => e.type === Re) || window.customCards.push({ } : 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 mr = Object.defineProperty, _r = Object.getOwnPropertyDescriptor, te = (e, t, i, n) => { + for (var r = n > 1 ? void 0 : n ? _r(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 && mr(t, i, r), r; }; -function _r(e, t) { +function br(e, t) { const i = [ { name: "dashboard_behavior", @@ -5411,14 +5428,14 @@ function _r(e, t) { name: "dashboard_preview", type: "expandable", flatten: !0, - iconPath: Bt, + iconPath: Wt, schema: i }, { name: "viewer_behavior", type: "expandable", flatten: !0, - iconPath: Yt, + iconPath: Xt, schema: [ { name: "default_mode", @@ -5445,7 +5462,7 @@ function _r(e, t) { name: "snapshots", type: "expandable", flatten: !0, - iconPath: mt, + iconPath: bt, schema: [ { name: "snapshot_entity", @@ -5464,7 +5481,7 @@ function _r(e, t) { name: "doorbell_features", type: "expandable", flatten: !0, - iconPath: jt, + iconPath: Gt, schema: [ { name: "doorbell_entity", @@ -5483,14 +5500,14 @@ function _r(e, t) { name: "door_access", type: "expandable", flatten: !0, - iconPath: _t, + iconPath: wt, schema: n }, { name: "card_appearance", type: "expandable", flatten: !0, - iconPath: Qt, + iconPath: Jt, schema: [ { name: "name", selector: { text: {} } }, { name: "show_name", selector: { boolean: {} } }, @@ -5561,7 +5578,7 @@ function _r(e, t) { } ]; } -const br = { +const wr = { recording_entity: "editor.recording_entity", live_entity: "editor.live_entity", snapshot_entity: "editor.snapshot_entity", @@ -5595,7 +5612,7 @@ const br = { preview_fallback: "editor.preview_fallback", aspect_ratio: "editor.aspect_ratio", fit_mode: "editor.fit_mode" -}, wr = { +}, yr = { recording_entity: "editor.helper_recording_entity", live_entity: "editor.helper_live_entity", dashboard_behavior: "editor.helper_dashboard_behavior", @@ -5622,7 +5639,7 @@ const br = { let D = class extends C { constructor() { super(...arguments), this.nativeChecked = !1, this.nativeAvailable = !1, this.computeLabel = (e) => { - const t = br[e.name]; + const t = wr[e.name]; if (!t) return; const i = s(this.hass, t); if (e.required || ![ @@ -5633,7 +5650,7 @@ let D = class extends C { ].includes( e.name )) return i; - const n = xt( + const n = Lt( this.hass, "ui.panel.lovelace.editor.card.config.optional", "common.optional" @@ -5641,10 +5658,10 @@ 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 = nn(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 = yr[e.name]; return t ? s(this.hass, t) : void 0; }, this.valueChanged = (e) => { if (!this.config) return; @@ -5677,13 +5694,13 @@ let D = class extends C { this.config = $e(e); } connectedCallback() { - super.connectedCallback(), qt().then((e) => { + super.connectedCallback(), Bt().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 = Zi(this.hass, this.config).filter( (t) => t.kind !== "compatibility" || this.nativeChecked && !this.nativeAvailable ); return h` @@ -5703,7 +5720,7 @@ let D = class extends C { (); @@ -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..7b24d7f 100644 --- a/src/ring-view.ts +++ b/src/ring-view.ts @@ -511,7 +511,7 @@ export class RingView extends LitElement { if (!entityId || !previous || !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; diff --git a/src/utilities/doorbell.ts b/src/utilities/doorbell.ts index 5399a1c..52e5505 100644 --- a/src/utilities/doorbell.ts +++ b/src/utilities/doorbell.ts @@ -4,26 +4,51 @@ const UNAVAILABLE_STATES = new Set(["unknown", "unavailable"]); export function isDoorbellRingTransition( entityId: string, - previousState: string | undefined, + previous: HassEntity | undefined, current: HassEntity | undefined, ): boolean { if ( !current - || previousState === undefined + || !previous || UNAVAILABLE_STATES.has(current.state) ) { return false; } if (entityId.startsWith("binary_sensor.")) { - return previousState === "off" && current.state === "on"; + if (UNAVAILABLE_STATES.has(previous.state)) return false; + if (previous.state === "off" && current.state === "on") return true; + if (previous.state !== "on" || current.state !== "on") return false; + + const previousDing = ringMqttDingMarker(previous); + const currentDing = ringMqttDingMarker(current); + return previousDing !== undefined + && currentDing !== undefined + && previousDing !== currentDing; } return ( entityId.startsWith("event.") - && previousState !== "unavailable" - && previousState !== current.state + && previous.state !== "unavailable" + && previous.state !== current.state && (current.attributes.event_type === undefined || current.attributes.event_type === "ring") ); } + +function ringMqttDingMarker(entity: HassEntity): string | undefined { + const lastDingTime = entity.attributes.lastDingTime; + if (typeof lastDingTime === "string" && lastDingTime.trim() !== "") { + return `time:${lastDingTime}`; + } + + const lastDing = entity.attributes.lastDing; + if ( + (typeof lastDing === "string" && lastDing.trim() !== "") + || (typeof lastDing === "number" && Number.isFinite(lastDing)) + ) { + return `epoch:${lastDing}`; + } + + return undefined; +} diff --git a/tests/unit/card-lifecycle.test.ts b/tests/unit/card-lifecycle.test.ts index 8ef59a2..9a2db38 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:00Z")); 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,31 @@ 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(6_000); + card.hass = { + ...card.hass, + states: { + ...card.hass.states, + [doorbell.entity_id]: { + ...doorbell, + state: "on", + attributes: { ...doorbell.attributes, lastDingTime: "2026-09-12T14:19:19Z" }, + }, + }, + }; + 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(7_000); + expect(card.shadowRoot?.querySelector(".ring-alert")).not.toBeNull(); + await vi.advanceTimersByTimeAsync(5_001); + expect(card.shadowRoot?.querySelector(".ring-alert")).toBeNull(); }); it("updates Ring-MQTT activity when a same-device motion sibling changes", async () => { @@ -908,6 +935,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..290b8a9 100644 --- a/tests/unit/doorbell.test.ts +++ b/tests/unit/doorbell.test.ts @@ -2,46 +2,88 @@ 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 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); + expect(isDoorbellRingTransition(id, entity(id, "off"), entity(id, "on"))).toBe(true); + expect(isDoorbellRingTransition(id, entity(id, "on"), entity(id, "on"))).toBe(false); + expect(isDoorbellRingTransition(id, entity(id, "on"), entity(id, "off"))).toBe(false); + expect(isDoorbellRingTransition(id, entity(id, "unknown"), entity(id, "on"))).toBe(false); + expect(isDoorbellRingTransition(id, entity(id, "unavailable"), entity(id, "on"))).toBe(false); + }); + + it("accepts a new Ring-MQTT Ding marker while the sensor remains on", () => { + 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:20:10Z", + lastDing: 1_789_222_810, + }), + )).toBe(true); + expect(isDoorbellRingTransition(id, previous, { ...previous })).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" }), + )).toBe(false); + }); + + it("falls back to lastDing and ignores a marker first added to an active sensor", () => { + const id = "binary_sensor.front_door_ding"; + expect(isDoorbellRingTransition( + id, + entity(id, "on", undefined, { lastDing: 1_789_222_753 }), + entity(id, "on", undefined, { lastDing: 1_789_222_810 }), + )).toBe(true); + expect(isDoorbellRingTransition( + id, + entity(id, "on"), + entity(id, "on", undefined, { lastDingTime: "2026-09-12T14:20:10Z" }), + )).toBe(false); }); it("accepts Ring events 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:00:00Z"), entity(id, "2026-09-12T14:01:00Z", "ring")), ).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:00:00Z"), entity(id, "2026-09-12T14:01:00Z", "motion")), ).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:01:00Z"), entity(id, "2026-09-12T14:01:00Z", "ring")), ).toBe(false); expect( - isDoorbellRingTransition(id, "unknown", entity(id, "2026-09-12T14:01:00Z", "ring")), + isDoorbellRingTransition(id, entity(id, "unknown"), entity(id, "2026-09-12T14:01:00Z", "ring")), ).toBe(true); expect( - isDoorbellRingTransition(id, "unavailable", entity(id, "2026-09-12T14:01:00Z", "ring")), + isDoorbellRingTransition(id, entity(id, "unavailable"), entity(id, "2026-09-12T14:01:00Z", "ring")), ).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); + expect(isDoorbellRingTransition("event.ding", entity("event.ding", "before"), entity("event.ding", "unavailable", "ring"))).toBe(false); + expect(isDoorbellRingTransition("sensor.ding", entity("sensor.ding", "off"), entity("sensor.ding", "on"))).toBe(false); }); }); From bdbfcaca4aa528c0596b35f8e19c655e4c971538 Mon Sep 17 00:00:00 2001 From: Thomas Gregg <2538782+thomasgregg@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:40:28 +0200 Subject: [PATCH 2/5] Harden doorbell event edge cases --- CHANGELOG.md | 9 +- dist/ring-view.js | 1092 +++++++++++++++-------------- src/ring-view.ts | 9 +- src/translations/de.json | 1 + src/translations/en.json | 1 + src/utilities/activity-time.ts | 2 +- src/utilities/doorbell.ts | 95 ++- tests/browser/card.spec.ts | 4 +- tests/unit/activity-time.test.ts | 4 +- tests/unit/card-lifecycle.test.ts | 34 +- tests/unit/doorbell.test.ts | 137 +++- 11 files changed, 784 insertions(+), 604 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7b9ebe..3947a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,13 @@ All notable changes to this project are documented here. ### Fixed -- Detect repeated Ring-MQTT doorbell presses from a changed `lastDingTime` or - `lastDing` attribute while the configured Ding sensor remains `on`. +- Detect rapid and repeated Ring-MQTT doorbell presses from advancing + `lastDingTime`, `lastDing`, or entity-update timestamps while the configured + Ding sensor remains `on`, without relying on a fixed debounce window. +- Recover a genuinely fresh Ring alert after the dashboard loads or an entity + reconnects, while continuing to ignore stale retained MQTT state. +- Prefix the visible relative time with **Activity** so it cannot be mistaken + for the age of an older recording selected through Ring-MQTT Event Select. ## [0.10.0-beta.1] - 2026-09-13 diff --git a/dist/ring-view.js b/dist/ring-view.js index f9e35fd..7ed7880 100644 --- a/dist/ring-view.js +++ b/dist/ring-view.js @@ -1,16 +1,16 @@ -var _e = "M11,15H13V17H11V15M11,7H13V13H11V7M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20Z", _t = "M10,21H14A2,2 0 0,1 12,23A2,2 0 0,1 10,21M21,19V20H3V19L5,17V11C5,7.9 7.03,5.17 10,4.29C10,4.19 10,4.1 10,4A2,2 0 0,1 12,2A2,2 0 0,1 14,4C14,4.1 14,4.19 14,4.29C16.97,5.17 19,7.9 19,11V17L21,19M17,11A5,5 0 0,0 12,6A5,5 0 0,0 7,11V18H17V11M19.75,3.19L18.33,4.61C20.04,6.3 21,8.6 21,11H23C23,8.07 21.84,5.25 19.75,3.19M1,11H3C3,8.6 3.96,6.3 5.67,4.61L4.25,3.19C2.16,5.25 1,8.07 1,11Z", Wt = "M9,12C9,11.19 9.3,10.5 9.89,9.89C10.5,9.3 11.19,9 12,9C12.81,9 13.5,9.3 14.11,9.89C14.7,10.5 15,11.19 15,12C15,12.81 14.7,13.5 14.11,14.11C13.5,14.7 12.81,15 12,15C11.19,15 10.5,14.7 9.89,14.11C9.3,13.5 9,12.81 9,12M5.53,8.44L7.31,10.22L5.53,12L7.31,13.78L5.53,15.56L2,12L5.53,8.44M8.44,18.47L10.22,16.69L12,18.47L13.78,16.69L15.56,18.47L12,22L8.44,18.47M18.47,15.56L16.69,13.78L18.47,12L16.69,10.22L18.47,8.44L22,12L18.47,15.56M15.56,5.53L13.78,7.31L12,5.53L10.22,7.31L8.44,5.53L12,2L15.56,5.53Z", bt = "M20,4H16.83L15,2H9L7.17,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6A2,2 0 0,0 20,4M20,18H4V6H8.05L9.88,4H14.12L15.95,6H20V18M12,7A5,5 0 0,0 7,12A5,5 0 0,0 12,17A5,5 0 0,0 17,12A5,5 0 0,0 12,7M12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15Z", Ke = "M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M12 20C7.59 20 4 16.41 4 12S7.59 4 12 4 20 7.59 20 12 16.41 20 12 20M16.59 7.58L10 14.17L7.41 11.59L6 13L10 17L18 9L16.59 7.58Z", jt = "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", Zt = "M16,11H18V13H16V11M12,3H19C20.11,3 21,3.89 21,5V19H22V21H2V19H10V5C10,3.89 10.89,3 12,3M12,5V19H19V5H12Z", We = "M12,3C10.89,3 10,3.89 10,5H3V19H2V21H22V19H21V5C21,3.89 20.11,3 19,3H12M12,5H19V19H12V5M5,11H7V13H5V11Z", Gt = "M14 15C14 16.11 13.11 17 12 17S10 16.11 10 15 10.9 13 12 13 14 13.9 14 15M18 4V20C18 21.1 17.11 22 16 22H8C6.9 22 6 21.11 6 20V4C6 2.9 6.9 2 8 2H16C17.11 2 18 2.9 18 4M10.5 7C10.5 7.83 11.17 8.5 12 8.5S13.5 7.83 13.5 7 12.83 5.5 12 5.5 10.5 6.17 10.5 7M16 10H8V20H16V10Z", Qt = "M5,5H10V7H7V10H5V5M14,5H19V10H17V7H14V5M17,14H19V19H14V17H17V14M10,17V19H5V14H7V17H10Z", Yt = "M13.5,8H12V13L16.28,15.54L17,14.33L13.5,12.25V8M13,3A9,9 0 0,0 4,12H1L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,20 10.5,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3", je = "M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z", wt = "M10 13C11.1 13 12 13.89 12 15C12 16.11 11.11 17 10 17S8 16.11 8 15 8.9 13 10 13M18 1C15.24 1 13 3.24 13 6V8H4C2.9 8 2 8.9 2 10V20C2 21.1 2.9 22 4 22H16C17.1 22 18 21.1 18 20V10C18 8.9 17.1 8 16 8H15V6C15 4.34 16.34 3 18 3S21 4.34 21 6V8H23V6C23 3.24 20.76 1 18 1M16 10V20H4V10H16Z", yt = "M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z", kt = "M19,11C19,12.19 18.66,13.3 18.1,14.28L16.87,13.05C17.14,12.43 17.3,11.74 17.3,11H19M15,11.16L9,5.18V5A3,3 0 0,1 12,2A3,3 0 0,1 15,5V11L15,11.16M4.27,3L21,19.73L19.73,21L15.54,16.81C14.77,17.27 13.91,17.58 13,17.72V21H11V17.72C7.72,17.23 5,14.41 5,11H6.7C6.7,14 9.24,16.1 12,16.1C12.81,16.1 13.6,15.91 14.31,15.58L12.65,13.92L12,14A3,3 0 0,1 9,11V10.28L3,4.27L4.27,3Z", Jt = "M12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2C17.5,2 22,6 22,11A6,6 0 0,1 16,17H14.2C13.9,17 13.7,17.2 13.7,17.5C13.7,17.6 13.8,17.7 13.8,17.8C14.2,18.3 14.4,18.9 14.4,19.5C14.5,20.9 13.4,22 12,22M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C12.3,20 12.5,19.8 12.5,19.5C12.5,19.3 12.4,19.2 12.4,19.1C12,18.6 11.8,18.1 11.8,17.5C11.8,16.1 12.9,15 14.3,15H16A4,4 0 0,0 20,11C20,7.1 16.4,4 12,4M6.5,10C7.3,10 8,10.7 8,11.5C8,12.3 7.3,13 6.5,13C5.7,13 5,12.3 5,11.5C5,10.7 5.7,10 6.5,10M9.5,6C10.3,6 11,6.7 11,7.5C11,8.3 10.3,9 9.5,9C8.7,9 8,8.3 8,7.5C8,6.7 8.7,6 9.5,6M14.5,6C15.3,6 16,6.7 16,7.5C16,8.3 15.3,9 14.5,9C13.7,9 13,8.3 13,7.5C13,6.7 13.7,6 14.5,6M17.5,10C18.3,10 19,10.7 19,11.5C19,12.3 18.3,13 17.5,13C16.7,13 16,12.3 16,11.5C16,10.7 16.7,10 17.5,10Z", At = "M8,5.14V19.14L19,12.14L8,5.14Z", Xt = "M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M10,16.5L16,12L10,7.5V16.5Z"; -const re = globalThis, Pe = re.ShadowRoot && (re.ShadyCSS === void 0 || re.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, Le = /* @__PURE__ */ Symbol(), Ze = /* @__PURE__ */ new WeakMap(); -let St = class { +var be = "M11,15H13V17H11V15M11,7H13V13H11V7M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20Z", yt = "M10,21H14A2,2 0 0,1 12,23A2,2 0 0,1 10,21M21,19V20H3V19L5,17V11C5,7.9 7.03,5.17 10,4.29C10,4.19 10,4.1 10,4A2,2 0 0,1 12,2A2,2 0 0,1 14,4C14,4.1 14,4.19 14,4.29C16.97,5.17 19,7.9 19,11V17L21,19M17,11A5,5 0 0,0 12,6A5,5 0 0,0 7,11V18H17V11M19.75,3.19L18.33,4.61C20.04,6.3 21,8.6 21,11H23C23,8.07 21.84,5.25 19.75,3.19M1,11H3C3,8.6 3.96,6.3 5.67,4.61L4.25,3.19C2.16,5.25 1,8.07 1,11Z", Zt = "M9,12C9,11.19 9.3,10.5 9.89,9.89C10.5,9.3 11.19,9 12,9C12.81,9 13.5,9.3 14.11,9.89C14.7,10.5 15,11.19 15,12C15,12.81 14.7,13.5 14.11,14.11C13.5,14.7 12.81,15 12,15C11.19,15 10.5,14.7 9.89,14.11C9.3,13.5 9,12.81 9,12M5.53,8.44L7.31,10.22L5.53,12L7.31,13.78L5.53,15.56L2,12L5.53,8.44M8.44,18.47L10.22,16.69L12,18.47L13.78,16.69L15.56,18.47L12,22L8.44,18.47M18.47,15.56L16.69,13.78L18.47,12L16.69,10.22L18.47,8.44L22,12L18.47,15.56M15.56,5.53L13.78,7.31L12,5.53L10.22,7.31L8.44,5.53L12,2L15.56,5.53Z", kt = "M20,4H16.83L15,2H9L7.17,4H4A2,2 0 0,0 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V6A2,2 0 0,0 20,4M20,18H4V6H8.05L9.88,4H14.12L15.95,6H20V18M12,7A5,5 0 0,0 7,12A5,5 0 0,0 12,17A5,5 0 0,0 17,12A5,5 0 0,0 12,7M12,15A3,3 0 0,1 9,12A3,3 0 0,1 12,9A3,3 0 0,1 15,12A3,3 0 0,1 12,15Z", Ze = "M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M12 20C7.59 20 4 16.41 4 12S7.59 4 12 4 20 7.59 20 12 16.41 20 12 20M16.59 7.58L10 14.17L7.41 11.59L6 13L10 17L18 9L16.59 7.58Z", Gt = "M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z", Qt = "M16,11H18V13H16V11M12,3H19C20.11,3 21,3.89 21,5V19H22V21H2V19H10V5C10,3.89 10.89,3 12,3M12,5V19H19V5H12Z", Ge = "M12,3C10.89,3 10,3.89 10,5H3V19H2V21H22V19H21V5C21,3.89 20.11,3 19,3H12M12,5H19V19H12V5M5,11H7V13H5V11Z", Yt = "M14 15C14 16.11 13.11 17 12 17S10 16.11 10 15 10.9 13 12 13 14 13.9 14 15M18 4V20C18 21.1 17.11 22 16 22H8C6.9 22 6 21.11 6 20V4C6 2.9 6.9 2 8 2H16C17.11 2 18 2.9 18 4M10.5 7C10.5 7.83 11.17 8.5 12 8.5S13.5 7.83 13.5 7 12.83 5.5 12 5.5 10.5 6.17 10.5 7M16 10H8V20H16V10Z", Jt = "M5,5H10V7H7V10H5V5M14,5H19V10H17V7H14V5M17,14H19V19H14V17H17V14M10,17V19H5V14H7V17H10Z", Xt = "M13.5,8H12V13L16.28,15.54L17,14.33L13.5,12.25V8M13,3A9,9 0 0,0 4,12H1L4.96,16.03L9,12H6A7,7 0 0,1 13,5A7,7 0 0,1 20,12A7,7 0 0,1 13,19C11.07,19 9.32,18.21 8.06,16.94L6.64,18.36C8.27,20 10.5,21 13,21A9,9 0 0,0 22,12A9,9 0 0,0 13,3", Qe = "M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z", At = "M10 13C11.1 13 12 13.89 12 15C12 16.11 11.11 17 10 17S8 16.11 8 15 8.9 13 10 13M18 1C15.24 1 13 3.24 13 6V8H4C2.9 8 2 8.9 2 10V20C2 21.1 2.9 22 4 22H16C17.1 22 18 21.1 18 20V10C18 8.9 17.1 8 16 8H15V6C15 4.34 16.34 3 18 3S21 4.34 21 6V8H23V6C23 3.24 20.76 1 18 1M16 10V20H4V10H16Z", St = "M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z", Tt = "M19,11C19,12.19 18.66,13.3 18.1,14.28L16.87,13.05C17.14,12.43 17.3,11.74 17.3,11H19M15,11.16L9,5.18V5A3,3 0 0,1 12,2A3,3 0 0,1 15,5V11L15,11.16M4.27,3L21,19.73L19.73,21L15.54,16.81C14.77,17.27 13.91,17.58 13,17.72V21H11V17.72C7.72,17.23 5,14.41 5,11H6.7C6.7,14 9.24,16.1 12,16.1C12.81,16.1 13.6,15.91 14.31,15.58L12.65,13.92L12,14A3,3 0 0,1 9,11V10.28L3,4.27L4.27,3Z", ei = "M12,22A10,10 0 0,1 2,12A10,10 0 0,1 12,2C17.5,2 22,6 22,11A6,6 0 0,1 16,17H14.2C13.9,17 13.7,17.2 13.7,17.5C13.7,17.6 13.8,17.7 13.8,17.8C14.2,18.3 14.4,18.9 14.4,19.5C14.5,20.9 13.4,22 12,22M12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C12.3,20 12.5,19.8 12.5,19.5C12.5,19.3 12.4,19.2 12.4,19.1C12,18.6 11.8,18.1 11.8,17.5C11.8,16.1 12.9,15 14.3,15H16A4,4 0 0,0 20,11C20,7.1 16.4,4 12,4M6.5,10C7.3,10 8,10.7 8,11.5C8,12.3 7.3,13 6.5,13C5.7,13 5,12.3 5,11.5C5,10.7 5.7,10 6.5,10M9.5,6C10.3,6 11,6.7 11,7.5C11,8.3 10.3,9 9.5,9C8.7,9 8,8.3 8,7.5C8,6.7 8.7,6 9.5,6M14.5,6C15.3,6 16,6.7 16,7.5C16,8.3 15.3,9 14.5,9C13.7,9 13,8.3 13,7.5C13,6.7 13.7,6 14.5,6M17.5,10C18.3,10 19,10.7 19,11.5C19,12.3 18.3,13 17.5,13C16.7,13 16,12.3 16,11.5C16,10.7 16.7,10 17.5,10Z", Rt = "M8,5.14V19.14L19,12.14L8,5.14Z", ti = "M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M10,16.5L16,12L10,7.5V16.5Z"; +const oe = globalThis, De = oe.ShadowRoot && (oe.ShadyCSS === void 0 || oe.ShadyCSS.nativeShadow) && "adoptedStyleSheets" in Document.prototype && "replace" in CSSStyleSheet.prototype, Ie = /* @__PURE__ */ Symbol(), Ye = /* @__PURE__ */ new WeakMap(); +let $t = class { constructor(t, i, n) { - if (this._$cssResult$ = !0, n !== Le) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); + if (this._$cssResult$ = !0, n !== Ie) throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead."); this.cssText = t, this.t = i; } get styleSheet() { let t = this.o; const i = this.t; - if (Pe && t === void 0) { + if (De && t === void 0) { const n = i !== void 0 && i.length === 1; - n && (t = Ze.get(i)), t === void 0 && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), n && Ze.set(i, t)); + n && (t = Ye.get(i)), t === void 0 && ((this.o = t = new CSSStyleSheet()).replaceSync(this.cssText), n && Ye.set(i, t)); } return t; } @@ -18,28 +18,28 @@ let St = class { return this.cssText; } }; -const ei = (e) => new St(typeof e == "string" ? e : e + "", void 0, Le), K = (e, ...t) => { +const ii = (e) => new $t(typeof e == "string" ? e : e + "", void 0, Ie), K = (e, ...t) => { const i = e.length === 1 ? e[0] : t.reduce((n, r, o) => n + ((a) => { if (a._$cssResult$ === !0) return a.cssText; if (typeof a == "number") return a; throw Error("Value passed to 'css' function must be a 'css' function result: " + a + ". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security."); })(r) + e[o + 1], e[0]); - return new St(i, e, Le); -}, ti = (e, t) => { - if (Pe) e.adoptedStyleSheets = t.map((i) => i instanceof CSSStyleSheet ? i : i.styleSheet); + return new $t(i, e, Ie); +}, ni = (e, t) => { + if (De) e.adoptedStyleSheets = t.map((i) => i instanceof CSSStyleSheet ? i : i.styleSheet); else for (const i of t) { - const n = document.createElement("style"), r = re.litNonce; + const n = document.createElement("style"), r = oe.litNonce; r !== void 0 && n.setAttribute("nonce", r), n.textContent = i.cssText, e.appendChild(n); } -}, Ge = Pe ? (e) => e : (e) => e instanceof CSSStyleSheet ? ((t) => { +}, Je = De ? (e) => e : (e) => e instanceof CSSStyleSheet ? ((t) => { let i = ""; for (const n of t.cssRules) i += n.cssText; - return ei(i); + return ii(i); })(e) : e; -const { is: ii, defineProperty: ni, getOwnPropertyDescriptor: ri, getOwnPropertyNames: oi, getOwnPropertySymbols: si, getPrototypeOf: ai } = Object, ve = globalThis, Qe = ve.trustedTypes, di = Qe ? Qe.emptyScript : "", ci = ve.reactiveElementPolyfillSupport, Z = (e, t) => e, de = { toAttribute(e, t) { +const { is: ri, defineProperty: oi, getOwnPropertyDescriptor: si, getOwnPropertyNames: ai, getOwnPropertySymbols: di, getPrototypeOf: ci } = Object, ge = globalThis, Xe = ge.trustedTypes, li = Xe ? Xe.emptyScript : "", hi = ge.reactiveElementPolyfillSupport, G = (e, t) => e, ce = { toAttribute(e, t) { switch (t) { case Boolean: - e = e ? di : null; + e = e ? li : null; break; case Object: case Array: @@ -64,23 +64,23 @@ const { is: ii, defineProperty: ni, getOwnPropertyDescriptor: ri, getOwnProperty } } return i; -} }, Me = (e, t) => !ii(e, t), Ye = { attribute: !0, type: String, converter: de, reflect: !1, useDefault: !1, hasChanged: Me }; -Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), ve.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); -let N = class extends HTMLElement { +} }, Ve = (e, t) => !ri(e, t), et = { attribute: !0, type: String, converter: ce, reflect: !1, useDefault: !1, hasChanged: Ve }; +Symbol.metadata ??= /* @__PURE__ */ Symbol("metadata"), ge.litPropertyMetadata ??= /* @__PURE__ */ new WeakMap(); +let B = class extends HTMLElement { static addInitializer(t) { this._$Ei(), (this.l ??= []).push(t); } static get observedAttributes() { return this.finalize(), this._$Eh && [...this._$Eh.keys()]; } - static createProperty(t, i = Ye) { + static createProperty(t, i = et) { if (i.state && (i.attribute = !1), this._$Ei(), this.prototype.hasOwnProperty(t) && ((i = Object.create(i)).wrapped = !0), this.elementProperties.set(t, i), !i.noAccessor) { const n = /* @__PURE__ */ Symbol(), r = this.getPropertyDescriptor(t, n, i); - r !== void 0 && ni(this.prototype, t, r); + r !== void 0 && oi(this.prototype, t, r); } } static getPropertyDescriptor(t, i, n) { - const { get: r, set: o } = ri(this.prototype, t) ?? { get() { + const { get: r, set: o } = si(this.prototype, t) ?? { get() { return this[i]; }, set(a) { this[i] = a; @@ -91,17 +91,17 @@ let N = class extends HTMLElement { }, configurable: !0, enumerable: !0 }; } static getPropertyOptions(t) { - return this.elementProperties.get(t) ?? Ye; + return this.elementProperties.get(t) ?? et; } static _$Ei() { - if (this.hasOwnProperty(Z("elementProperties"))) return; - const t = ai(this); + if (this.hasOwnProperty(G("elementProperties"))) return; + const t = ci(this); t.finalize(), t.l !== void 0 && (this.l = [...t.l]), this.elementProperties = new Map(t.elementProperties); } static finalize() { - if (this.hasOwnProperty(Z("finalized"))) return; - if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(Z("properties"))) { - const i = this.properties, n = [...oi(i), ...si(i)]; + if (this.hasOwnProperty(G("finalized"))) return; + if (this.finalized = !0, this._$Ei(), this.hasOwnProperty(G("properties"))) { + const i = this.properties, n = [...ai(i), ...di(i)]; for (const r of n) this.createProperty(r, i[r]); } const t = this[Symbol.metadata]; @@ -120,8 +120,8 @@ let N = class extends HTMLElement { const i = []; if (Array.isArray(t)) { const n = new Set(t.flat(1 / 0).reverse()); - for (const r of n) i.unshift(Ge(r)); - } else t !== void 0 && i.push(Ge(t)); + for (const r of n) i.unshift(Je(r)); + } else t !== void 0 && i.push(Je(t)); return i; } static _$Eu(t, i) { @@ -147,7 +147,7 @@ let N = class extends HTMLElement { } createRenderRoot() { const t = this.shadowRoot ?? this.attachShadow(this.constructor.shadowRootOptions); - return ti(t, this.constructor.elementStyles), t; + return ni(t, this.constructor.elementStyles), t; } connectedCallback() { this.renderRoot ??= this.createRenderRoot(), this.enableUpdating(!0), this._$EO?.forEach((t) => t.hostConnected?.()); @@ -163,14 +163,14 @@ let N = class extends HTMLElement { _$ET(t, i) { const n = this.constructor.elementProperties.get(t), r = this.constructor._$Eu(t, n); if (r !== void 0 && n.reflect === !0) { - const o = (n.converter?.toAttribute !== void 0 ? n.converter : de).toAttribute(i, n.type); + const o = (n.converter?.toAttribute !== void 0 ? n.converter : ce).toAttribute(i, n.type); this._$Em = t, o == null ? this.removeAttribute(r) : this.setAttribute(r, o), this._$Em = null; } } _$AK(t, i) { const n = this.constructor, r = n._$Eh.get(t); if (r !== void 0 && this._$Em !== r) { - const o = n.getPropertyOptions(r), a = typeof o.converter == "function" ? { fromAttribute: o.converter } : o.converter?.fromAttribute !== void 0 ? o.converter : de; + const o = n.getPropertyOptions(r), a = typeof o.converter == "function" ? { fromAttribute: o.converter } : o.converter?.fromAttribute !== void 0 ? o.converter : ce; this._$Em = r; const d = a.fromAttribute(i, o.type); this[r] = d ?? this._$Ej?.get(r) ?? d, this._$Em = null; @@ -179,7 +179,7 @@ let N = class extends HTMLElement { requestUpdate(t, i, n, r = !1, o) { if (t !== void 0) { const a = this.constructor; - if (r === !1 && (o = this[t]), n ??= a.getPropertyOptions(t), !((n.hasChanged ?? Me)(o, i) || n.useDefault && n.reflect && o === this._$Ej?.get(t) && !this.hasAttribute(a._$Eu(t, n)))) return; + if (r === !1 && (o = this[t]), n ??= a.getPropertyOptions(t), !((n.hasChanged ?? Ve)(o, i) || n.useDefault && n.reflect && o === this._$Ej?.get(t) && !this.hasAttribute(a._$Eu(t, n)))) return; this.C(t, i, n); } this.isUpdatePending === !1 && (this._$ES = this._$EP()); @@ -247,70 +247,70 @@ let N = class extends HTMLElement { firstUpdated(t) { } }; -N.elementStyles = [], N.shadowRootOptions = { mode: "open" }, N[Z("elementProperties")] = /* @__PURE__ */ new Map(), N[Z("finalized")] = /* @__PURE__ */ new Map(), ci?.({ ReactiveElement: N }), (ve.reactiveElementVersions ??= []).push("2.1.2"); -const De = globalThis, Je = (e) => e, ce = De.trustedTypes, Xe = ce ? ce.createPolicy("lit-html", { createHTML: (e) => e }) : void 0, Tt = "$lit$", L = `lit$${Math.random().toFixed(9).slice(2)}$`, Rt = "?" + L, li = `<${Rt}>`, O = document, G = () => O.createComment(""), Q = (e) => e === null || typeof e != "object" && typeof e != "function", Ie = Array.isArray, hi = (e) => Ie(e) || typeof e?.[Symbol.iterator] == "function", be = `[ -\f\r]`, j = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, et = /-->/g, tt = />/g, V = RegExp(`>|${be}(?:([^\\s"'>=/]+)(${be}*=${be}*(?:[^ -\f\r"'\`<>=]|("|')|))|$)`, "g"), it = /'/g, nt = /"/g, $t = /^(?:script|style|textarea|title)$/i, ui = (e) => (t, ...i) => ({ _$litType$: e, strings: t, values: i }), h = ui(1), M = /* @__PURE__ */ Symbol.for("lit-noChange"), l = /* @__PURE__ */ Symbol.for("lit-nothing"), rt = /* @__PURE__ */ new WeakMap(), U = O.createTreeWalker(O, 129); -function Ct(e, t) { - if (!Ie(e) || !e.hasOwnProperty("raw")) throw Error("invalid template strings array"); - return Xe !== void 0 ? Xe.createHTML(t) : t; +B.elementStyles = [], B.shadowRootOptions = { mode: "open" }, B[G("elementProperties")] = /* @__PURE__ */ new Map(), B[G("finalized")] = /* @__PURE__ */ new Map(), hi?.({ ReactiveElement: B }), (ge.reactiveElementVersions ??= []).push("2.1.2"); +const Ue = globalThis, tt = (e) => e, le = Ue.trustedTypes, it = le ? le.createPolicy("lit-html", { createHTML: (e) => e }) : void 0, Ct = "$lit$", M = `lit$${Math.random().toFixed(9).slice(2)}$`, Et = "?" + M, ui = `<${Et}>`, z = document, Q = () => z.createComment(""), Y = (e) => e === null || typeof e != "object" && typeof e != "function", He = Array.isArray, pi = (e) => He(e) || typeof e?.[Symbol.iterator] == "function", we = `[ +\f\r]`, Z = /<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g, nt = /-->/g, rt = />/g, U = RegExp(`>|${we}(?:([^\\s"'>=/]+)(${we}*=${we}*(?:[^ +\f\r"'\`<>=]|("|')|))|$)`, "g"), ot = /'/g, st = /"/g, xt = /^(?:script|style|textarea|title)$/i, vi = (e) => (t, ...i) => ({ _$litType$: e, strings: t, values: i }), h = vi(1), D = /* @__PURE__ */ Symbol.for("lit-noChange"), l = /* @__PURE__ */ Symbol.for("lit-nothing"), at = /* @__PURE__ */ new WeakMap(), H = z.createTreeWalker(z, 129); +function Pt(e, t) { + if (!He(e) || !e.hasOwnProperty("raw")) throw Error("invalid template strings array"); + return it !== void 0 ? it.createHTML(t) : t; } -const pi = (e, t) => { +const gi = (e, t) => { const i = e.length - 1, n = []; - let r, o = t === 2 ? "" : t === 3 ? "" : "", a = j; + let r, o = t === 2 ? "" : t === 3 ? "" : "", a = Z; for (let d = 0; d < i; d++) { const c = e[d]; let u, p, v = -1, y = 0; - for (; y < c.length && (a.lastIndex = y, p = a.exec(c), p !== null); ) y = a.lastIndex, a === j ? p[1] === "!--" ? a = et : p[1] !== void 0 ? a = tt : p[2] !== void 0 ? ($t.test(p[2]) && (r = RegExp("" ? (a = r ?? j, v = -1) : p[1] === void 0 ? v = -2 : (v = a.lastIndex - p[2].length, u = p[1], a = p[3] === void 0 ? V : p[3] === '"' ? nt : it) : a === nt || a === it ? a = V : a === et || a === tt ? a = j : (a = V, r = void 0); - const A = a === V && e[d + 1].startsWith("/>") ? " " : ""; - o += a === j ? c + li : v >= 0 ? (n.push(u), c.slice(0, v) + Tt + c.slice(v) + L + A) : c + L + (v === -2 ? d : A); + for (; y < c.length && (a.lastIndex = y, p = a.exec(c), p !== null); ) y = a.lastIndex, a === Z ? p[1] === "!--" ? a = nt : p[1] !== void 0 ? a = rt : p[2] !== void 0 ? (xt.test(p[2]) && (r = RegExp("" ? (a = r ?? Z, v = -1) : p[1] === void 0 ? v = -2 : (v = a.lastIndex - p[2].length, u = p[1], a = p[3] === void 0 ? U : p[3] === '"' ? st : ot) : a === st || a === ot ? a = U : a === nt || a === rt ? a = Z : (a = U, r = void 0); + const A = a === U && e[d + 1].startsWith("/>") ? " " : ""; + o += a === Z ? c + ui : v >= 0 ? (n.push(u), c.slice(0, v) + Ct + c.slice(v) + M + A) : c + M + (v === -2 ? d : A); } - return [Ct(e, o + (e[i] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; + return [Pt(e, o + (e[i] || "") + (t === 2 ? "" : t === 3 ? "" : "")), n]; }; -class Y { +class J { constructor({ strings: t, _$litType$: i }, n) { let r; this.parts = []; let o = 0, a = 0; - const d = t.length - 1, c = this.parts, [u, p] = pi(t, i); - if (this.el = Y.createElement(u, n), U.currentNode = this.el.content, i === 2 || i === 3) { + const d = t.length - 1, c = this.parts, [u, p] = gi(t, i); + if (this.el = J.createElement(u, n), H.currentNode = this.el.content, i === 2 || i === 3) { const v = this.el.content.firstChild; v.replaceWith(...v.childNodes); } - for (; (r = U.nextNode()) !== null && c.length < d; ) { + for (; (r = H.nextNode()) !== null && c.length < d; ) { if (r.nodeType === 1) { - if (r.hasAttributes()) for (const v of r.getAttributeNames()) if (v.endsWith(Tt)) { - const y = p[a++], A = r.getAttribute(v).split(L), F = /([.?@])?(.*)/.exec(y); - c.push({ type: 1, index: o, name: F[2], strings: A, ctor: F[1] === "." ? gi : F[1] === "?" ? fi : F[1] === "@" ? mi : ge }), r.removeAttribute(v); - } else v.startsWith(L) && (c.push({ type: 6, index: o }), r.removeAttribute(v)); - if ($t.test(r.tagName)) { - const v = r.textContent.split(L), y = v.length - 1; + if (r.hasAttributes()) for (const v of r.getAttributeNames()) if (v.endsWith(Ct)) { + const y = p[a++], A = r.getAttribute(v).split(M), q = /([.?@])?(.*)/.exec(y); + c.push({ type: 1, index: o, name: q[2], strings: A, ctor: q[1] === "." ? mi : q[1] === "?" ? _i : q[1] === "@" ? bi : fe }), r.removeAttribute(v); + } else v.startsWith(M) && (c.push({ type: 6, index: o }), r.removeAttribute(v)); + if (xt.test(r.tagName)) { + const v = r.textContent.split(M), y = v.length - 1; if (y > 0) { - r.textContent = ce ? ce.emptyScript : ""; - for (let A = 0; A < y; A++) r.append(v[A], G()), U.nextNode(), c.push({ type: 2, index: ++o }); - r.append(v[y], G()); + r.textContent = le ? le.emptyScript : ""; + for (let A = 0; A < y; A++) r.append(v[A], Q()), H.nextNode(), c.push({ type: 2, index: ++o }); + r.append(v[y], Q()); } } - } else if (r.nodeType === 8) if (r.data === Rt) c.push({ type: 2, index: o }); + } else if (r.nodeType === 8) if (r.data === Et) c.push({ type: 2, index: o }); else { let v = -1; - for (; (v = r.data.indexOf(L, v + 1)) !== -1; ) c.push({ type: 7, index: o }), v += L.length - 1; + for (; (v = r.data.indexOf(M, v + 1)) !== -1; ) c.push({ type: 7, index: o }), v += M.length - 1; } o++; } } static createElement(t, i) { - const n = O.createElement("template"); + const n = z.createElement("template"); return n.innerHTML = t, n; } } -function B(e, t, i = e, n) { - if (t === M) return t; +function W(e, t, i = e, n) { + if (t === D) return t; let r = n !== void 0 ? i._$Co?.[n] : i._$Cl; - const o = Q(t) ? void 0 : t._$litDirective$; - return r?.constructor !== o && (r?._$AO?.(!1), o === void 0 ? r = void 0 : (r = new o(e), r._$AT(e, i, n)), n !== void 0 ? (i._$Co ??= [])[n] = r : i._$Cl = r), r !== void 0 && (t = B(e, r._$AS(e, t.values), r, n)), t; + const o = Y(t) ? void 0 : t._$litDirective$; + return r?.constructor !== o && (r?._$AO?.(!1), o === void 0 ? r = void 0 : (r = new o(e), r._$AT(e, i, n)), n !== void 0 ? (i._$Co ??= [])[n] = r : i._$Cl = r), r !== void 0 && (t = W(e, r._$AS(e, t.values), r, n)), t; } -class vi { +class fi { constructor(t, i) { this._$AV = [], this._$AN = void 0, this._$AD = t, this._$AM = i; } @@ -321,24 +321,24 @@ class vi { return this._$AM._$AU; } u(t) { - const { el: { content: i }, parts: n } = this._$AD, r = (t?.creationScope ?? O).importNode(i, !0); - U.currentNode = r; - let o = U.nextNode(), a = 0, d = 0, c = n[0]; + const { el: { content: i }, parts: n } = this._$AD, r = (t?.creationScope ?? z).importNode(i, !0); + H.currentNode = r; + let o = H.nextNode(), a = 0, d = 0, c = n[0]; for (; c !== void 0; ) { if (a === c.index) { let u; - c.type === 2 ? u = new ee(o, o.nextSibling, this, t) : c.type === 1 ? u = new c.ctor(o, c.name, c.strings, this, t) : c.type === 6 && (u = new _i(o, this, t)), this._$AV.push(u), c = n[++d]; + c.type === 2 ? u = new te(o, o.nextSibling, this, t) : c.type === 1 ? u = new c.ctor(o, c.name, c.strings, this, t) : c.type === 6 && (u = new wi(o, this, t)), this._$AV.push(u), c = n[++d]; } - a !== c?.index && (o = U.nextNode(), a++); + a !== c?.index && (o = H.nextNode(), a++); } - return U.currentNode = O, r; + return H.currentNode = z, r; } p(t) { let i = 0; for (const n of this._$AV) n !== void 0 && (n.strings !== void 0 ? (n._$AI(t, n, i), i += n.strings.length - 2) : n._$AI(t[i])), i++; } } -class ee { +class te { get _$AU() { return this._$AM?._$AU ?? this._$Cv; } @@ -357,7 +357,7 @@ class ee { return this._$AB; } _$AI(t, i = this) { - t = B(this, t, i), Q(t) ? t === l || t == null || t === "" ? (this._$AH !== l && this._$AR(), this._$AH = l) : t !== this._$AH && t !== M && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : hi(t) ? this.k(t) : this._(t); + t = W(this, t, i), Y(t) ? t === l || t == null || t === "" ? (this._$AH !== l && this._$AR(), this._$AH = l) : t !== this._$AH && t !== D && this._(t) : t._$litType$ !== void 0 ? this.$(t) : t.nodeType !== void 0 ? this.T(t) : pi(t) ? this.k(t) : this._(t); } O(t) { return this._$AA.parentNode.insertBefore(t, this._$AB); @@ -366,38 +366,38 @@ class ee { this._$AH !== t && (this._$AR(), this._$AH = this.O(t)); } _(t) { - this._$AH !== l && Q(this._$AH) ? this._$AA.nextSibling.data = t : this.T(O.createTextNode(t)), this._$AH = t; + this._$AH !== l && Y(this._$AH) ? this._$AA.nextSibling.data = t : this.T(z.createTextNode(t)), this._$AH = t; } $(t) { - const { values: i, _$litType$: n } = t, r = typeof n == "number" ? this._$AC(t) : (n.el === void 0 && (n.el = Y.createElement(Ct(n.h, n.h[0]), this.options)), n); + const { values: i, _$litType$: n } = t, r = typeof n == "number" ? this._$AC(t) : (n.el === void 0 && (n.el = J.createElement(Pt(n.h, n.h[0]), this.options)), n); if (this._$AH?._$AD === r) this._$AH.p(i); else { - const o = new vi(r, this), a = o.u(this.options); + const o = new fi(r, this), a = o.u(this.options); o.p(i), this.T(a), this._$AH = o; } } _$AC(t) { - let i = rt.get(t.strings); - return i === void 0 && rt.set(t.strings, i = new Y(t)), i; + let i = at.get(t.strings); + return i === void 0 && at.set(t.strings, i = new J(t)), i; } k(t) { - Ie(this._$AH) || (this._$AH = [], this._$AR()); + He(this._$AH) || (this._$AH = [], this._$AR()); const i = this._$AH; let n, r = 0; - for (const o of t) r === i.length ? i.push(n = new ee(this.O(G()), this.O(G()), this, this.options)) : n = i[r], n._$AI(o), r++; + for (const o of t) r === i.length ? i.push(n = new te(this.O(Q()), this.O(Q()), this, this.options)) : n = i[r], n._$AI(o), r++; r < i.length && (this._$AR(n && n._$AB.nextSibling, r), i.length = r); } _$AR(t = this._$AA.nextSibling, i) { for (this._$AP?.(!1, !0, i); t !== this._$AB; ) { - const n = Je(t).nextSibling; - Je(t).remove(), t = n; + const n = tt(t).nextSibling; + tt(t).remove(), t = n; } } setConnected(t) { this._$AM === void 0 && (this._$Cv = t, this._$AP?.(t)); } } -class ge { +class fe { get tagName() { return this.element.tagName; } @@ -410,11 +410,11 @@ class ge { _$AI(t, i = this, n, r) { const o = this.strings; let a = !1; - if (o === void 0) t = B(this, t, i, 0), a = !Q(t) || t !== this._$AH && t !== M, a && (this._$AH = t); + if (o === void 0) t = W(this, t, i, 0), a = !Y(t) || t !== this._$AH && t !== D, a && (this._$AH = t); else { const d = t; let c, u; - for (t = o[0], c = 0; c < o.length - 1; c++) u = B(this, d[n + c], i, c), u === M && (u = this._$AH[c]), a ||= !Q(u) || u !== this._$AH[c], u === l ? t = l : t !== l && (t += (u ?? "") + o[c + 1]), this._$AH[c] = u; + for (t = o[0], c = 0; c < o.length - 1; c++) u = W(this, d[n + c], i, c), u === D && (u = this._$AH[c]), a ||= !Y(u) || u !== this._$AH[c], u === l ? t = l : t !== l && (t += (u ?? "") + o[c + 1]), this._$AH[c] = u; } a && !r && this.j(t); } @@ -422,7 +422,7 @@ class ge { t === l ? this.element.removeAttribute(this.name) : this.element.setAttribute(this.name, t ?? ""); } } -class gi extends ge { +class mi extends fe { constructor() { super(...arguments), this.type = 3; } @@ -430,7 +430,7 @@ class gi extends ge { this.element[this.name] = t === l ? void 0 : t; } } -class fi extends ge { +class _i extends fe { constructor() { super(...arguments), this.type = 4; } @@ -438,12 +438,12 @@ class fi extends ge { this.element.toggleAttribute(this.name, !!t && t !== l); } } -class mi extends ge { +class bi extends fe { constructor(t, i, n, r, o) { super(t, i, n, r, o), this.type = 5; } _$AI(t, i = this) { - if ((t = B(this, t, i, 0) ?? l) === M) return; + if ((t = W(this, t, i, 0) ?? l) === D) return; const n = this._$AH, r = t === l && n !== l || t.capture !== n.capture || t.once !== n.once || t.passive !== n.passive, o = t !== l && (n === l || r); r && this.element.removeEventListener(this.name, this, n), o && this.element.addEventListener(this.name, this, t), this._$AH = t; } @@ -451,7 +451,7 @@ class mi extends ge { typeof this._$AH == "function" ? this._$AH.call(this.options?.host ?? this.element, t) : this._$AH.handleEvent(t); } } -class _i { +class wi { constructor(t, i, n) { this.element = t, this.type = 6, this._$AN = void 0, this._$AM = i, this.options = n; } @@ -459,22 +459,22 @@ class _i { return this._$AM._$AU; } _$AI(t) { - B(this, t); + W(this, t); } } -const bi = De.litHtmlPolyfillSupport; -bi?.(Y, ee), (De.litHtmlVersions ??= []).push("3.3.3"); -const wi = (e, t, i) => { +const yi = Ue.litHtmlPolyfillSupport; +yi?.(J, te), (Ue.litHtmlVersions ??= []).push("3.3.3"); +const ki = (e, t, i) => { const n = i?.renderBefore ?? t; let r = n._$litPart$; if (r === void 0) { const o = i?.renderBefore ?? null; - n._$litPart$ = r = new ee(t.insertBefore(G(), o), o, void 0, i ?? {}); + n._$litPart$ = r = new te(t.insertBefore(Q(), o), o, void 0, i ?? {}); } return r._$AI(e), r; }; -const Ve = globalThis; -let C = class extends N { +const Oe = globalThis; +let C = class extends B { constructor() { super(...arguments), this.renderOptions = { host: this }, this._$Do = void 0; } @@ -484,7 +484,7 @@ let C = class extends N { } update(t) { const i = this.render(); - this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = wi(i, this.renderRoot, this.renderOptions); + this.hasUpdated || (this.renderOptions.isConnected = this.isConnected), super.update(t), this._$Do = ki(i, this.renderRoot, this.renderOptions); } connectedCallback() { super.connectedCallback(), this._$Do?.setConnected(!0); @@ -493,15 +493,15 @@ let C = class extends N { super.disconnectedCallback(), this._$Do?.setConnected(!1); } render() { - return M; + return D; } }; -C._$litElement$ = !0, C.finalized = !0, Ve.litElementHydrateSupport?.({ LitElement: C }); -const yi = Ve.litElementPolyfillSupport; -yi?.({ LitElement: C }); -(Ve.litElementVersions ??= []).push("4.2.2"); -const Et = { ATTRIBUTE: 1 }, Ue = (e) => (...t) => ({ _$litDirective$: e, values: t }); -let He = class { +C._$litElement$ = !0, C.finalized = !0, Oe.litElementHydrateSupport?.({ LitElement: C }); +const Ai = Oe.litElementPolyfillSupport; +Ai?.({ LitElement: C }); +(Oe.litElementVersions ??= []).push("4.2.2"); +const Lt = { ATTRIBUTE: 1 }, ze = (e) => (...t) => ({ _$litDirective$: e, values: t }); +let Fe = class { constructor(t) { } get _$AU() { @@ -517,9 +517,9 @@ let He = class { return this.render(...i); } }; -const xt = "important", ki = " !" + xt, ke = Ue(class extends He { +const Mt = "important", Si = " !" + Mt, Ae = ze(class extends Fe { constructor(e) { - if (super(e), e.type !== Et.ATTRIBUTE || e.name !== "style" || e.strings?.length > 2) throw Error("The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute."); + if (super(e), e.type !== Lt.ATTRIBUTE || e.name !== "style" || e.strings?.length > 2) throw Error("The `styleMap` directive must be used in the `style` attribute and must be the only part in the attribute."); } render(e) { return Object.keys(e).reduce((t, i) => { @@ -535,19 +535,19 @@ const xt = "important", ki = " !" + xt, ke = Ue(class extends He { const r = t[n]; if (r != null) { this.ft.add(n); - const o = typeof r == "string" && r.endsWith(ki); - n.includes("-") || o ? i.setProperty(n, o ? r.slice(0, -11) : r, o ? xt : "") : i[n] = r; + const o = typeof r == "string" && r.endsWith(Si); + n.includes("-") || o ? i.setProperty(n, o ? r.slice(0, -11) : r, o ? Mt : "") : i[n] = r; } } - return M; + return D; } }); -const W = (e) => (t, i) => { +const j = (e) => (t, i) => { i !== void 0 ? i.addInitializer(() => { customElements.define(e, t); }) : customElements.define(e, t); }; -const Ai = { attribute: !0, type: String, converter: de, reflect: !1, hasChanged: Me }, Si = (e = Ai, t, i) => { +const Ti = { attribute: !0, type: String, converter: ce, reflect: !1, hasChanged: Ve }, Ri = (e = Ti, t, i) => { const { kind: n, metadata: r } = i; let o = globalThis.litPropertyMetadata.get(r); if (o === void 0 && globalThis.litPropertyMetadata.set(r, o = /* @__PURE__ */ new Map()), n === "setter" && ((e = Object.create(e)).wrapped = !0), o.set(i.name, e), n === "accessor") { @@ -569,7 +569,7 @@ const Ai = { attribute: !0, type: String, converter: de, reflect: !1, hasChanged throw Error("Unsupported decorator location: " + n); }; function _(e) { - return (t, i) => typeof i == "object" ? Si(e, t, i) : ((n, r, o) => { + return (t, i) => typeof i == "object" ? Ri(e, t, i) : ((n, r, o) => { const a = r.hasOwnProperty(o); return r.constructor.createProperty(o, n), a ? Object.getOwnPropertyDescriptor(r, o) : void 0; })(e, t, i); @@ -577,7 +577,7 @@ function _(e) { function g(e) { return _({ ...e, state: !0, attribute: !1 }); } -const Ti = ` +const $i = ` @@ -608,7 +608,7 @@ const Ti = ` -`, Ri = { +`, Ci = { "common.camera": "Kamera", "common.close": "Schließen", "common.last_recording": "Letzte Aufnahme", @@ -701,6 +701,7 @@ const Ti = ` "u" ? void 0 : document.documentElement.lang, i = typeof navigator > "u" ? void 0 : navigator.language; return e?.language || e?.locale?.language || t || i || "en"; } -function Pt(e) { - return Ci(e).trim().toLowerCase().split(/[-_]/)[0] === "de" ? "de" : "en"; +function Dt(e) { + return xi(e).trim().toLowerCase().split(/[-_]/)[0] === "de" ? "de" : "en"; } function s(e, t, i = {}) { - return (ot[Pt(e)][t] ?? ot.en[t]).replace( + return (dt[Dt(e)][t] ?? dt.en[t]).replace( /\{([a-z_]+)\}/gi, (r, o) => o in i ? String(i[o]) : r ); } -function Lt(e, t, i) { +function It(e, t, i) { return e?.localize?.(t) || s(e, i); } -function Ei(e) { +function Pi(e) { return e?.platform === "ring" ? "official_ring" : e?.platform === "mqtt" ? "mqtt" : "other"; } -function z(e, t, i) { +function F(e, t, i) { const n = e.entities?.[i]; return { role: t, entityId: i, entity: e.states[i], registry: n, - provider: Ei(n), + provider: Pi(n), deviceId: n?.device_id ?? void 0 }; } -function Mt(e, t) { +function Vt(e, t) { const i = e.entities?.[t]?.device_id; return i ? Object.values(e.entities ?? {}).filter((n) => n.device_id === i && n.disabled_by == null && !!e.states[n.entity_id]).map((n) => n.entity_id).sort() : []; } -function le(e, t, i) { - const n = Mt(e, t).filter((r) => { +function he(e, t, i) { + const n = Vt(e, t).filter((r) => { const o = e.states[r], a = e.entities?.[r]; return !!(o && a && i(r, o, a)); }); return n.length === 1 ? n[0] : void 0; } -const xi = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:?\d{2})?$/i, Pi = /^-?\d+(?:\.\d+)?$/, Li = 1e11, Mi = [ +const Li = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:?\d{2})?$/i, Mi = /^-?\d+(?:\.\d+)?$/, Di = 1e11, Ii = [ "lastDingTime", "lastMotionTime", "lastDing", "lastMotion" -], Dt = /* @__PURE__ */ new Set(["unknown", "unavailable"]); -function Di(e) { +], Ut = /* @__PURE__ */ new Set(["unknown", "unavailable"]); +function Vi(e) { const t = Number(e[1]), i = Number(e[2]), n = Number(e[3]), r = Number(e[4]), o = Number(e[5]), a = Number(e[6] ?? 0), d = new Date(Date.UTC(t, i, 0)).getUTCDate(); return i >= 1 && i <= 12 && n >= 1 && n <= d && r >= 0 && r <= 23 && o >= 0 && o <= 59 && a >= 0 && a <= 59; } -function It(e) { +function P(e) { if (typeof e != "string" && typeof e != "number") return; const t = String(e).trim(); if (!t || ["unknown", "unavailable", "none", "null"].includes(t.toLowerCase())) return; - if (Pi.test(t)) { + if (Mi.test(t)) { const o = Number(t); if (!Number.isFinite(o)) return; - const a = Math.abs(o) < Li ? o * 1e3 : o; + const a = Math.abs(o) < Di ? o * 1e3 : o; return Number.isFinite(new Date(a).getTime()) ? a : void 0; } - const i = t.match(xi); - if (!i || !Di(i)) return; + const i = t.match(Li); + if (!i || !Vi(i)) return; const n = t.includes(" ") ? t.replace(" ", "T") : t, r = Date.parse(n); return Number.isFinite(r) ? r : void 0; } -function Ii(e) { - return It(e?.state); +function Ui(e) { + return P(e?.state); } -function oe(e) { +function se(e) { const t = e.filter((i) => i !== void 0); return t.length > 0 ? Math.max(...t) : void 0; } -function st(e) { - if (!(!e || Dt.has(e.state))) - return oe( - Mi.map( - (t) => It(e.attributes[t]) +function ct(e) { + if (!(!e || Ut.has(e.state))) + return se( + Ii.map( + (t) => P(e.attributes[t]) ) ); } -function H(e, t) { - const i = e.states[t], n = i && !Dt.has(i.state), r = n ? [Ii(i)] : []; - if (!t.startsWith("binary_sensor.") || (n && r.push(st(i)), z(e, "activity", t).provider !== "mqtt")) return oe(r); - for (const a of Mt(e, t)) - a === t || !a.startsWith("binary_sensor.") || z(e, "activity", a).provider !== "mqtt" || r.push(st(e.states[a])); - return oe(r); +function O(e, t) { + const i = e.states[t], n = i && !Ut.has(i.state), r = n ? [Ui(i)] : []; + if (!t.startsWith("binary_sensor.") || (n && r.push(ct(i)), F(e, "activity", t).provider !== "mqtt")) return se(r); + for (const a of Vt(e, t)) + a === t || !a.startsWith("binary_sensor.") || F(e, "activity", a).provider !== "mqtt" || r.push(ct(e.states[a])); + return se(r); } -function Vi(e) { +function Hi(e) { const t = Math.abs(e); return t < 60 ? { unit: "second", seconds: 1 } : t < 3600 ? { unit: "minute", seconds: 60 } : t < 86400 ? { unit: "hour", seconds: 3600 } : t < 2629746 ? { unit: "day", seconds: 86400 } : t < 31556952 ? { unit: "month", seconds: 2629746 } : { unit: "year", seconds: 31556952 }; } -function Ui(e, t) { +function Oi(e, t) { const i = Math.trunc(e / t); return Object.is(i, -0) ? 0 : i; } -function Vt(e, t, i = Date.now()) { - const n = Pt(e), r = (t - i) / 1e3, { unit: o, seconds: a } = Vi(r), d = Ui(r, a), c = new Intl.RelativeTimeFormat(n, { +function Ht(e, t, i = Date.now()) { + const n = Dt(e), r = (t - i) / 1e3, { unit: o, seconds: a } = Hi(r), d = Oi(r, a), c = new Intl.RelativeTimeFormat(n, { numeric: "auto", style: "short" }).format(d, o), u = n === "en" ? c.replace(/\b(sec|min|hr)\./g, "$1") : c, p = new Intl.RelativeTimeFormat(n, { @@ -1139,27 +1141,27 @@ function Vt(e, t, i = Date.now()) { timeStyle: "short" }).format(t); return { - relative: u, + relative: s(e, "activity.relative", { time: u }), accessible: s(e, "activity.accessible", { time: p }), title: s(e, "activity.title", { time: v }) }; } -var Hi = Object.defineProperty, Oi = Object.getOwnPropertyDescriptor, Oe = (e, t, i, n) => { - for (var r = n > 1 ? void 0 : n ? Oi(t, i) : t, o = e.length - 1, a; o >= 0; o--) +var zi = Object.defineProperty, Fi = Object.getOwnPropertyDescriptor, qe = (e, t, i, n) => { + for (var r = n > 1 ? void 0 : n ? Fi(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 && Hi(t, i, r), r; + return n && r && zi(t, i, r), r; }; -const zi = 3e4; -let J = class extends C { +const qi = 3e4; +let X = class extends C { disconnectedCallback() { this.clearRefreshTimer(), super.disconnectedCallback(); } render() { - const e = this.hass && this.entityId ? H(this.hass, this.entityId) : void 0; + const e = this.hass && this.entityId ? O(this.hass, this.entityId) : void 0; if (e === void 0) return l; - const t = Vt(this.hass, e); + const t = Ht(this.hass, e); return h` ${t.relative} @@ -1167,20 +1169,20 @@ let J = class extends C { `; } updated() { - this.clearRefreshTimer(), (this.hass && this.entityId ? H(this.hass, this.entityId) : void 0) !== void 0 && (this.refreshTimer = window.setTimeout(() => { + this.clearRefreshTimer(), (this.hass && this.entityId ? O(this.hass, this.entityId) : void 0) !== void 0 && (this.refreshTimer = window.setTimeout(() => { this.refreshTimer = void 0, this.requestUpdate(), this.dispatchEvent( new CustomEvent("ring-view-activity-tick", { bubbles: !0, composed: !0 }) ); - }, zi)); + }, qi)); } clearRefreshTimer() { this.refreshTimer !== void 0 && (window.clearTimeout(this.refreshTimer), this.refreshTimer = void 0); } }; -J.styles = K` +X.styles = K` :host { display: block; min-width: 0; @@ -1202,33 +1204,33 @@ J.styles = K` white-space: nowrap; } `; -Oe([ +qe([ _({ attribute: !1 }) -], J.prototype, "hass", 2); -Oe([ +], X.prototype, "hass", 2); +qe([ _({ attribute: "entity-id" }) -], J.prototype, "entityId", 2); -J = Oe([ - W("ring-view-activity-time") -], J); -const Fi = [ +], X.prototype, "entityId", 2); +X = qe([ + j("ring-view-activity-time") +], X); +const Ni = [ "video_url", "recordingUrl", "recording_url" -], qi = 3e4; -function Ni(e) { +], Bi = 3e4; +function Wi(e) { const t = e.trim().toLowerCase(); return t === "" || t.includes("") || t.includes(""); } -function fe(e) { +function me(e) { if (e) - for (const t of Fi) { + for (const t of Ni) { const i = e.attributes[t]; - if (!(typeof i != "string" || Ni(i))) + if (!(typeof i != "string" || Wi(i))) return i.trim(); } } -function Bi(e) { +function Ki(e) { let t; try { t = new URL(e, "http://homeassistant.local"); @@ -1236,8 +1238,8 @@ function Bi(e) { return; } const i = /* @__PURE__ */ new Map(); - t.searchParams.forEach((A, F) => { - i.set(F.toLowerCase(), A); + t.searchParams.forEach((A, q) => { + i.set(q.toLowerCase(), A); }); const n = i.get("x-amz-date"), r = Number(i.get("x-amz-expires")); if (!n || !Number.isFinite(r) || r < 0) return; @@ -1253,15 +1255,15 @@ function Bi(e) { ); return Number.isFinite(y) ? y + r * 1e3 : void 0; } -function we(e, t = Date.now()) { - const i = fe(e); +function ye(e, t = Date.now()) { + const i = me(e); if (!i) return !1; - const n = Bi(i); - return n === void 0 || n - t > qi; + const n = Ki(i); + return n === void 0 || n - t > Bi; } -function Ae(e) { +function Se(e) { if (!e) return; - const t = fe(e), i = e.attributes.eventId ?? e.attributes.event_id; + const t = me(e), i = e.attributes.eventId ?? e.attributes.event_id; if (t) return `${String(i ?? "")}:${t}`; for (const n of ["last_video_id", "entity_picture"]) { const r = e.attributes[n]; @@ -1270,38 +1272,38 @@ function Ae(e) { return `${n}:${r}`; } } -function Se(e, t) { +function Te(e, t) { if (!t.startsWith("select.")) return !1; - const i = z(e, "recording", t); + const i = F(e, "recording", t); if (i.provider !== "mqtt") return !1; const n = i.registry?.unique_id?.toLowerCase(), r = i.registry?.original_name?.trim().toLowerCase(); return n?.endsWith("_event_select") === !0 || r === "event select" || "recordingUrl" in (i.entity?.attributes ?? {}) || "eventId" in (i.entity?.attributes ?? {}); } -function at(e, t) { +function lt(e, t) { if (!t) return !1; const i = e.states[t]?.state; return i !== void 0 && i !== "unavailable" && i !== "unknown"; } -function Ut(e, t) { - return t.recording_entity.startsWith("camera.") ? t.recording_entity : at(e, t.snapshot_entity) ? t.snapshot_entity : (at(e, t.live_entity), t.live_entity); +function Ot(e, t) { + return t.recording_entity.startsWith("camera.") ? t.recording_entity : lt(e, t.snapshot_entity) ? t.snapshot_entity : (lt(e, t.live_entity), t.live_entity); } -const Ki = 2, Wi = 1; +const ji = 2, Zi = 1; function w(e) { return !e || e.state === "unavailable" || e.state === "unknown"; } -function Ht(e) { - return (Number(e?.attributes.supported_features ?? 0) & Ki) !== 0; +function zt(e) { + return (Number(e?.attributes.supported_features ?? 0) & ji) !== 0; } -function Te(e, t) { - return z(e, "live", t).provider === "official_ring" && Ht(e.states[t]); +function Re(e, t) { + return F(e, "live", t).provider === "official_ring" && zt(e.states[t]); } -function se(e) { - return (Number(e?.attributes.supported_features ?? 0) & Wi) !== 0; +function ae(e) { + return (Number(e?.attributes.supported_features ?? 0) & Zi) !== 0; } -function ji(e) { - return !!(fe(e) || e?.attributes.entity_picture); +function Gi(e) { + return !!(me(e) || e?.attributes.entity_picture); } -function Zi(e, t) { +function Qi(e, t) { if (!e || !t) return []; const i = e.states[t.recording_entity], n = e.states[t.live_entity], r = []; if (w(i) ? r.push({ @@ -1309,7 +1311,7 @@ function Zi(e, t) { message: s(e, "warning.unavailable", { name: T(i, t.recording_entity) }) - }) : ji(i) || r.push({ + }) : Gi(i) || r.push({ kind: "recording", message: s(e, "warning.recording_media") }), w(n) ? r.push({ @@ -1317,7 +1319,7 @@ function Zi(e, t) { message: s(e, "warning.unavailable", { name: T(n, t.live_entity) }) - }) : Ht(n) ? t.two_way_audio && !Te(e, t.live_entity) && r.push({ + }) : zt(n) ? t.two_way_audio && !Re(e, t.live_entity) && r.push({ kind: "talkback", message: s(e, "warning.talkback_unsupported") }) : r.push({ @@ -1342,7 +1344,7 @@ function Zi(e, t) { message: s(e, "warning.unavailable", { name: T(o, t.last_activity_entity) }) - }) : H(e, t.last_activity_entity) === void 0 && r.push({ + }) : O(e, t.last_activity_entity) === void 0 && r.push({ kind: "last_activity", message: s(e, "warning.activity_timestamp") }); @@ -1366,7 +1368,7 @@ function Zi(e, t) { message: s(e, "warning.unavailable", { name: T(o, t.door_entity) }) - }) : t.door_action === "open" && !se(o) && r.push({ + }) : t.door_action === "open" && !ae(o) && r.push({ kind: "door", message: s(e, "warning.door_open_unsupported") }); @@ -1388,7 +1390,7 @@ function Zi(e, t) { function T(e, t) { return e?.attributes.friendly_name || t; } -const Gi = [ +const Yi = [ "timestamp", "capture_timestamp", "captured_at", @@ -1396,7 +1398,7 @@ const Gi = [ "last_recording_at", "recording_timestamp" ]; -function Qi(e) { +function Ji(e) { if (typeof e == "number") return !Number.isFinite(e) || e <= 0 ? void 0 : e < 1e12 ? e * 1e3 : e; if (typeof e != "string" || e.trim() === "") return; @@ -1406,20 +1408,20 @@ function Qi(e) { const i = Date.parse(e); return Number.isFinite(i) && i > 0 ? i : void 0; } -function he(e) { +function ue(e) { if (e) - for (const t of Gi) { - const i = Qi(e.attributes[t]); + for (const t of Yi) { + const i = Ji(e.attributes[t]); if (i !== void 0) return i; } } -function Yi(e) { - return Ae(e); +function Xi(e) { + return Se(e); } function x(e, t) { - return Ut(e, t); + return Ot(e, t); } -function Ji(e, t, i) { +function en(e, t, i) { const n = t.recording_entity, r = t.snapshot_entity; if (!r) return x(e, t); const o = e.states[n], a = e.states[r], d = !w(o), c = !w(a); @@ -1427,7 +1429,7 @@ function Ji(e, t, i) { return x(e, t); if (c && !d) return r; if (d && c) { - const u = he(o), p = he(a); + const u = ue(o), p = ue(a); if (u !== void 0 && p !== void 0) return u !== p ? p > u ? r : x(e, t) : t.preview_fallback === "snapshot" ? r : x(e, t); if (i !== void 0) @@ -1435,85 +1437,85 @@ function Ji(e, t, i) { } return t.preview_fallback === "snapshot" ? r : x(e, t); } -function Xi(e, t, i, n) { +function tn(e, t, i, n) { switch (t.preview_source) { case "live": return t.live_entity; case "snapshot": return t.snapshot_entity ?? x(e, t); case "newest": - return Ji(e, t, n); + return en(e, t, n); case "default": return i === "live" ? t.live_entity : x(e, t); default: return x(e, t); } } -const en = "/media/ring-view"; -function me(e) { +const nn = "/media/ring-view"; +function _e(e) { const t = e.trim(); return t.length > 1 ? t.replace(/\/+$/, "") : t; } -function tn(e) { +function rn(e) { if (typeof e != "string" || e.trim() === "") return "required"; - const t = me(e); + const t = _e(e); if (!t.startsWith("/")) return "absolute"; if (t === "/") return "root"; if (/[\u0000-\u001f\u007f]/.test(t) || t.includes("{{") || t.includes("}}") || t.split("/").some((i) => i === "." || i === "..")) return "unsafe"; } -function nn(e) { - const t = me(e); +function on(e) { + const t = _e(e); if (t === "/config/www" || t.startsWith("/config/www/")) return "public"; if (!(t === "/media" || t.startsWith("/media/"))) return "custom"; } -function rn(e, t) { +function sn(e, t) { return t.snapshot_entity && !w(e.states[t.snapshot_entity]) ? t.snapshot_entity : w(e.states[t.live_entity]) ? void 0 : t.live_entity; } -function dt(e, t) { - return he(e.states[t]); +function ht(e, t) { + return ue(e.states[t]); } -function on(e, t) { - if (z(e, "snapshot", t).provider !== "mqtt") +function an(e, t) { + if (F(e, "snapshot", t).provider !== "mqtt") return; - const i = le( + const i = he( e, t, (n, r, o) => n.startsWith("button.") && o.platform === "mqtt" && (o.unique_id?.endsWith("_take_snapshot") === !0 || o.original_name === "Take Snapshot") ); - return i || le( + return i || he( e, t, (n, r, o) => n.startsWith("button.") && o.platform === "mqtt" ); } -function sn(e, t) { - if (z(e, "snapshot", t).provider !== "mqtt") +function dn(e, t) { + if (F(e, "snapshot", t).provider !== "mqtt") return; - const i = le( + const i = he( e, t, (n, r, o) => n.startsWith("camera.") && o.platform === "mqtt" && (o.unique_id?.endsWith("_snapshot") === !0 || o.original_name === "Snapshot") ); - return i || le( + return i || he( e, t, (n, r, o) => n.startsWith("camera.") && o.platform === "mqtt" ); } -function an(e, t) { - return z(e, "snapshot", t).provider === "mqtt"; +function cn(e, t) { + return F(e, "snapshot", t).provider === "mqtt"; } -function dn(e, t) { +function ln(e, t) { const i = e.states[t.live_entity]; return (t.name || T(i, t.live_entity.split(".", 2)[1] || "ring-camera")).normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "") || "ring-camera"; } -function q(e, t) { +function N(e, t) { return e.find((i) => i.type === t)?.value ?? "00"; } -function cn(e, t) { +function hn(e, t) { const i = Number.isFinite(e.getTime()) ? e : /* @__PURE__ */ new Date(0), n = { timeZone: t, year: "numeric", @@ -1532,15 +1534,15 @@ function cn(e, t) { } const o = String(i.getMilliseconds()).padStart(3, "0"); return [ - `${q(r, "year")}-${q(r, "month")}-${q(r, "day")}`, - `${q(r, "hour")}-${q(r, "minute")}-${q(r, "second")}-${o}` + `${N(r, "year")}-${N(r, "month")}-${N(r, "day")}`, + `${N(r, "hour")}-${N(r, "minute")}-${N(r, "second")}-${o}` ].join("_"); } -function ln(e, t, i = /* @__PURE__ */ new Date()) { - const n = me(t.snapshot_directory), r = cn(i, e.config?.time_zone); - return `${n}/${dn(e, t)}_${r}.jpg`; +function un(e, t, i = /* @__PURE__ */ new Date()) { + const n = _e(t.snapshot_directory), r = hn(i, e.config?.time_zone); + return `${n}/${ln(e, t)}_${r}.jpg`; } -const ze = "custom:ring-view", Re = "ring-view", hn = "Ring View", b = { +const Ne = "custom:ring-view", $e = "ring-view", pn = "Ring View", b = { default_mode: "last_recording", remember_last_mode: !1, autoplay_recording: !0, @@ -1557,20 +1559,20 @@ const ze = "custom:ring-view", Re = "ring-view", hn = "Ring View", b = { preview_source: "last_recording", preview_fallback: "last_recording", show_snapshot_button: !1, - snapshot_directory: en, + snapshot_directory: nn, aspect_ratio: "16:9", fit_mode: "cover" -}, un = /* @__PURE__ */ new Set(["last_recording", "live"]), pn = /* @__PURE__ */ new Set([ +}, vn = /* @__PURE__ */ new Set(["last_recording", "live"]), gn = /* @__PURE__ */ new Set([ "last_recording", "live", "default", "snapshot", "newest" -]), vn = /* @__PURE__ */ new Set(["last_recording", "snapshot"]), gn = /* @__PURE__ */ new Set(["auto", "16:9", "4:3", "1:1"]), fn = /* @__PURE__ */ new Set(["cover", "contain"]), mn = /* @__PURE__ */ new Set(["unlock", "open"]), _n = /* @__PURE__ */ new Set(["live_only", "all_views"]), bn = /* @__PURE__ */ new Set([ +]), fn = /* @__PURE__ */ new Set(["last_recording", "snapshot"]), mn = /* @__PURE__ */ new Set(["auto", "16:9", "4:3", "1:1"]), _n = /* @__PURE__ */ new Set(["cover", "contain"]), bn = /* @__PURE__ */ new Set(["unlock", "open"]), wn = /* @__PURE__ */ new Set(["live_only", "all_views"]), yn = /* @__PURE__ */ new Set([ "viewer_only", "dashboard_and_viewer" -]), wn = /* @__PURE__ */ new Set(["open_viewer", "interactive"]), yn = /* @__PURE__ */ new Set(["on_demand", "last_recording", "live"]), kn = ["event.", "binary_sensor."]; -function ct(e, t) { +]), kn = /* @__PURE__ */ new Set(["open_viewer", "interactive"]), An = /* @__PURE__ */ new Set(["on_demand", "last_recording", "live"]), Sn = ["event.", "binary_sensor."]; +function ut(e, t) { if (typeof e != "string" || !e.startsWith("camera.")) throw new Error( s(void 0, "config.entity_required", { @@ -1578,52 +1580,52 @@ function ct(e, t) { }) ); } -function An(e) { +function Tn(e) { if (typeof e != "string" || !e.startsWith("camera.") && !e.startsWith("select.")) throw new Error(s(void 0, "config.recording_source_required")); } -function Sn(e) { +function Rn(e) { if (!e || typeof e != "object") throw new Error(s(void 0, "config.invalid")); - if (An(e.recording_entity), ct(e.live_entity, "config.live_entity"), e.snapshot_entity !== void 0 && e.snapshot_entity !== "" && ct(e.snapshot_entity, "config.snapshot_entity"), e.last_activity_entity !== void 0 && e.last_activity_entity !== "" && (typeof e.last_activity_entity != "string" || !/^[a-z0-9_]+\.[a-z0-9_]+$/i.test(e.last_activity_entity))) + if (Tn(e.recording_entity), ut(e.live_entity, "config.live_entity"), e.snapshot_entity !== void 0 && e.snapshot_entity !== "" && ut(e.snapshot_entity, "config.snapshot_entity"), e.last_activity_entity !== void 0 && e.last_activity_entity !== "" && (typeof e.last_activity_entity != "string" || !/^[a-z0-9_]+\.[a-z0-9_]+$/i.test(e.last_activity_entity))) throw new Error(s(void 0, "config.last_activity_entity")); - if (e.default_mode && !un.has(e.default_mode)) + if (e.default_mode && !vn.has(e.default_mode)) throw new Error(s(void 0, "config.default_mode")); - if (e.preview_source && !pn.has(e.preview_source)) + if (e.preview_source && !gn.has(e.preview_source)) throw new Error(s(void 0, "config.preview_source")); - if (e.preview_fallback && !vn.has(e.preview_fallback)) + if (e.preview_fallback && !fn.has(e.preview_fallback)) throw new Error(s(void 0, "config.preview_fallback")); if (e.snapshot_directory !== void 0) { - const t = tn(e.snapshot_directory); + const t = rn(e.snapshot_directory); if (t) throw new Error( s(void 0, `config.snapshot_directory_${t}`) ); } - if (e.aspect_ratio && !gn.has(e.aspect_ratio)) + if (e.aspect_ratio && !mn.has(e.aspect_ratio)) throw new Error(s(void 0, "config.aspect_ratio")); - if (e.fit_mode && !fn.has(e.fit_mode)) + if (e.fit_mode && !_n.has(e.fit_mode)) throw new Error(s(void 0, "config.fit_mode")); - if (e.doorbell_entity !== void 0 && e.doorbell_entity !== "" && (typeof e.doorbell_entity != "string" || !kn.some((t) => e.doorbell_entity.startsWith(t)))) + if (e.doorbell_entity !== void 0 && e.doorbell_entity !== "" && (typeof e.doorbell_entity != "string" || !Sn.some((t) => e.doorbell_entity.startsWith(t)))) throw new Error(s(void 0, "config.doorbell_entity")); if (e.door_entity !== void 0 && e.door_entity !== "" && (typeof e.door_entity != "string" || !e.door_entity.startsWith("lock."))) throw new Error(s(void 0, "config.door_entity")); if (e.door_contact_entity !== void 0 && e.door_contact_entity !== "" && (typeof e.door_contact_entity != "string" || !e.door_contact_entity.startsWith("binary_sensor."))) throw new Error(s(void 0, "config.door_contact_entity")); - if (e.door_action && !mn.has(e.door_action)) + if (e.door_action && !bn.has(e.door_action)) throw new Error(s(void 0, "config.door_action")); - if (e.door_control_visibility && !_n.has(e.door_control_visibility)) + if (e.door_control_visibility && !wn.has(e.door_control_visibility)) throw new Error(s(void 0, "config.door_control_visibility")); - if (e.door_control_location && !bn.has(e.door_control_location)) + if (e.door_control_location && !yn.has(e.door_control_location)) throw new Error(s(void 0, "config.door_control_location")); - if (e.dashboard_behavior && !wn.has(e.dashboard_behavior)) + if (e.dashboard_behavior && !kn.has(e.dashboard_behavior)) throw new Error(s(void 0, "config.dashboard_behavior")); - if (e.dashboard_start && !yn.has(e.dashboard_start)) + if (e.dashboard_start && !An.has(e.dashboard_start)) throw new Error(s(void 0, "config.dashboard_start")); } -function $e(e) { - return Sn(e), { - type: e.type ?? ze, +function Ce(e) { + return Rn(e), { + type: e.type ?? Ne, recording_entity: e.recording_entity, live_entity: e.live_entity, snapshot_entity: e.snapshot_entity || void 0, @@ -1648,7 +1650,7 @@ function $e(e) { preview_source: e.preview_source ?? b.preview_source, preview_fallback: e.preview_fallback ?? b.preview_fallback, show_snapshot_button: e.show_snapshot_button ?? b.show_snapshot_button, - snapshot_directory: me( + snapshot_directory: _e( e.snapshot_directory ?? b.snapshot_directory ), aspect_ratio: e.aspect_ratio ?? b.aspect_ratio, @@ -1656,7 +1658,7 @@ function $e(e) { grid_options: e.grid_options }; } -function Ot(e) { +function Ft(e) { switch (e) { case "16:9": return 16 / 9; @@ -1668,18 +1670,18 @@ function Ot(e) { return; } } -function Tn(e) { +function $n(e) { return e === "auto" ? "16 / 9" : e.replace(":", " / "); } -const Fe = "ring-view-live-entity", qe = "ring-view-recording-entity", Ne = "ring-view-mode", ue = "ring-view-ringing-until"; -function zt() { +const Be = "ring-view-live-entity", We = "ring-view-recording-entity", Ke = "ring-view-mode", pe = "ring-view-ringing-until"; +function qt() { return `${window.location.pathname}${window.location.search}${window.location.hash}`; } -function Ce(e = window.location.search) { - const t = e instanceof URLSearchParams ? e : new URLSearchParams(e), i = t.get(Fe), n = t.get(qe), r = t.get(Ne); +function Ee(e = window.location.search) { + const t = e instanceof URLSearchParams ? e : new URLSearchParams(e), i = t.get(Be), n = t.get(We), r = t.get(Ke); if (!i?.startsWith("camera.") || !n?.startsWith("camera.") || r !== "live" && r !== "last_recording") return; - const o = t.get(ue), a = o === null ? void 0 : Number(o); + const o = t.get(pe), a = o === null ? void 0 : Number(o); return { liveEntity: i, recordingEntity: n, @@ -1687,18 +1689,18 @@ function Ce(e = window.location.search) { ringingUntil: a !== void 0 && Number.isFinite(a) ? a : void 0 }; } -function Ee(e, t) { +function xe(e, t) { return e?.liveEntity === t.live_entity && e.recordingEntity === t.recording_entity; } -function Rn(e, t) { +function Cn(e, t) { const i = new URL(e, window.location.origin); - return i.searchParams.set(Fe, t.liveEntity), i.searchParams.set(qe, t.recordingEntity), i.searchParams.set(Ne, t.mode), t.ringingUntil !== void 0 && t.ringingUntil > Date.now() ? i.searchParams.set(ue, String(t.ringingUntil)) : i.searchParams.delete(ue), `${i.pathname}${i.search}${i.hash}`; + return i.searchParams.set(Be, t.liveEntity), i.searchParams.set(We, t.recordingEntity), i.searchParams.set(Ke, t.mode), t.ringingUntil !== void 0 && t.ringingUntil > Date.now() ? i.searchParams.set(pe, String(t.ringingUntil)) : i.searchParams.delete(pe), `${i.pathname}${i.search}${i.hash}`; } -function Ft(e) { +function Nt(e) { const t = new URL(e, window.location.origin); - return t.searchParams.delete(Fe), t.searchParams.delete(qe), t.searchParams.delete(Ne), t.searchParams.delete(ue), `${t.pathname}${t.search}${t.hash}`; + return t.searchParams.delete(Be), t.searchParams.delete(We), t.searchParams.delete(Ke), t.searchParams.delete(pe), `${t.pathname}${t.search}${t.hash}`; } -function xe(e, t) { +function Pe(e, t) { try { const i = window.history.state && typeof window.history.state == "object" ? window.history.state : {}; let n = i; @@ -1714,25 +1716,25 @@ function xe(e, t) { } catch { } } -const Be = "ring-view-dialog", ae = 12e3; -let X; -function $n(e) { - X = e; +const je = "ring-view-dialog", de = 12e3; +let ee; +function En(e) { + ee = e; } -function Cn(e) { - X === e && (X = void 0); +function xn(e) { + ee === e && (ee = void 0); } -function En(e) { - return !e.restored || !X?.isOpenFor(e) ? !1 : (X.adoptRestoredDialog(e), !0); +function Pn(e) { + return !e.restored || !ee?.isOpenFor(e) ? !1 : (ee.adoptRestoredDialog(e), !0); } -function ye(e, t) { - if (En(t)) return; - const i = Ft(zt()); - xe(i), e.dispatchEvent( +function ke(e, t) { + if (Pn(t)) return; + const i = Nt(qt()); + Pe(i), e.dispatchEvent( new CustomEvent("show-dialog", { detail: { - dialogTag: Be, - dialogImport: () => Promise.resolve().then(() => lr), + dialogTag: je, + dialogImport: () => Promise.resolve().then(() => vr), dialogParams: { ...t, returnUrl: i }, addHistory: !0 }, @@ -1741,21 +1743,21 @@ function ye(e, t) { }) ); } -const xn = 9e3, lt = /* @__PURE__ */ new WeakMap(); -function qt(e, t, i) { +const Ln = 9e3, pt = /* @__PURE__ */ new WeakMap(); +function Bt(e, t, i) { const n = t?.attributes.entity_picture; return typeof n == "string" && n.length > 0 ? e.hassUrl(n) : e.hassUrl(`/api/camera_proxy/${encodeURIComponent(i)}`); } -async function Pn(e, t, i, n) { - const r = await Ln(e, t), o = r.includes("?") ? "&" : "?"; - return `${r}${o}width=${ht(i)}&height=${ht(n)}`; +async function Mn(e, t, i, n) { + const r = await Dn(e, t), o = r.includes("?") ? "&" : "?"; + return `${r}${o}width=${vt(i)}&height=${vt(n)}`; } -function ht(e) { +function vt(e) { return Math.max(1, Math.ceil(Number.isFinite(e) ? e : 1)); } -async function Ln(e, t) { - let i = lt.get(e); - i || (i = /* @__PURE__ */ new Map(), lt.set(e, i)); +async function Dn(e, t) { + let i = pt.get(e); + i || (i = /* @__PURE__ */ new Map(), pt.set(e, i)); const n = Date.now(), r = i.get(t); if (r && r.expiresAt > n) return r.promise; const o = e.callWS({ @@ -1767,7 +1769,7 @@ async function Ln(e, t) { return e.hassUrl(a.path); }); i.set(t, { - expiresAt: n + xn, + expiresAt: n + Ln, promise: o }); try { @@ -1776,20 +1778,20 @@ async function Ln(e, t) { throw i.delete(t), a; } } -function ut(e) { +function gt(e) { return e === "live" ? h`` : h` `; } -function Mn(e, t) { +function In(e, t) { return s( t, e === "live" ? "common.live" : "common.last_recording" ); } -const Dn = K` +const Vn = K` :host { display: block; min-width: 0; @@ -1942,7 +1944,7 @@ const Dn = K` display: none; } } -`, In = K` +`, Un = K` :host { position: fixed; inset: 0; @@ -2718,7 +2720,7 @@ const Dn = K` :host([inline]) .state-layer.with-visitor-controls .spinner { flex: 0 0 auto; } -`, Vn = K` +`, Hn = K` :host { display: block; min-width: 0; @@ -2735,29 +2737,29 @@ const Dn = K` gap: 8px; margin: 0 0 12px; } -`, Un = "ring-view:mode:"; -function Nt(e) { - return `${Un}${e.recording_entity}|${e.live_entity}`; +`, On = "ring-view:mode:"; +function Wt(e) { + return `${On}${e.recording_entity}|${e.live_entity}`; } -function ie(e) { +function ne(e) { if (!e.remember_last_mode) return e.default_mode; try { - const t = window.localStorage.getItem(Nt(e)); + const t = window.localStorage.getItem(Wt(e)); return t === "live" || t === "last_recording" ? t : e.default_mode; } catch { return e.default_mode; } } -function Hn(e, t) { +function zn(e, t) { if (e.remember_last_mode) try { - window.localStorage.setItem(Nt(e), t); + window.localStorage.setItem(Wt(e), t); } catch { } } -const $ = Ue(class extends He { +const $ = ze(class extends Fe { constructor(e) { - if (super(e), e.type !== Et.ATTRIBUTE || e.name !== "class" || e.strings?.length > 2) throw Error("`classMap()` can only be used in the `class` attribute and must be the only part in the attribute."); + if (super(e), e.type !== Lt.ATTRIBUTE || e.name !== "class" || e.strings?.length > 2) throw Error("`classMap()` can only be used in the `class` attribute and must be the only part in the attribute."); } render(e) { return " " + Object.keys(e).filter((t) => e[t]).join(" ") + " "; @@ -2774,11 +2776,11 @@ const $ = Ue(class extends He { const r = !!t[n]; r === this.st.has(n) || this.nt?.has(n) || (r ? (i.add(n), this.st.add(n)) : (i.remove(n), this.st.delete(n))); } - return M; + return D; } }); -const On = {}, zn = (e, t = On) => e._$AH = t; -const pt = Ue(class extends He { +const Fn = {}, qn = (e, t = Fn) => e._$AH = t; +const ft = ze(class extends Fe { constructor() { super(...arguments), this.key = l; } @@ -2786,16 +2788,16 @@ const pt = Ue(class extends He { return this.key = e, t; } update(e, [t, i]) { - return t !== this.key && (zn(e), this.key = t), i; + return t !== this.key && (qn(e), this.key = t), i; } }); -var Fn = Object.defineProperty, qn = Object.getOwnPropertyDescriptor, P = (e, t, i, n) => { - for (var r = n > 1 ? void 0 : n ? qn(t, i) : t, o = e.length - 1, a; o >= 0; o--) +var Nn = Object.defineProperty, Bn = Object.getOwnPropertyDescriptor, L = (e, t, i, n) => { + for (var r = n > 1 ? void 0 : n ? Bn(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 && Fn(t, i, r), r; + return n && r && Nn(t, i, r), r; }; -const pe = "ha-camera-stream", Nn = 8e3, vt = "ring-view-native-layout"; -async function Bn(e, t) { +const ve = "ha-camera-stream", Wn = 8e3, mt = "ring-view-native-layout"; +async function Kn(e, t) { if (customElements.get(e)) return !0; let i; try { @@ -2811,17 +2813,17 @@ async function Bn(e, t) { i !== void 0 && window.clearTimeout(i); } } -async function Bt() { - if (customElements.get(pe)) return !0; +async function Kt() { + if (customElements.get(ve)) return !0; try { await (await window.loadCardHelpers?.())?.importMoreInfoControl?.("camera"); } catch { } - return Bn(pe, Nn); + return Kn(ve, Wn); } let R = class extends C { constructor() { - super(...arguments), this.controls = !0, this.muted = !0, this.allowExoPlayer = !0, this.fitMode = "cover", this.passiveSurface = !1, this.nativeAvailable = !!customElements.get(pe), this.handledStreamEvents = /* @__PURE__ */ new WeakSet(), this.ready = !1, this.handleSurfaceClick = (e) => { + super(...arguments), this.controls = !0, this.muted = !0, this.allowExoPlayer = !0, this.fitMode = "cover", this.passiveSurface = !1, this.nativeAvailable = !!customElements.get(ve), this.handledStreamEvents = /* @__PURE__ */ new WeakSet(), this.ready = !1, this.handleSurfaceClick = (e) => { if (!this.passiveSurface || e.detail === 0 || !this.eventHost) return; const t = this.eventHost.getBoundingClientRect(), i = this.controls ? 64 : 0; e.clientY >= t.bottom - i || (e.preventDefault(), e.stopImmediatePropagation()); @@ -2869,11 +2871,11 @@ let R = class extends C { } updated() { if (!this.nativeAvailable) return; - const e = this.renderRoot.querySelector(pe); + const e = this.renderRoot.querySelector(ve); e && this.attachEventListeners(e); } async loadNativeComponent() { - const e = await Bt(); + const e = await Kt(); if (this.isConnected) { if (!e) { this.dispatchFailure("component-unavailable"); @@ -2893,9 +2895,9 @@ let R = class extends C { }), this.mediaObserver.observe(t, { childList: !0, subtree: !0 }), this.prepareNativeRoot(t), this.detectReadyMedia(t)); } prepareNativeRoot(e) { - if (e.getElementById(vt)) return; + if (e.getElementById(mt)) return; const t = document.createElement("style"); - t.id = vt, t.textContent = ` + t.id = mt, t.textContent = ` :host { display: block; width: 100%; @@ -2963,41 +2965,41 @@ R.styles = K` height: 100%; } `; -P([ +L([ _({ attribute: !1 }) ], R.prototype, "stateObj", 2); -P([ +L([ _({ type: Boolean }) ], R.prototype, "controls", 2); -P([ +L([ _({ type: Boolean }) ], R.prototype, "muted", 2); -P([ +L([ _({ type: Boolean, attribute: "allow-exoplayer" }) ], R.prototype, "allowExoPlayer", 2); -P([ +L([ _({ type: Number, attribute: !1 }) ], R.prototype, "aspectRatio", 2); -P([ +L([ _({ attribute: !1 }) ], R.prototype, "fitMode", 2); -P([ +L([ _({ type: Boolean, attribute: "passive-surface" }) ], R.prototype, "passiveSurface", 2); -P([ +L([ g() ], R.prototype, "nativeAvailable", 2); -R = P([ - W("ring-view-native-camera-adapter") +R = L([ + j("ring-view-native-camera-adapter") ], R); -const Kn = (e) => e ?? l; -var Wn = Object.defineProperty, jn = Object.getOwnPropertyDescriptor, S = (e, t, i, n) => { - for (var r = n > 1 ? void 0 : n ? jn(t, i) : t, o = e.length - 1, a; o >= 0; o--) +const jn = (e) => e ?? l; +var Zn = Object.defineProperty, Gn = Object.getOwnPropertyDescriptor, S = (e, t, i, n) => { + for (var r = n > 1 ? void 0 : n ? Gn(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 && Wn(t, i, r), r; + return n && r && Zn(t, i, r), r; }; -const Zn = 3e3; -function Gn(e, t) { +const Qn = 3e3; +function Yn(e, t) { if (t instanceof DOMException) { if (t.name === "NotAllowedError") return s(e, "talkback.permission_denied"); if (t.name === "NotFoundError") return s(e, "talkback.no_microphone"); @@ -3048,7 +3050,7 @@ let k = class extends C { e !== this.connectionToken || this.localStream !== i || (this.cancelActivePress(), this.localStream = void 0, this.microphoneState = "failed", this.showStatusMessage(s(this.hass, "talkback.microphone_ended"), "error")); }), await this.audioSender.replaceTrack(n), e !== this.connectionToken ? (i.getTracks().forEach((r) => r.stop()), !1) : (this.localStream?.getTracks().forEach((r) => r.stop()), this.localStream = i, this.microphoneState = "ready", this.showStatusMessage(""), !0); } catch (n) { - return i?.getTracks().forEach((r) => r.stop()), e !== this.connectionToken || (this.microphoneState = "failed", this.showStatusMessage(Gn(this.hass, n), "error")), !1; + return i?.getTracks().forEach((r) => r.stop()), e !== this.connectionToken || (this.microphoneState = "failed", this.showStatusMessage(Yn(this.hass, n), "error")), !1; } })(); this.microphoneRequest = t; @@ -3134,13 +3136,13 @@ let k = class extends C { autoplay playsinline controls - poster=${Kn(this.poster)} + poster=${jn(this.poster)} .muted=${this.actualMuted} @playing=${this.handlePlaying} > ${this.playbackBlocked ? h` ` : l} @@ -3158,7 +3160,7 @@ let k = class extends C { @keydown=${this.handleTalkKeyDown} @keyup=${this.handleTalkKeyUp} > - ${this.icon(e ? yt : kt)} + ${this.icon(e ? St : Tt)} ${r} ` : l} @@ -3335,7 +3337,7 @@ let k = class extends C { }) ), e && (this.statusMessageTimeout = window.setTimeout(() => { this.statusMessageTimeout = void 0, this.showStatusMessage(""); - }, Zn)); + }, Qn)); } dispatchTalkbackState() { const e = { @@ -3551,30 +3553,46 @@ S([ g() ], k.prototype, "readyDispatched", 2); k = S([ - W("ring-view-ring-webrtc-player") + j("ring-view-ring-webrtc-player") ], k); -const gt = /* @__PURE__ */ new Set(["unknown", "unavailable"]); -function Kt(e, t, i) { - if (!i || !t || gt.has(i.state)) - return !1; +const _t = /* @__PURE__ */ new Set(["unknown", "unavailable"]), Jn = 12e3; +function jt(e, t, i, n = Date.now()) { + if (!i || _t.has(i.state)) return !1; + if (!t || _t.has(t.state)) + return Xn(e, i, n); if (e.startsWith("binary_sensor.")) { - if (gt.has(t.state)) return !1; if (t.state === "off" && i.state === "on") return !0; if (t.state !== "on" || i.state !== "on") return !1; - const n = ft(t), r = ft(i); - return n !== void 0 && r !== void 0 && n !== r; + const a = Me(t), d = Me(i); + if (bt(t) || bt(i)) + return a !== void 0 && d !== void 0 && d > a; + const c = P(t.last_updated), u = P(i.last_updated); + return c !== void 0 && u !== void 0 && u > c; } - return e.startsWith("event.") && t.state !== "unavailable" && t.state !== i.state && (i.attributes.event_type === void 0 || i.attributes.event_type === "ring"); + if (!e.startsWith("event.") || i.attributes.event_type !== void 0 && i.attributes.event_type !== "ring") + return !1; + const r = P(t.state), o = P(i.state); + return o === void 0 ? !1 : r === void 0 ? Le(o, n) : o > r; } -function ft(e) { - const t = e.attributes.lastDingTime; - if (typeof t == "string" && t.trim() !== "") - return `time:${t}`; - const i = e.attributes.lastDing; - if (typeof i == "string" && i.trim() !== "" || typeof i == "number" && Number.isFinite(i)) - return `epoch:${i}`; +function Xn(e, t, i) { + return e.startsWith("binary_sensor.") ? t.state !== "on" ? !1 : Le(Me(t), i) : e.startsWith("event.") && (t.attributes.event_type === void 0 || t.attributes.event_type === "ring") && Le(P(t.state), i); } -class Qn { +function Le(e, t) { + if (e === void 0) return !1; + const i = t - e; + return i >= -5e3 && i <= Jn; +} +function Me(e) { + const t = [ + P(e.attributes.lastDingTime), + P(e.attributes.lastDing) + ].filter((i) => i !== void 0); + return t.length > 0 ? Math.max(...t) : void 0; +} +function bt(e) { + return e.attributes.lastDingTime !== void 0 || e.attributes.lastDing !== void 0; +} +class er { constructor() { this.generation = 0; } @@ -3598,19 +3616,19 @@ class Qn { this.clearTimeout(), this.generation += 1; } } -var Yn = Object.defineProperty, Jn = Object.getOwnPropertyDescriptor, m = (e, t, i, n) => { - for (var r = n > 1 ? void 0 : n ? Jn(t, i) : t, o = e.length - 1, a; o >= 0; o--) +var tr = Object.defineProperty, ir = Object.getOwnPropertyDescriptor, m = (e, t, i, n) => { + for (var r = n > 1 ? void 0 : n ? ir(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 && Yn(t, i, r), r; + return n && r && tr(t, i, r), r; }; -const Xn = 20, er = 2500, tr = 2500, ir = 900, nr = 2e3, rr = 3e3, or = 2e3, sr = 3e3, ar = 15e3, dr = 15e3; -function cr(e) { +const nr = 20, rr = 2500, or = 2500, sr = 900, ar = 2e3, dr = 3e3, cr = 2e3, lr = 3e3, hr = 15e3, ur = 15e3; +function pr(e) { const t = e instanceof Error ? e.message.toLowerCase() : String(e).toLowerCase(); return /timeout|timed out/.test(t) ? "snapshot.timeout" : /unauthorized|forbidden|permission denied/.test(t) ? "snapshot.permission_failed" : /cannot write|can't write|no access to path|read-only|readonly/.test(t) ? "snapshot.write_failed" : /connection|websocket|service api unavailable/.test(t) ? "snapshot.connection_failed" : /no image|could not provide|not supported|snapshot unavailable/.test(t) ? "snapshot.camera_failed" : /refresh control unavailable/.test(t) ? "snapshot.refresh_unavailable" : "snapshot.failed"; } let f = class extends C { constructor() { - super(...arguments), this.open = !1, this.inline = !1, this.ringing = !1, this.mode = "last_recording", this.mediaStatus = "idle", this.session = 0, this.suspended = !1, this.recordingMuted = !1, this.recordingStarted = !0, this.recordingControlsVisible = !0, this.recordingEnded = !1, this.liveMuted = !0, this.recordingVideoFailed = !1, this.recordingPreparationActive = !1, this.retryCount = 0, this.statusAnnouncement = "", this.talkbackReady = !1, this.talkbackRequesting = !1, this.talkbackTalking = !1, this.doorActionStatus = "idle", this.snapshotActionStatus = "idle", this.inlineStarted = !0, this.lifecycle = new Qn(), this.recordingPlayback = /* @__PURE__ */ new WeakSet(), this.recordingPaused = !1, this.automaticLiveRetry = !0, this.pageUnloading = !1, this.talkKeyboardPressed = !1, this.doorKeyboardPressed = !1, this.doorActionToken = 0, this.snapshotActionToken = 0, this.recordingRefreshAttempted = !1, this.recordingRefreshToken = 0, this.inlineVisible = !1, this.inlineActive = !1, this.takeSnapshot = async () => { + super(...arguments), this.open = !1, this.inline = !1, this.ringing = !1, this.mode = "last_recording", this.mediaStatus = "idle", this.session = 0, this.suspended = !1, this.recordingMuted = !1, this.recordingStarted = !0, this.recordingControlsVisible = !0, this.recordingEnded = !1, this.liveMuted = !0, this.recordingVideoFailed = !1, this.recordingPreparationActive = !1, this.retryCount = 0, this.statusAnnouncement = "", this.talkbackReady = !1, this.talkbackRequesting = !1, this.talkbackTalking = !1, this.doorActionStatus = "idle", this.snapshotActionStatus = "idle", this.inlineStarted = !0, this.lifecycle = new er(), this.recordingPlayback = /* @__PURE__ */ new WeakSet(), this.recordingPaused = !1, this.automaticLiveRetry = !0, this.pageUnloading = !1, this.talkKeyboardPressed = !1, this.doorKeyboardPressed = !1, this.doorActionToken = 0, this.snapshotActionToken = 0, this.recordingRefreshAttempted = !1, this.recordingRefreshToken = 0, this.inlineVisible = !1, this.inlineActive = !1, this.takeSnapshot = async () => { const e = this.hass, t = this.config, i = this.snapshotEntityId(); if (!e || !t || !i || !this.shouldShowSnapshotAction() || this.snapshotActionStatus === "working") return; @@ -3619,13 +3637,13 @@ let f = class extends C { try { if (e.connection?.connected === !1 || !e.callService) throw new Error("Home Assistant service API unavailable"); - const r = on(e, i); - if (an(e, i) && !r) + const r = an(e, i); + if (cn(e, i) && !r) throw new Error("Ring-MQTT snapshot refresh control unavailable"); if (r) { const a = this.waitForSnapshotUpdate( i, - dt(e, i) + ht(e, i) ); try { await e.callService( @@ -3646,19 +3664,19 @@ let f = class extends C { if (await e.callService( "camera", "snapshot", - { filename: ln(e, t) }, + { filename: un(e, t) }, { entity_id: i } ), n !== this.snapshotActionToken || !this.open) return; const o = s(this.hass, "snapshot.saved"); this.snapshotActionStatus = "success", this.snapshotFeedbackMessage = o, this.clearViewerFeedback("snapshot"), this.statusAnnouncement = o, this.snapshotFeedbackTimer = window.setTimeout(() => { n === this.snapshotActionToken && (this.snapshotFeedbackTimer = void 0, this.snapshotFeedbackMessage = void 0, this.snapshotActionStatus = "idle"); - }, or); + }, cr); } catch (r) { if (n !== this.snapshotActionToken || !this.open) return; - const o = s(this.hass, cr(r)); + const o = s(this.hass, pr(r)); this.snapshotActionStatus = "error", this.snapshotFeedbackMessage = o, this.setViewerFeedback("snapshot", o, "error"), this.statusAnnouncement = o, this.snapshotFeedbackTimer = window.setTimeout(() => { n === this.snapshotActionToken && (this.snapshotFeedbackTimer = void 0, this.snapshotFeedbackMessage = void 0, this.snapshotActionStatus = "idle"); - }, sr); + }, lr); } }, this.handleTalkbackState = (e) => { e.currentTarget === this.renderRoot.querySelector("ring-view-ring-webrtc-player") && (e.detail.ready || this.cancelTalkPress(e.currentTarget), this.talkbackReady = e.detail.ready, this.talkbackRequesting = e.detail.requesting, this.talkbackTalking = e.detail.talking); @@ -3686,7 +3704,7 @@ let f = class extends C { this.mode !== "live" || typeof e.detail?.hasAudio != "boolean" || this.liveMuted && e.detail.hasAudio === !1 || (this.liveHasAudio = this.liveHasAudio === !0 || e.detail.hasAudio, this.mediaStatus === "ready" && this.liveHasAudio ? this.statusAnnouncement = s(this.hass, "viewer.live_connected_audio") : this.mediaStatus === "ready" && this.liveHasAudio === !1 && (this.statusAnnouncement = s(this.hass, "viewer.live_connected_no_audio"))); }, this.handleRecordingVideoError = (e) => { if (!(this.mode !== "last_recording" || !this.acceptsMediaEvent(e))) { - if (this.hass && Se(this.hass, this.activeEntityId())) { + if (this.hass && Te(this.hass, this.activeEntityId())) { this.recordingVideoFailed = !0, this.statusAnnouncement = s(this.hass, "viewer.refreshing_recording"), this.startMedia(); return; } @@ -3836,7 +3854,7 @@ let f = class extends C { this.adoptRestoredDialog(e), this.syncUrl(); return; } - this.open && this.finishClose(!1, !1), this.inline = !1, this.config = e.config, this.opener = e.opener, this.returnUrl = e.returnUrl ?? Ft(zt()), this.mode = e.mode, this.liveMuted = e.restored && this.mode === "live" ? !0 : this.config.live_muted, this.recordingStarted = this.mode === "live" || this.config.autoplay_recording, this.resetMediaAttempt(), this.automaticLiveRetry = !e.restored, this.pageUnloading = !1, this.suspended = !1, this.statusAnnouncement = "", this.resetVisitorActions(), this.resetSnapshotAction(), this.lastDoorbellEntity = this.config.doorbell_entity ? this.hass.states[this.config.doorbell_entity] : void 0, this.setRingingUntil(e.ringingUntil), this.open = !0, $n(this), this.syncUrl(), this.attachGlobalListeners(), e.restored && this.mode === "live" ? this.prepareAutomaticLiveResume() : this.startMedia(), this.updateComplete.then(() => this.focusInitialControl()); + this.open && this.finishClose(!1, !1), this.inline = !1, this.config = e.config, this.opener = e.opener, this.returnUrl = e.returnUrl ?? Nt(qt()), this.mode = e.mode, this.liveMuted = e.restored && this.mode === "live" ? !0 : this.config.live_muted, this.recordingStarted = this.mode === "live" || this.config.autoplay_recording, this.resetMediaAttempt(), this.automaticLiveRetry = !e.restored, this.pageUnloading = !1, this.suspended = !1, this.statusAnnouncement = "", this.resetVisitorActions(), this.resetSnapshotAction(), this.lastDoorbellEntity = this.config.doorbell_entity ? this.hass.states[this.config.doorbell_entity] : void 0, this.setRingingUntil(e.ringingUntil), this.open = !0, En(this), this.syncUrl(), this.attachGlobalListeners(), e.restored && this.mode === "live" ? this.prepareAutomaticLiveResume() : this.startMedia(), this.updateComplete.then(() => this.focusInitialControl()); } } showInline(e) { @@ -3877,7 +3895,7 @@ let f = class extends C { } render() { if (!this.open || !this.hass || !this.config) return l; - const e = this.dialogTitle(), t = this.config.show_name, i = this.config.last_activity_entity ? H(this.hass, this.config.last_activity_entity) !== void 0 : !1, n = this.config.aspect_ratio, r = { + const e = this.dialogTitle(), t = this.config.show_name, i = this.config.last_activity_entity ? O(this.hass, this.config.last_activity_entity) !== void 0 : !1, n = this.config.aspect_ratio, r = { "--ring-view-aspect-ratio": n === "auto" ? "16 / 9" : n.replace(":", " / "), "--ring-view-fit-mode": this.config.fit_mode }; @@ -3885,7 +3903,7 @@ let f = class extends C { ${this.inline ? l : h`
`}
- ${this.icon(this.inline ? Qt : jt)} + ${this.icon(this.inline ? Jt : Gt)} ${this.renderModeSwitch()} @@ -3946,7 +3964,7 @@ let f = class extends C { aria-label=${e} title=${e} > - ${this.icon(_t)} + ${this.icon(yt)} ${e} `; @@ -3971,7 +3989,7 @@ let f = class extends C { @click=${() => this.selectMode("last_recording")} @keydown=${this.handleTabKeyDown} > - ${ut("last_recording")} + ${gt("last_recording")} `; } renderSnapshotAction() { if (!this.shouldShowSnapshotAction()) return l; - const e = this.snapshotEntityId(), t = this.hass?.connection?.connected !== !1, i = this.snapshotActionStatus === "working", n = i || !e || !t, r = t ? e ? this.snapshotActionStatus === "working" ? "snapshot.saving" : this.snapshotActionStatus === "success" ? "snapshot.saved" : this.snapshotActionStatus === "error" ? this.snapshotFeedbackMessage ? void 0 : "snapshot.failed" : "snapshot.take" : "snapshot.unavailable" : "snapshot.connection_failed", o = r ? s(this.hass, r) : this.snapshotFeedbackMessage, a = this.snapshotActionStatus === "working" ? je : this.snapshotActionStatus === "success" ? Ke : this.snapshotActionStatus === "error" ? _e : bt; + const e = this.snapshotEntityId(), t = this.hass?.connection?.connected !== !1, i = this.snapshotActionStatus === "working", n = i || !e || !t, r = t ? e ? this.snapshotActionStatus === "working" ? "snapshot.saving" : this.snapshotActionStatus === "success" ? "snapshot.saved" : this.snapshotActionStatus === "error" ? this.snapshotFeedbackMessage ? void 0 : "snapshot.failed" : "snapshot.take" : "snapshot.unavailable" : "snapshot.connection_failed", o = r ? s(this.hass, r) : this.snapshotFeedbackMessage, a = this.snapshotActionStatus === "working" ? Qe : this.snapshotActionStatus === "success" ? Ze : this.snapshotActionStatus === "error" ? be : kt; return h` ` : l} @@ -4125,7 +4143,7 @@ let f = class extends C { )) && (this.mode === "live" || this.config.door_control_visibility === "all_views")); } shouldShowTalkControl() { - return !!(this.mode === "live" && this.config?.two_way_audio && this.hass && Te(this.hass, this.config.live_entity) && ["pending", "ready", "playback-blocked"].includes(this.mediaStatus)); + return !!(this.mode === "live" && this.config?.two_way_audio && this.hass && Re(this.hass, this.config.live_entity) && ["pending", "ready", "playback-blocked"].includes(this.mediaStatus)); } doorEntity() { const e = this.config?.door_entity; @@ -4139,11 +4157,11 @@ let f = class extends C { } doorActionDisabled() { const e = this.doorEntity(); - return !this.config?.door_entity || this.doorContactState() === "open" || w(e) || e?.state === "jammed" || this.doorActionStatus === "working" || this.doorActionStatus === "success" || this.doorWaitingForLiveVideo() ? !0 : this.config.door_action === "open" ? !se(e) || ["open", "opening"].includes(e?.state ?? "") : ["unlocked", "unlocking", "open", "opening"].includes(e?.state ?? ""); + return !this.config?.door_entity || this.doorContactState() === "open" || w(e) || e?.state === "jammed" || this.doorActionStatus === "working" || this.doorActionStatus === "success" || this.doorWaitingForLiveVideo() ? !0 : this.config.door_action === "open" ? !ae(e) || ["open", "opening"].includes(e?.state ?? "") : ["unlocked", "unlocking", "open", "opening"].includes(e?.state ?? ""); } doorActionLabel() { const e = this.doorEntity(); - return this.doorContactState() === "open" ? s(this.hass, "door.contact_open") : w(e) ? s(this.hass, "door.unavailable") : e?.state === "jammed" ? s(this.hass, "door.jammed") : this.config?.door_action === "open" && !se(e) ? s(this.hass, "door.open_unsupported") : this.doorWaitingForLiveVideo() ? s( + return this.doorContactState() === "open" ? s(this.hass, "door.contact_open") : w(e) ? s(this.hass, "door.unavailable") : e?.state === "jammed" ? s(this.hass, "door.jammed") : this.config?.door_action === "open" && !ae(e) ? s(this.hass, "door.open_unsupported") : this.doorWaitingForLiveVideo() ? s( this.hass, this.config?.door_action === "open" ? "door.open_when_ready" : "door.unlock_when_ready" ) : this.doorActionStatus === "working" || this.config?.door_action === "unlock" && e?.state === "unlocking" || this.config?.door_action === "open" && e?.state === "opening" ? s( @@ -4171,7 +4189,7 @@ let f = class extends C { } doorActionIcon() { const e = this.doorEntity(), t = this.doorContactState(); - return t === "open" ? We : t === "closed" ? Zt : t === "unknown" || w(e) || e?.state === "jammed" || this.config?.door_action === "open" && !se(e) || this.doorActionStatus === "error" ? _e : this.doorActionStatus === "working" ? je : this.doorActionStatus === "success" ? Ke : this.config?.door_action === "open" ? We : wt; + return t === "open" ? Ge : t === "closed" ? Qt : t === "unknown" || w(e) || e?.state === "jammed" || this.config?.door_action === "open" && !ae(e) || this.doorActionStatus === "error" ? be : this.doorActionStatus === "working" ? Qe : this.doorActionStatus === "success" ? Ze : this.config?.door_action === "open" ? Ge : At; } talkbackPlayer() { return this.shadowRoot?.querySelector("ring-view-ring-webrtc-player") ?? null; @@ -4182,7 +4200,7 @@ let f = class extends C { beginDoorHold() { this.doorHoldTimer !== void 0 || this.doorActionDisabled() || (this.clearDoorFeedback(), this.doorActionStatus = "holding", this.doorHoldTimer = window.setTimeout(() => { this.doorHoldTimer = void 0, this.doorPointerId = void 0, this.doorKeyboardPressed = !1, this.executeDoorAction(); - }, ir)); + }, sr)); } cancelDoorHold() { this.doorHoldTimer !== void 0 && (window.clearTimeout(this.doorHoldTimer), this.doorHoldTimer = void 0), this.doorPointerId = void 0, this.doorKeyboardPressed = !1, this.doorActionStatus === "holding" && (this.doorActionStatus = "idle"); @@ -4204,7 +4222,7 @@ let f = class extends C { ); this.doorActionStatus = "success", this.clearViewerFeedback("door"), this.statusAnnouncement = r, this.doorFeedbackTimer = window.setTimeout(() => { n === this.doorActionToken && (this.doorFeedbackTimer = void 0, this.clearViewerFeedback("door"), this.doorActionStatus = "idle"); - }, nr); + }, ar); } catch { if (n !== this.doorActionToken || !this.open) return; const r = s( @@ -4213,7 +4231,7 @@ let f = class extends C { ); this.doorActionStatus = "error", this.setViewerFeedback("door", r, "error"), this.statusAnnouncement = r, this.doorFeedbackTimer = window.setTimeout(() => { n === this.doorActionToken && (this.doorFeedbackTimer = void 0, this.clearViewerFeedback("door"), this.doorActionStatus = "idle"); - }, rr); + }, dr); } } clearDoorFeedback() { @@ -4229,11 +4247,11 @@ let f = class extends C { this.viewerFeedback?.source === e && (this.viewerFeedback = void 0); } renderMedia() { - const e = this.activeEntity(), t = this.activeEntityId(), i = w(e), n = !i && !this.suspended && (this.mode === "live" || this.recordingStarted), r = Ot(this.config.aspect_ratio), o = this.mode === "last_recording" ? Ut(this.hass, this.config) : t, a = qt( + const e = this.activeEntity(), t = this.activeEntityId(), i = w(e), n = !i && !this.suspended && (this.mode === "live" || this.recordingStarted), r = Ft(this.config.aspect_ratio), o = this.mode === "last_recording" ? Ot(this.hass, this.config) : t, a = Bt( this.hass, this.hass.states[o], o - ), d = this.mode === "last_recording" ? fe(e) : void 0, c = this.mode === "last_recording" && t.startsWith("select."), u = n && ["pending", "ready", "playback-blocked"].includes(this.mediaStatus), p = !!(u && d && !this.recordingVideoFailed && !this.recordingPreparationActive), v = u && !p && !c, y = !!(n && this.mode === "live" && this.config.two_way_audio && Te(this.hass, t)), A = !!(this.inline && !this.inlineStarted || this.mode === "last_recording" && !this.recordingStarted); + ), d = this.mode === "last_recording" ? me(e) : void 0, c = this.mode === "last_recording" && t.startsWith("select."), u = n && ["pending", "ready", "playback-blocked"].includes(this.mediaStatus), p = !!(u && d && !this.recordingVideoFailed && !this.recordingPreparationActive), v = u && !p && !c, y = !!(n && this.mode === "live" && this.config.two_way_audio && Re(this.hass, t)), A = !!(this.inline && !this.inlineStarted || this.mode === "last_recording" && !this.recordingStarted); return h`
` : l} - ${v ? pt( + ${v ? ft( `${t}:${this.session}`, h` ${y ? h` @@ -4296,7 +4314,7 @@ let f = class extends C { `} ` ) : l} - ${p ? pt( + ${p ? ft( `${t}:${this.session}:recording-video`, h`
@@ -4498,7 +4516,7 @@ let f = class extends C { this.inline && !this.inlineStarted && (this.inlineStarted = !0, this.recordingStarted = !0, this.resetMediaAttempt(), this.startMedia()); return; } - this.resetVisitorActions(), this.resetSnapshotAction(), this.clearAutomaticLiveRecovery(), this.lifecycle.dispose(), this.releaseInlineLive(), this.automaticLiveRetry = !0, this.mode = e, Hn(this.config, e), this.liveMuted = this.inline ? this.config.dashboard_live_muted : this.config.live_muted, this.inlineStarted = !0, this.recordingStarted = this.inline ? !0 : e === "live" || this.config.autoplay_recording, this.resetMediaAttempt(), this.statusAnnouncement = s( + this.resetVisitorActions(), this.resetSnapshotAction(), this.clearAutomaticLiveRecovery(), this.lifecycle.dispose(), this.releaseInlineLive(), this.automaticLiveRetry = !0, this.mode = e, zn(this.config, e), this.liveMuted = this.inline ? this.config.dashboard_live_muted : this.config.live_muted, this.inlineStarted = !0, this.recordingStarted = this.inline ? !0 : e === "live" || this.config.autoplay_recording, this.resetMediaAttempt(), this.statusAnnouncement = s( this.hass, e === "live" ? "viewer.mode_selected_live" : "viewer.mode_selected_recording" ), this.syncUrl(), this.startMedia(); @@ -4521,7 +4539,7 @@ let f = class extends C { this.mediaStatus = "error", this.statusAnnouncement = s(this.hass, "viewer.entity_unavailable"); return; } - if (this.mode === "last_recording" && this.hass && Se(this.hass, this.activeEntityId()) && (!we(this.activeEntity()) || this.recordingVideoFailed)) { + if (this.mode === "last_recording" && this.hass && Te(this.hass, this.activeEntityId()) && (!ye(this.activeEntity()) || this.recordingVideoFailed)) { if (this.recordingRefreshAttempted) { this.failRecordingPreparation("viewer.recording_playback_failed"); return; @@ -4529,7 +4547,7 @@ let f = class extends C { this.recordingRefreshAttempted = !0, this.refreshRingMqttRecording(); return; } - if (this.mode === "last_recording" && this.activeEntityId().startsWith("select.") && !we(this.activeEntity())) { + if (this.mode === "last_recording" && this.activeEntityId().startsWith("select.") && !ye(this.activeEntity())) { this.failRecordingPreparation("viewer.recording_source_unsupported"); return; } @@ -4539,7 +4557,7 @@ let f = class extends C { } this.mode === "last_recording" && this.resetRecordingControls(), this.recordingPreparationActive = !1, this.recordingFailureDetail = void 0, this.mediaStatus = "pending", this.session = this.lifecycle.next(), this.lifecycle.scheduleTimeout( () => this.failMedia(), - Xn * 1e3 + nr * 1e3 ); } resetMediaAttempt() { @@ -4560,7 +4578,7 @@ let f = class extends C { this.recordingPreparationActive = !0, this.recordingVideoFailed = !1, this.recordingFailureDetail = void 0, this.lifecycle.dispose(), this.session = this.lifecycle.current(), this.mediaStatus = "pending", this.statusAnnouncement = s(this.hass, "viewer.refreshing_recording"); const o = this.waitForRecordingUpdate( t, - Ae(i) + Se(i) ); Promise.resolve().then(() => e.callService( "select", @@ -4587,15 +4605,15 @@ let f = class extends C { return this.finishPendingRecordingUpdate("cancelled"), new Promise((i) => { const n = window.setTimeout(() => { this.finishPendingRecordingUpdate("timeout"); - }, dr); + }, ur); this.pendingRecordingUpdate = { entityId: e, baseline: t, timer: n, resolve: i }; }); } observePendingRecordingUpdate() { const e = this.pendingRecordingUpdate; if (!e || !this.hass) return; - const t = this.hass.states[e.entityId], i = Ae(t); - i !== void 0 && i !== e.baseline && we(t) && this.finishPendingRecordingUpdate("updated"); + const t = this.hass.states[e.entityId], i = Se(t); + i !== void 0 && i !== e.baseline && ye(t) && this.finishPendingRecordingUpdate("updated"); } finishPendingRecordingUpdate(e) { const t = this.pendingRecordingUpdate; @@ -4627,7 +4645,7 @@ let f = class extends C { scheduleRecordingControlsHide(e, t) { this.clearRecordingControlsTimer(), this.recordingControlsTimer = window.setTimeout(() => { this.recordingControlsTimer = void 0, this.isCurrentRecording(e, t) && (e.controls = !1, this.recordingControlsVisible = !1); - }, tr); + }, or); } resetRecordingControls() { this.clearRecordingControlsTimer(), this.recordingControlsVisible = !0, this.recordingEnded = !1, this.recordingPaused = !1; @@ -4672,7 +4690,7 @@ let f = class extends C { return; } if (this.automaticLiveRetry && this.mode === "live" && this.retryCount < 1) { - this.retryCount += 1, this.scheduleLiveReconnect(er); + this.retryCount += 1, this.scheduleLiveReconnect(rr); return; } this.lifecycle.dispose(), this.releaseInlineLive(), this.session = this.lifecycle.current(), this.mediaStatus = "error", this.statusAnnouncement = this.mode === "live" ? s(this.hass, "viewer.live_failed") : s(this.hass, "viewer.recording_unavailable"); @@ -4728,7 +4746,7 @@ let f = class extends C { const t = this.config?.doorbell_entity; if (!t || !this.hass) return; const i = this.hass.states[t], n = e?.states[t] ?? this.lastDoorbellEntity; - this.lastDoorbellEntity = i, Kt(t, n, i) && this.setRingingUntil(Date.now() + ae); + this.lastDoorbellEntity = i, jt(t, n, i) && this.setRingingUntil(Date.now() + de); } setRingingUntil(e) { this.ringAlertTimer !== void 0 && (window.clearTimeout(this.ringAlertTimer), this.ringAlertTimer = void 0); @@ -4743,25 +4761,25 @@ let f = class extends C { } syncUrl() { if (this.inline || !this.open || !this.config || !this.returnUrl) return; - const e = Rn(this.returnUrl, { + const e = Cn(this.returnUrl, { liveEntity: this.config.live_entity, recordingEntity: this.config.recording_entity, mode: this.mode, ringingUntil: this.ringingUntil }); - xe(e, e); + Pe(e, e); } finishClose(e = !0, t = !0) { if (!this.open) return; const i = this.inline; this.clearAutomaticLiveRecovery(); const n = this.opener, r = this.returnUrl, o = this.config; - this.renderRoot.querySelector("ring-view-ring-webrtc-player")?.stopTalking(), this.lifecycle.dispose(), this.releaseInlineLive(), this.session = this.lifecycle.current(), this.mediaStatus = "idle", this.open = !1, Cn(this), this.suspended = !1, this.resetMediaAttempt(), this.automaticLiveRetry = !0, this.recordingStarted = !0, this.statusAnnouncement = "", this.resetVisitorActions(), this.resetSnapshotAction(), this.setRingingUntil(), this.lastDoorbellEntity = void 0, this.detachGlobalListeners(), document.fullscreenElement && document.exitFullscreen().catch(() => { - }), t && r && o && Ee(Ce(), o) && xe(r, null), i || this.dispatchEvent( + this.renderRoot.querySelector("ring-view-ring-webrtc-player")?.stopTalking(), this.lifecycle.dispose(), this.releaseInlineLive(), this.session = this.lifecycle.current(), this.mediaStatus = "idle", this.open = !1, xn(this), this.suspended = !1, this.resetMediaAttempt(), this.automaticLiveRetry = !0, this.recordingStarted = !0, this.statusAnnouncement = "", this.resetVisitorActions(), this.resetSnapshotAction(), this.setRingingUntil(), this.lastDoorbellEntity = void 0, this.detachGlobalListeners(), document.fullscreenElement && document.exitFullscreen().catch(() => { + }), t && r && o && xe(Ee(), o) && Pe(r, null), i || this.dispatchEvent( new CustomEvent("viewer-closed", { bubbles: !0, composed: !0 }) ), t && this.dispatchEvent( new CustomEvent("dialog-closed", { - detail: { dialog: Be }, + detail: { dialog: je }, bubbles: !0, composed: !0 }) @@ -4773,22 +4791,22 @@ let f = class extends C { claimInlineLive(e = !1) { const t = this.config?.live_entity; if (!this.inline || !t) return !0; - const i = ne.get(t); + const i = re.get(t); if (i && i !== this) { if (!e) return !1; i.pauseForInlineTakeover(); } - return ne.set(t, this), !0; + return re.set(t, this), !0; } releaseInlineLive() { const e = this.config?.live_entity; - e && ne.get(e) === this && ne.delete(e); + e && re.get(e) === this && re.delete(e); } pauseForInlineTakeover() { !this.inline || !this.open || this.mode !== "live" || (this.resetVisitorActions(), this.clearAutomaticLiveRecovery(), this.lifecycle.dispose(), this.releaseInlineLive(), this.session = this.lifecycle.current(), this.automaticLiveRetry = !1, this.mediaStatus = "awaiting-resume", this.statusAnnouncement = s(this.hass, "viewer.resume_live")); } }; -f.styles = In; +f.styles = Un; m([ _({ attribute: !1 }) ], f.prototype, "hass", 2); @@ -4874,21 +4892,21 @@ m([ g() ], f.prototype, "inlineStarted", 2); f = m([ - W(Be) + j(je) ], f); -const ne = /* @__PURE__ */ new Map(), lr = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ +const re = /* @__PURE__ */ new Map(), vr = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({ __proto__: null, get RingViewDialog() { return f; } }, Symbol.toStringTag, { value: "Module" })); -var hr = Object.defineProperty, ur = Object.getOwnPropertyDescriptor, I = (e, t, i, n) => { - for (var r = n > 1 ? void 0 : n ? ur(t, i) : t, o = e.length - 1, a; o >= 0; o--) +var gr = Object.defineProperty, fr = Object.getOwnPropertyDescriptor, V = (e, t, i, n) => { + for (var r = n > 1 ? void 0 : n ? fr(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 && hr(t, i, r), r; + return n && r && gr(t, i, r), r; }; -const pr = 1e4, vr = 640, gr = 16 / 9, mt = 16, fr = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent( - Ti +const mr = 1e4, _r = 640, br = 16 / 9, wt = 16, wr = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent( + $i )}`; let E = class extends C { constructor() { @@ -4898,17 +4916,17 @@ let E = class extends C { if (this.preview || this.isInCardPicker()) return; this.updateRingAlert(); const e = this.renderRoot.querySelector(".preview") ?? void 0; - ye(e ?? this, { + ke(e ?? this, { config: this.config, - mode: this.ringAlertVisible ? "live" : ie(this.config), + mode: this.ringAlertVisible ? "live" : ne(this.config), opener: e, - ringingUntil: this.ringAlertVisible ? this.lastRingAlertAt + ae : void 0 + ringingUntil: this.ringAlertVisible ? this.lastRingAlertAt + de : void 0 }); }, this.openExpandedViewer = (e) => { if (!this.config) return; e.stopPropagation(); const t = e.currentTarget; - t.stopInline(), this.inlinePausedForViewer = !0, ye(t, { + t.stopInline(), this.inlinePausedForViewer = !0, ke(t, { config: this.config, mode: e.detail.mode, opener: t, @@ -4925,7 +4943,7 @@ let E = class extends C { }; } static async getConfigElement() { - return await Promise.resolve().then(() => kr), document.createElement("ring-view-editor"); + return await Promise.resolve().then(() => Rr), document.createElement("ring-view-editor"); } static getStubConfig(e) { const t = Object.keys(e?.states ?? {}).filter((d) => d.startsWith("camera.")), i = t.find( @@ -4933,19 +4951,19 @@ let E = class extends C { ) ?? t.find((d) => /live(_view)?$/i.test(d)) ?? t[1] ?? "camera.live_view", n = t.find( (d) => d !== i && e?.entities?.[d]?.platform === "ring" && (Number(e.states[d]?.attributes.supported_features ?? 0) & 2) === 0 ), r = Object.keys(e?.states ?? {}).find( - (d) => e ? Se(e, d) : !1 + (d) => e ? Te(e, d) : !1 ), o = n ?? r ?? t.find( (d) => d !== i && (Number(e?.states[d]?.attributes.supported_features ?? 0) & 2) === 0 - ) ?? t.find((d) => d !== i) ?? t[0] ?? "camera.latest_recording", a = e && o === r ? sn(e, o) : void 0; + ) ?? t.find((d) => d !== i) ?? t[0] ?? "camera.latest_recording", a = e && o === r ? dn(e, o) : void 0; return { - type: ze, + type: Ne, recording_entity: o, live_entity: i, ...a ? { snapshot_entity: a } : {} }; } setConfig(e) { - const t = $e(e); + const t = Ce(e); (t.recording_entity !== this.config?.recording_entity || t.snapshot_entity !== this.config?.snapshot_entity) && (this.previewMarkersInitialized = !1, this.recordingMarker = void 0, this.snapshotTimestamp = void 0, this.latestObservedPreviewSource = void 0), this.config = t; } getCardSize() { @@ -4976,11 +4994,11 @@ let E = class extends C { if (i !== "hass") return !0; if (!e.has("hass") || !this.hass || !this.config) return !1; const t = e.get("hass"); - return t ? t.language !== this.hass.language || t.locale?.language !== this.hass.locale?.language || t.states[this.config.recording_entity] !== this.hass.states[this.config.recording_entity] || t.states[this.config.live_entity] !== this.hass.states[this.config.live_entity] || this.config.snapshot_entity !== void 0 && t.states[this.config.snapshot_entity] !== this.hass.states[this.config.snapshot_entity] || this.config.last_activity_entity !== void 0 && H(t, this.config.last_activity_entity) !== H(this.hass, this.config.last_activity_entity) || this.config.doorbell_entity !== void 0 && t.states[this.config.doorbell_entity] !== this.hass.states[this.config.doorbell_entity] || this.config.door_entity !== void 0 && t.states[this.config.door_entity] !== this.hass.states[this.config.door_entity] || this.config.door_contact_entity !== void 0 && t.states[this.config.door_contact_entity] !== this.hass.states[this.config.door_contact_entity] : !0; + return t ? t.language !== this.hass.language || t.locale?.language !== this.hass.locale?.language || t.states[this.config.recording_entity] !== this.hass.states[this.config.recording_entity] || t.states[this.config.live_entity] !== this.hass.states[this.config.live_entity] || this.config.snapshot_entity !== void 0 && t.states[this.config.snapshot_entity] !== this.hass.states[this.config.snapshot_entity] || this.config.last_activity_entity !== void 0 && O(t, this.config.last_activity_entity) !== O(this.hass, this.config.last_activity_entity) || this.config.doorbell_entity !== void 0 && t.states[this.config.doorbell_entity] !== this.hass.states[this.config.doorbell_entity] || this.config.door_entity !== void 0 && t.states[this.config.door_entity] !== this.hass.states[this.config.door_entity] || this.config.door_contact_entity !== void 0 && t.states[this.config.door_contact_entity] !== this.hass.states[this.config.door_contact_entity] : !0; } willUpdate(e) { - if (!this.hass || !this.config || (this.observePreviewMedia(), this.detectDoorbellEvent(e.get("hass")), this.isInCardPicker())) return; - const t = this.previewEntityId(), i = qt(this.hass, this.hass.states[t], t); + if (!this.hass || !this.config || (this.observePreviewMedia(), e.has("hass") && this.detectDoorbellEvent(e.get("hass")), this.isInCardPicker())) return; + const t = this.previewEntityId(), i = Bt(this.hass, this.hass.states[t], t); (t !== this.activePreviewEntityId || i !== this.lastFallbackPoster) && (this.activePreviewEntityId = t, this.lastFallbackPoster = i, this.lastPoster = i, this.lastPreviewSize = void 0, this.previewRequestId += 1, this.previewFailed = !1); } updated(e) { @@ -4992,14 +5010,14 @@ let E = class extends C { const e = this.previewEntityId(), t = this.hass.states[e], i = this.config.name || T( this.hass.states[this.config.recording_entity], s(this.hass, "common.camera") - ), n = this.ringAlertVisible ? "live" : ie(this.config), r = this.isInCardPicker(), o = r || this.preview, a = r ? !1 : w(t), d = { - "--ring-view-aspect-ratio": Tn(this.config.aspect_ratio), + ), n = this.ringAlertVisible ? "live" : ne(this.config), r = this.isInCardPicker(), o = r || this.preview, a = r ? !1 : w(t), d = { + "--ring-view-aspect-ratio": $n(this.config.aspect_ratio), "--ring-view-fit-mode": this.config.fit_mode }; if (this.config.dashboard_behavior === "interactive" && !o) return h` -
+
`; - const c = !o, u = this.config.last_activity_entity ? H(this.hass, this.config.last_activity_entity) : void 0, p = u === void 0 ? void 0 : Vt(this.hass, u), v = c ? s(this.hass, "card.open_viewer", { + const c = !o, u = this.config.last_activity_entity ? O(this.hass, this.config.last_activity_entity) : void 0, p = u === void 0 ? void 0 : Ht(this.hass, u), v = c ? s(this.hass, "card.open_viewer", { name: i, - mode: Mn(n, this.hass) + mode: In(n, this.hass) }) : s(this.hass, "card.preview_alt", { name: i }), y = p ? `${v}. ${p.accessible}.` : v; return h`
${!a && !this.previewFailed ? h` ${s(this.hass, @@ -5051,7 +5069,7 @@ let E = class extends C { ` : l} ${this.ringAlertVisible ? h`
${s(this.hass, "ring.alert")}
` : l} @@ -5060,17 +5078,17 @@ let E = class extends C { `; } previewEntityId() { - return Xi( + return tn( this.hass, this.config, - ie(this.config), + ne(this.config), this.latestObservedPreviewSource ); } observePreviewMedia() { - const e = Yi( + 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) { @@ -5087,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, @@ -5111,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 (!Kt(t, i, 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() { @@ -5153,7 +5171,7 @@ let E = class extends C { if (e) { this.refreshPreview(!0), this.previewRefreshTimer = window.setInterval(() => { this.refreshPreview(!0); - }, pr); + }, mr); return; } this.stopPreviewRefreshTimer(), this.previewRequestId += 1; @@ -5166,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 : vr, o = t.clientHeight || t.getBoundingClientRect().height, a = Ot(this.config.aspect_ratio) ?? gr, 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) < mt && Math.abs(u - this.lastPreviewSize.height) < mt) + 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 Pn(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; @@ -5194,35 +5212,35 @@ let E = class extends C { return !1; } }; -E.styles = Dn; -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: hn, +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) => { @@ -5232,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 mr = Object.defineProperty, _r = Object.getOwnPropertyDescriptor, te = (e, t, i, n) => { - for (var r = n > 1 ? void 0 : n ? _r(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 && mr(t, i, r), r; + return n && r && yr(t, i, r), r; }; -function br(e, t) { +function Ar(e, t) { const i = [ { name: "dashboard_behavior", @@ -5428,14 +5446,14 @@ function br(e, t) { name: "dashboard_preview", type: "expandable", flatten: !0, - iconPath: Wt, + iconPath: Zt, schema: i }, { name: "viewer_behavior", type: "expandable", flatten: !0, - iconPath: Xt, + iconPath: ti, schema: [ { name: "default_mode", @@ -5462,7 +5480,7 @@ function br(e, t) { name: "snapshots", type: "expandable", flatten: !0, - iconPath: bt, + iconPath: kt, schema: [ { name: "snapshot_entity", @@ -5481,7 +5499,7 @@ function br(e, t) { name: "doorbell_features", type: "expandable", flatten: !0, - iconPath: Gt, + iconPath: Yt, schema: [ { name: "doorbell_entity", @@ -5500,14 +5518,14 @@ function br(e, t) { name: "door_access", type: "expandable", flatten: !0, - iconPath: wt, + iconPath: At, schema: n }, { name: "card_appearance", type: "expandable", flatten: !0, - iconPath: Jt, + iconPath: ei, schema: [ { name: "name", selector: { text: {} } }, { name: "show_name", selector: { boolean: {} } }, @@ -5578,7 +5596,7 @@ function br(e, t) { } ]; } -const wr = { +const Sr = { recording_entity: "editor.recording_entity", live_entity: "editor.live_entity", snapshot_entity: "editor.snapshot_entity", @@ -5612,7 +5630,7 @@ const wr = { preview_fallback: "editor.preview_fallback", aspect_ratio: "editor.aspect_ratio", fit_mode: "editor.fit_mode" -}, yr = { +}, Tr = { recording_entity: "editor.helper_recording_entity", live_entity: "editor.helper_live_entity", dashboard_behavior: "editor.helper_dashboard_behavior", @@ -5636,10 +5654,10 @@ const wr = { 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 = wr[e.name]; + const t = Sr[e.name]; if (!t) return; const i = s(this.hass, t); if (e.required || ![ @@ -5650,7 +5668,7 @@ let D = class extends C { ].includes( e.name )) return i; - const n = Lt( + const n = It( this.hass, "ui.panel.lovelace.editor.card.config.optional", "common.optional" @@ -5658,14 +5676,14 @@ let D = class extends C { return `${i} (${n})`; }, this.computeHelper = (e) => { if (e.name === "snapshot_directory" && this.config) { - const i = nn(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 = yr[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 }); @@ -5691,16 +5709,16 @@ let D = class extends C { }; } setConfig(e) { - this.config = $e(e); + this.config = Ce(e); } connectedCallback() { - super.connectedCallback(), Bt().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 = Zi(this.hass, this.config).filter( + const e = Qi(this.hass, this.config).filter( (t) => t.kind !== "compatibility" || this.nativeChecked && !this.nativeAvailable ); return h` @@ -5720,7 +5738,7 @@ let D = class extends C { ): 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, 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 52e5505..5fafc6c 100644 --- a/src/utilities/doorbell.ts +++ b/src/utilities/doorbell.ts @@ -1,54 +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, previous: HassEntity | undefined, current: HassEntity | undefined, + now = Date.now(), ): boolean { - if ( - !current - || !previous - || UNAVAILABLE_STATES.has(current.state) - ) { - return false; + 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 (UNAVAILABLE_STATES.has(previous.state)) return false; if (previous.state === "off" && current.state === "on") return true; if (previous.state !== "on" || current.state !== "on") return false; - const previousDing = ringMqttDingMarker(previous); - const currentDing = ringMqttDingMarker(current); - return previousDing !== undefined - && currentDing !== undefined - && previousDing !== currentDing; + 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 ( + !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.")) { + if (current.state !== "on") return false; + return isRecentTimestamp(ringMqttDingTimestamp(current), now); } - return ( - entityId.startsWith("event.") - && previous.state !== "unavailable" - && previous.state !== current.state + return entityId.startsWith("event.") && (current.attributes.event_type === undefined || current.attributes.event_type === "ring") - ); + && isRecentTimestamp(parseTimestampValue(current.state), now); } -function ringMqttDingMarker(entity: HassEntity): string | undefined { - const lastDingTime = entity.attributes.lastDingTime; - if (typeof lastDingTime === "string" && lastDingTime.trim() !== "") { - return `time:${lastDingTime}`; - } +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; +} - const lastDing = entity.attributes.lastDing; - if ( - (typeof lastDing === "string" && lastDing.trim() !== "") - || (typeof lastDing === "number" && Number.isFinite(lastDing)) - ) { - return `epoch:${lastDing}`; - } +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; +} - return 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 9a2db38..a92a71c 100644 --- a/tests/unit/card-lifecycle.test.ts +++ b/tests/unit/card-lifecycle.test.ts @@ -370,7 +370,7 @@ describe("card stream lifecycle", () => { 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:00Z")); + vi.setSystemTime(Date.parse("2026-09-12T14:19:13Z")); const doorbell: HassEntity = { entity_id: "binary_sensor.front_door_ding", state: "on", @@ -417,7 +417,7 @@ describe("card stream lifecycle", () => { "Someone is at the door", ); - await vi.advanceTimersByTimeAsync(6_000); + await vi.advanceTimersByTimeAsync(2_000); card.hass = { ...card.hass, states: { @@ -425,7 +425,7 @@ describe("card stream lifecycle", () => { [doorbell.entity_id]: { ...doorbell, state: "on", - attributes: { ...doorbell.attributes, lastDingTime: "2026-09-12T14:19:19Z" }, + attributes: { ...doorbell.attributes, lastDingTime: "2026-09-12T14:19:15Z" }, }, }, }; @@ -433,10 +433,32 @@ describe("card stream lifecycle", () => { // The second marker starts a fresh twelve-second alert even though the // binary sensor never returned to off. - await vi.advanceTimersByTimeAsync(7_000); + await vi.advanceTimersByTimeAsync(10_001); expect(card.shadowRoot?.querySelector(".ring-alert")).not.toBeNull(); - await vi.advanceTimersByTimeAsync(5_001); - expect(card.shadowRoot?.querySelector(".ring-alert")).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 () => { diff --git a/tests/unit/doorbell.test.ts b/tests/unit/doorbell.test.ts index 290b8a9..8e81d70 100644 --- a/tests/unit/doorbell.test.ts +++ b/tests/unit/doorbell.test.ts @@ -16,16 +16,41 @@ function entity( } 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"))).toBe(true); - expect(isDoorbellRingTransition(id, entity(id, "on"), entity(id, "on"))).toBe(false); - expect(isDoorbellRingTransition(id, entity(id, "on"), entity(id, "off"))).toBe(false); - expect(isDoorbellRingTransition(id, entity(id, "unknown"), entity(id, "on"))).toBe(false); - expect(isDoorbellRingTransition(id, entity(id, "unavailable"), entity(id, "on"))).toBe(false); + 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("accepts a new Ring-MQTT Ding marker while the sensor remains on", () => { + 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"; const previous = entity(id, "on", undefined, { lastDingTime: "2026-09-12T14:19:13Z", @@ -39,51 +64,123 @@ describe("doorbell ring transitions", () => { lastDingTime: "2026-09-12T14:20:10Z", lastDing: 1_789_222_810, }), + now, )).toBe(true); - expect(isDoorbellRingTransition(id, previous, { ...previous })).toBe(false); + 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("falls back to lastDing and ignores a marker first added to an active sensor", () => { + 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 }), - entity(id, "on", undefined, { lastDing: 1_789_222_810 }), + 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("accepts Ring events and ignores other event types", () => { + 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, entity(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, entity(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, entity(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, entity(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, entity(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", entity("event.ding", "before"), entity("event.ding", "unavailable", "ring"))).toBe(false); - expect(isDoorbellRingTransition("sensor.ding", entity("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); }); }); From b782732fe72b7854a66112fef298c238408bb085 Mon Sep 17 00:00:00 2001 From: Thomas Gregg <2538782+thomasgregg@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:53:21 +0200 Subject: [PATCH 3/5] Support Ring-MQTT in notification blueprint --- CHANGELOG.md | 3 + README.md | 13 +- .../ring_view/doorbell_notification.yaml | 237 ++++++++++++++---- docs/compatibility.md | 10 +- docs/notifications.md | 47 ++-- 5 files changed, 237 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3947a11..af79185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to this project are documented here. ### Fixed +- Support official Ring and Ring-MQTT entities in the included phone + notification blueprint, including repeated MQTT presses and fresh snapshot + previews without stale-image fallback. - Detect rapid and repeated Ring-MQTT doorbell presses from advancing `lastDingTime`, `lastDing`, or entity-update timestamps while the configured Ding sensor remains `on`, without relying on a fixed debounce window. diff --git a/README.md b/README.md index 24271be..226032f 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ See what happened, check what is happening, save the moment, and answer the door - **Save a moment from Live.** Optionally show one snapshot button that saves a timestamped image through Home Assistant, with no custom automation. - **See when something last happened.** Optionally show a localized relative time from a timestamp sensor, event entity, or date-and-time helper. - **Know when someone rings.** A temporary doorbell alert works with official Ring event entities and Ring-MQTT Ding binary sensors. Tap it to open Live. -- **Take the doorbell beyond the dashboard.** The included notification blueprint sends a phone alert, then adds a preview when the recording is ready. +- **Take the doorbell beyond the dashboard.** The included notification blueprint works with official Ring and Ring-MQTT, sends a phone alert, then adds a fresh camera preview when it is ready. - **Make it yours without YAML.** Choose cameras, opening behavior, layout, and doorbell features in the visual editor. - **Use it across your home.** Responsive phone, tablet, and desktop layouts; Home Assistant themes; English and German; keyboard and screen-reader support. @@ -162,17 +162,22 @@ The patch is **temporary**, intended only until the upstream Ring library fix is ## Make your doorbell do more -The included **Ring View doorbell notification** blueprint connects your Ring Ding event, recording camera, and Companion app: +The included **Ring View doorbell notification** blueprint connects either an +official Ring Ding event or Ring-MQTT Ding sensor, a preview camera, and the +Companion app: 1. Someone rings → your phone gets an immediate notification. 2. Tap the notification → Home Assistant opens your chosen dashboard. -3. The new recording becomes available → the same notification gains a preview. +3. The official recording or Ring-MQTT snapshot becomes available → the same + notification gains a fresh preview. It works even when the dashboard is closed, and the preview does not start another live session. [![Import the Ring View doorbell notification blueprint.](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https://github.com/thomasgregg/ring-view/blob/main/blueprints/automation/ring_view/doorbell_notification.yaml) -Use the same Ring event in your own Home Assistant automations for porch lights, announcements, or presence-aware alerts. Those are ideas for additional automations—not actions the included blueprint runs automatically. +Use the same doorbell signal in your own Home Assistant automations for porch +lights, announcements, or presence-aware alerts. Those are ideas for +additional automations—not actions the included blueprint runs automatically. [Set up notifications and explore automation ideas](docs/notifications.md) diff --git a/blueprints/automation/ring_view/doorbell_notification.yaml b/blueprints/automation/ring_view/doorbell_notification.yaml index 7e033b9..fd522bf 100644 --- a/blueprints/automation/ring_view/doorbell_notification.yaml +++ b/blueprints/automation/ring_view/doorbell_notification.yaml @@ -1,55 +1,176 @@ blueprint: name: Ring View doorbell notification description: >- - Sends an immediate Home Assistant Companion notification when a doorbell - event is received. Tapping opens the configured Ring View dashboard. When - Ring finishes processing a new recording, the same notification is updated - with a camera preview. The recording camera path does not start another - Ring live-view session. + Sends an immediate Home Assistant Companion notification for an official + Ring or Ring-MQTT doorbell signal. Tapping opens the configured Ring View + dashboard. When the selected preview camera publishes a new recording or + snapshot, the same notification is updated with that image. No additional + Ring live-view session is started. domain: automation + author: Thomas Gregg + source_url: >- + https://github.com/thomasgregg/ring-view/blob/main/blueprints/automation/ring_view/doorbell_notification.yaml homeassistant: min_version: 2026.7.0 input: - doorbell_event: - name: Doorbell event - description: Select the Ring Ding event entity. - selector: - entity: - filter: - - domain: event - device_class: doorbell - recording_camera: - name: Last recording camera - description: Select the Ring last-recording camera entity. - selector: - entity: - filter: - - domain: camera - notification_device: - name: Phone or tablet - description: Select a device running the Home Assistant Companion app. - selector: - device: - filter: - - integration: mobile_app - dashboard_path: - name: Ring View dashboard path - description: Relative Home Assistant path opened when the notification is tapped. - default: /ring-camera-test/ring-view - selector: - text: {} + sources: + name: Ring View sources + icon: mdi:doorbell-video + input: + doorbell_event: + name: Doorbell signal + description: >- + Select the same official Ring Ding event or Ring-MQTT Ding binary + sensor used by the Ring View card. + selector: + entity: + filter: + - domain: event + device_class: doorbell + - domain: binary_sensor + integration: mqtt + recording_camera: + name: Notification preview camera + description: >- + Select the official Ring Last recording camera or the Ring-MQTT + Snapshot camera. A preview is added only after this camera updates. + selector: + entity: + filter: + - domain: camera + delivery: + name: Notification delivery + icon: mdi:cellphone-message + input: + notification_device: + name: Phone or tablet + description: Select a device running the Home Assistant Companion app. + selector: + device: + filter: + - integration: mobile_app + dashboard_path: + name: Ring View dashboard path + description: Relative Home Assistant path opened when the notification is tapped. + default: /lovelace/entrance + selector: + text: {} triggers: - - trigger: event.received - target: - entity_id: !input doorbell_event - options: - event_type: - - ring + # Omitting from/to is intentional: Home Assistant then reports both state + # and attribute changes, including another Ring-MQTT Ding while still on. + - trigger: state + entity_id: !input doorbell_event + +conditions: + - alias: Continue only for a new doorbell press + condition: template + value_template: >- + {% if trigger is not defined or trigger.platform != 'state' %} + {{ false }} + {% else %} + {% set before = trigger.from_state %} + {% set after = trigger.to_state %} + {% set before_state = before.state if before is not none else none %} + {% set recent_before = before_state in [none, 'unknown', 'unavailable'] %} + {% if after is none or after.state in ['unknown', 'unavailable'] %} + {{ false }} + {% elif after.entity_id.startswith('event.') %} + {% set current_event = as_timestamp(after.state, none) %} + {% set previous_event = as_timestamp(before_state, none) %} + {% set event_type = after.attributes.event_type | default('ring', true) %} + {{ event_type == 'ring' and current_event is not none and ( + (previous_event is not none and current_event > previous_event) + or (recent_before + and current_event >= now().timestamp() - 12 + and current_event <= now().timestamp() + 5) + ) }} + {% elif after.entity_id.startswith('binary_sensor.') %} + {% set previous_time_raw = before.attributes.lastDingTime + | default(none) if before is not none else none %} + {% set previous_epoch_raw = before.attributes.lastDing + | default(none) if before is not none else none %} + {% set current_time_raw = after.attributes.lastDingTime + | default(none) %} + {% set current_epoch_raw = after.attributes.lastDing + | default(none) %} + {% set previous_time_number = previous_time_raw | float(none) %} + {% set previous_epoch_number = previous_epoch_raw | float(none) %} + {% set current_time_number = current_time_raw | float(none) %} + {% set current_epoch_number = current_epoch_raw | float(none) %} + {% set previous_time = previous_time_number + if previous_time_number is not none + else as_timestamp(previous_time_raw, none) %} + {% set previous_epoch = previous_epoch_number + if previous_epoch_number is not none + else as_timestamp(previous_epoch_raw, none) %} + {% set current_time = current_time_number + if current_time_number is not none + else as_timestamp(current_time_raw, none) %} + {% set current_epoch = current_epoch_number + if current_epoch_number is not none + else as_timestamp(current_epoch_raw, none) %} + {% set previous_ding = previous_time + if previous_epoch is none + or (previous_time is not none and previous_time >= previous_epoch) + else previous_epoch %} + {% set current_ding = current_time + if current_epoch is none + or (current_time is not none and current_time >= current_epoch) + else current_epoch %} + {% set previous_has_marker = before is not none and ( + 'lastDingTime' in before.attributes or 'lastDing' in before.attributes + ) %} + {% set current_has_marker = + 'lastDingTime' in after.attributes or 'lastDing' in after.attributes %} + {% set previous_update = as_timestamp( + before.last_updated, none + ) if before is not none else none %} + {% set current_update = as_timestamp(after.last_updated, none) %} + {{ after.state == 'on' and ( + before_state == 'off' + or (recent_before and current_ding is not none + and current_ding >= now().timestamp() - 12 + and current_ding <= now().timestamp() + 5) + or (before_state == 'on' and ( + ((previous_has_marker or current_has_marker) + and previous_ding is not none + and current_ding is not none + and current_ding > previous_ding) + or (not previous_has_marker and not current_has_marker + and previous_update is not none + and current_update is not none + and current_update > previous_update) + )) + ) }} + {% else %} + {{ false }} + {% endif %} + {% endif %} variables: - recording_camera_id: !input recording_camera + preview_camera_id: !input recording_camera dashboard_path_value: !input dashboard_path + preview_kind: >- + {% set preview = states[preview_camera_id] %} + {% if preview.attributes.last_video_id | default(none) is not none %} + recording + {% elif preview.attributes.timestamp | default(none) is not none %} + snapshot + {% else %} + camera + {% endif %} + preview_marker_before: >- + {% set preview = states[preview_camera_id] %} + {% if preview_kind == 'recording' %} + {{ preview.attributes.last_video_id }} + {% elif preview_kind == 'snapshot' %} + {% set raw = preview.attributes.timestamp %} + {% set numeric = raw | float(none) %} + {{ numeric if numeric is not none else as_timestamp(raw, none) }} + {% else %} + {{ as_timestamp(preview.last_updated, none) }} + {% endif %} actions: - alias: Notify immediately @@ -63,14 +184,31 @@ actions: group: ring-view url: "{{ dashboard_path_value }}" clickAction: "{{ dashboard_path_value }}" - - alias: Wait for the new Ring recording - wait_for_trigger: - - trigger: state - entity_id: !input recording_camera - attribute: last_video_id + - alias: Wait for a genuinely new preview + # A template wait closes the small race where the camera can update while + # the immediate mobile notification is being sent. + wait_template: >- + {% set preview = states[preview_camera_id] %} + {% if preview_kind == 'recording' %} + {% set current = preview.attributes.last_video_id | default(none) %} + {{ current is not none + and (current ~ '') != (preview_marker_before ~ '') }} + {% elif preview_kind == 'snapshot' %} + {% set raw = preview.attributes.timestamp | default(none) %} + {% set numeric = raw | float(none) %} + {% set current = numeric + if numeric is not none else as_timestamp(raw, none) %} + {{ current is not none + and current > (preview_marker_before | float(-1)) + and (preview.attributes.type | default('ding', true)) == 'ding' }} + {% else %} + {% set current = as_timestamp(preview.last_updated, none) %} + {{ current is not none + and current > (preview_marker_before | float(-1)) }} + {% endif %} timeout: "00:02:00" continue_on_timeout: false - - alias: Add the visitor preview to the existing notification + - alias: Add the fresh preview to the existing notification device_id: !input notification_device domain: mobile_app type: notify @@ -81,9 +219,10 @@ actions: group: ring-view url: "{{ dashboard_path_value }}" clickAction: "{{ dashboard_path_value }}" - entity_id: "{{ recording_camera_id }}" + entity_id: "{{ preview_camera_id }}" image: >- - /api/camera_proxy/{{ recording_camera_id }}?v={{ - state_attr(recording_camera_id, 'last_video_id') }} + /api/camera_proxy/{{ preview_camera_id }}?v={{ + now().timestamp() | int }} mode: restart +max_exceeded: silent diff --git a/docs/compatibility.md b/docs/compatibility.md index 892cd41..6aa46a9 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -18,6 +18,7 @@ controls, spacing, or status design. | Dashboard still | Last recording or Live camera image | Ring-MQTT snapshot `camera.*` | Same passive card. A snapshot camera is also the poster for Event Select recordings. | | Manual snapshot | Configured snapshot camera, otherwise Live camera | Snapshot camera plus its same-device Take Snapshot button | Same camera action and feedback. Ring View waits for Ring-MQTT's timestamp update before saving. | | Doorbell alert | Ding `event.*` | Ding `binary_sensor.*` | Same bell indicator and Open live view action. | +| Phone notification blueprint | Ding event plus Last recording camera | Ding binary sensor plus Snapshot camera | Same immediate notification and later fresh preview. Repeated MQTT presses while the Ding sensor remains `on` are detected. | | Last activity | Timestamp state from a sensor, event, or helper | Ding or motion binary-sensor attributes | Same localized relative time. The freshest supported same-device MQTT activity wins. | | Door access | Any Home Assistant `lock.*` and optional contact sensor | Same | Provider-independent. | @@ -38,10 +39,11 @@ enabled with a different Live camera. Ring-MQTT also requires its [documented one-time Home Assistant camera setup](https://github.com/tsightler/ring-mqtt/wiki/Video-Streaming#home-assistant-generic-camera-configuration) for the RTSP path; that setup is outside the card. The included notification -blueprint and optional Ring WebRTC backend patch still target the official Ring -integration. Other camera pairings remain compatible in principle when a -recording camera exposes `video_url` or `entity_picture` and the Live camera -advertises stream support. +blueprint uses the selected official Last recording camera or Ring-MQTT +Snapshot camera without starting a stream. The optional Ring WebRTC backend +patch still targets only the official Ring integration. Other camera pairings +remain compatible in principle when a recording camera exposes `video_url` or +`entity_picture` and the Live camera advertises stream support. The viewer uses Home Assistant's application-level `show-dialog` contract for dialog placement, browser Back behavior, and independence from responsive card diff --git a/docs/notifications.md b/docs/notifications.md index 49e2877..d62f320 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -8,8 +8,9 @@ In the visual editor, open **Doorbell features** and select either your official Ring Ding event entity, for example `event.front_door_ding`, or a Ring-MQTT Ding binary sensor such as `binary_sensor.front_door_ding`. -A fresh `ring` event or an `off` to `on` Ding transition displays **Someone is -at the door** for twelve seconds. +A fresh `ring` event, an `off` to `on` Ding transition, or a newer Ring-MQTT +Ding timestamp while the sensor remains `on` displays the bell alert for +twelve seconds. Tapping the card during that alert opens Live. An already active Live session is left untouched. The alert expires from the original event time; rebuilding the card does not restart its twelve-second lifetime. @@ -26,8 +27,12 @@ runs in Home Assistant instead. ### Setup 1. Import the blueprint and create an automation from it. -2. Select the Ring **Ding event entity** and the matching **last-recording - camera**. +2. Under **Ring View sources**, select: + + - For official Ring: the **Ding event** and matching **Last recording** + camera. + - For Ring-MQTT: the **Ding binary sensor** and matching **Snapshot** camera. + 3. Choose the phone registered with the Home Assistant **Companion app**. 4. Set **Dashboard path** to the actual view containing your Ring View card, such as `/lovelace/entrance`. Replace the example/test path; the blueprint @@ -37,21 +42,31 @@ runs in Home Assistant instead. ### What happens -- A Ring Ding event sends an immediate **Someone is at the door** notification. +- An official Ring or Ring-MQTT Ding sends an immediate **Someone is at the + door** notification. Repeated Ring-MQTT presses are detected even while its + Ding sensor remains `on`. - Tapping it opens the configured Home Assistant dashboard, not a guaranteed automatically opened Live viewer. -- The automation waits up to **two minutes** for the recording camera's - `last_video_id` to change. -- When the recording is ready, it updates the same tagged notification with a - recording preview. No extra Ring live session is started. -- If no new recording appears within two minutes, the immediate notification - remains; there is no preview update. +- The automation waits up to **two minutes** for a new official recording ID or + a newer Ring-MQTT Ding snapshot timestamp. +- When the chosen preview camera updates, the automation updates the same + tagged notification with its fresh image. No extra Ring live session is + started. +- If the camera does not publish a new image within two minutes, the immediate + notification remains unchanged; an older image is never attached as if it + were current. - Another ring restarts the wait. The shared notification tag updates the same alert rather than creating a separate notification for every stage. -The blueprint requires Home Assistant **2026.7 or newer**, a Ring recording -entity with `last_video_id`, and a working Companion app notification device. -The optional backend patch is not required for this automation. +The blueprint requires Home Assistant **2026.7 or newer**, a supported Ding +entity, a camera preview entity, and a working Companion app notification +device. The optional backend patch is not required for this automation. + +Ring-MQTT snapshot timing depends on the camera and its **Snapshot Mode**. For +the most useful doorbell preview, choose a mode that includes Ding snapshots. +Low-power cameras may not always produce a snapshot while recording; in that +case the immediate alert still arrives and the blueprint safely leaves it +without a stale preview. On some newer wired Ring cameras using 24/7 recording, an [upstream Home Assistant issue](https://github.com/home-assistant/core/issues/176299) @@ -61,9 +76,9 @@ a new recording and therefore leaves the notification without a recording preview after the two-minute wait. The issue reporter found that disabling 24/7 recording and using periodic snapshots restored recording updates. -## Build on the same Ring event +## Build on the same doorbell signal -You can use the doorbell event in additional Home Assistant automations to: +You can use the doorbell signal in additional Home Assistant automations to: - Turn on an entrance light after dark. - Announce a visitor on a speaker. From da13c93ec3bcc34de5cf99d468f3dd255a46fa3a Mon Sep 17 00:00:00 2001 From: Thomas Gregg <2538782+thomasgregg@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:07:55 +0200 Subject: [PATCH 4/5] Clarify official Ring and Ring-MQTT setup --- CHANGELOG.md | 9 ++++++ README.md | 67 +++++++++++++++++++++++++++++++++++++------ docs/compatibility.md | 14 +++++++++ docs/configuration.md | 15 ++++++++-- docs/notifications.md | 4 +-- 5 files changed, 96 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af79185..f82f34c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,15 @@ All notable changes to this project are documented here. - Prefix the visible relative time with **Activity** so it cannot be mistaken for the age of an older recording selected through Ring-MQTT Event Select. +### Documentation + +- Add a plain-language official Ring versus Ring-MQTT feature table and a + recommended mixed-entity setup to the README. +- Explain exactly where to choose **Ding 1**, **Motion 1**, or a transcoded + Ring-MQTT Event Select recording before opening **Last recording**. +- Align the doorbell guide with the current temporary bell and centered **Open + live view** behavior. + ## [0.10.0-beta.1] - 2026-09-13 ### Added diff --git a/README.md b/README.md index 226032f..c8af03a 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ## Your Ring camera. One card. Recording, Live, snapshots, talkback, and door access. -See what happened, check what is happening, save the moment, and answer the door without leaving the same viewer. Ring View brings Ring's separate recording and live camera entities together in one Home Assistant dashboard card. +See what happened, check what is happening, save the moment, and answer the door without leaving the same viewer. Ring View brings Ring media together in one Home Assistant dashboard card. It supports Home Assistant's official Ring integration, Ring-MQTT, or a mix of entities from both.

@@ -20,6 +20,7 @@ See what happened, check what is happening, save the moment, and answer the door ## Contents - [What Ring View can do](#what-you-can-do) +- [Choose official Ring, Ring-MQTT, or both](#choose-official-ring-ring-mqtt-or-both) - [Install and get started](#get-started) - [Configure visually](#configure-visually) - [Choose the dashboard card behavior](#choose-how-the-dashboard-card-works) @@ -43,16 +44,50 @@ See what happened, check what is happening, save the moment, and answer the door - **Make it yours without YAML.** Choose cameras, opening behavior, layout, and doorbell features in the visual editor. - **Use it across your home.** Responsive phone, tablet, and desktop layouts; Home Assistant themes; English and German; keyboard and screen-reader support. +## Choose official Ring, Ring-MQTT, or both + +Ring View supports both Home Assistant's built-in **Ring integration** and the +separate [**Ring-MQTT add-on**](https://github.com/tsightler/ring-mqtt). You do +not have to choose one provider for the +whole card: each feature can use the source that works best for it. The visual +editor, viewer, controls, and layout remain the same whichever entities you +select. + +| Ring View feature | Official Ring integration | Ring-MQTT | Our recommendation | +| --- | --- | --- | --- | +| **Last recording** | ✅ Select the **Last recording** camera. It is the simplest option, but [some cameras using 24/7 recording can remain on an old clip](https://github.com/home-assistant/core/issues/176299). | ✅ Select **Event Select**, then choose the event to play. **Ding 1** is the newest doorbell press; **Motion 1** is the newest motion event. | Use Event Select if the official camera is stale or missing. Otherwise, the official camera is simpler. | +| **Live video** | ✅ Select the **Live view** camera. It works directly and is the only source that supports Ring View talkback. A [known upstream cleanup problem](docs/backend-patch.md) can affect repeated Live sessions on some systems. | ✅ Works after a one-time Home Assistant camera setup using Ring-MQTT's Live stream. It provides video and audio from the doorbell, but not talkback. | Use the official Live view camera, especially when you want **Hold to talk**. | +| **Two-way audio** | ✅ **Hold to talk** is supported. | ❌ Ring-MQTT's Live stream has no microphone return path. This is a source limitation, not a Ring View setting or bug. | Use the official Live view camera. | +| **Doorbell alert inside the card** | ✅ Works through the Ring Ding event while its realtime listener is healthy. Upstream failures can leave that listener stopped ([#526](https://github.com/python-ring-doorbell/python-ring-doorbell/issues/526), [#537](https://github.com/python-ring-doorbell/python-ring-doorbell/issues/537)). | ✅ Works through the Ding binary sensor. | Prefer the Ring-MQTT Ding sensor for reliability today. | +| **Last activity time** | ✅ A Ring event or another Home Assistant timestamp can be used, but a Ring event depends on the same realtime listener. | ✅ Ring-MQTT Ding and motion sensors are supported directly; Ring View chooses the freshest activity time from the device. | Prefer a Ring-MQTT Ding or motion sensor. | +| **Dashboard image and saved snapshots** | ⚠️ A Ring camera may provide a still image, but the official Live camera does not always expose the current Live frame for saving. | ✅ The snapshot camera provides the dashboard image. For a manual save, Ring View asks **Take Snapshot** for a new image first. | Use the Ring-MQTT snapshot camera for the freshest and most dependable image. | +| **Phone-notification blueprint** | ✅ Select the Ding event and Last recording camera. The alert is immediate; a fresh recording preview follows when available. | ✅ Select the Ding binary sensor and Snapshot camera. Repeated presses are detected while Ding remains on, and only a fresh Ding snapshot is attached. | Prefer the Ring-MQTT Ding and Snapshot entities while the official realtime listener is unreliable. Set Snapshot Mode to an option that includes **Ding**. | +| **Door access** | ✅ Any Home Assistant lock and optional contact sensor. | ✅ The same—door access is independent of the camera provider. | Use whichever lock is already connected to Home Assistant. | + +### Recommended mixed setup + +For the most complete experience today, keep both integrations and choose: + +- **Last recording:** Ring-MQTT **Event Select**. +- **Live and Hold to talk:** official Ring **Live view** camera. +- **Dashboard image and manual snapshots:** Ring-MQTT **Snapshot** camera. +- **Doorbell alert and last activity:** Ring-MQTT **Ding** and motion sensors. +- **Phone notification:** Ring-MQTT **Ding** sensor and **Snapshot** camera. +- **Door access:** any Home Assistant lock and optional contact sensor. + +This combines Ring-MQTT's dependable events and snapshots with the official +integration's Live talkback path. You can still choose only official Ring or +only Ring-MQTT when that better matches your installation. See the +[provider guide](docs/compatibility.md#official-ring-and-ring-mqtt-sources) for +the detailed source mapping and limitations. + ## Get started You need Home Assistant **2026.7 or newer** and Ring media exposed through Home -Assistant. Use the official [Ring integration](https://www.home-assistant.io/integrations/ring/), -Ring-MQTT, or mix their entities by role. The official setup uses **Last -recording** and **Live view** cameras. A Ring-MQTT setup uses **Event Select** -for recordings, its snapshot camera, and a Home Assistant camera configured -from Ring-MQTT's live RTSP path. Recording access requires a suitable Ring -subscription. See [Choosing camera entities](docs/configuration.md#choosing-camera-entities) -for both setups. +Assistant. Install the official [Ring integration](https://www.home-assistant.io/integrations/ring/), +Ring-MQTT, or both. Recording access requires a suitable Ring subscription. +Ring View itself needs no Ring login; it uses the entities already available +in Home Assistant. The core viewer can also use another Home Assistant recording camera when it exposes recorded media, and any Live camera that advertises stream support. @@ -82,6 +117,22 @@ YAML is optional. The visual editor lets you select the media sources and config dashboard behavior, manual snapshots, Talk, doorbell features, door access and appearance. +#### If you use Ring-MQTT Event Select for recordings + +Event Select is a Home Assistant menu that decides which recording Ring View +plays. Choosing it in the card editor is only the first step: + +1. Open **Settings → Devices & services → Entities** in Home Assistant. +2. Search for **Event Select**, then open the one belonging to your Ring camera. +3. Choose **Ding 1** for the newest doorbell press or **Motion 1** for the newest + motion event. Higher numbers are older events. +4. Open Ring View and select **Last recording**. + +If the normal recording does not play in your browser, choose the matching +**(Transcoded)** option. Ring View refreshes the temporary playback link when +needed, but it deliberately keeps your selected Ding, Motion, or on-demand +event instead of changing that choice for you. +

Ring View visual configuration editor showing camera selection, Dashboard card, Fullscreen viewer, Snapshots, Doorbell features, Door access, and Card appearance diff --git a/docs/compatibility.md b/docs/compatibility.md index 6aa46a9..5fadf3a 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -22,6 +22,20 @@ controls, spacing, or status design. | Last activity | Timestamp state from a sensor, event, or helper | Ding or motion binary-sensor attributes | Same localized relative time. The freshest supported same-device MQTT activity wins. | | Door access | Any Home Assistant `lock.*` and optional contact sensor | Same | Provider-independent. | +For the broadest feature coverage, use Ring-MQTT Event Select for recordings, +the Ring-MQTT snapshot camera for stills and saved snapshots, Ring-MQTT Ding and +motion sensors for alerts and activity time, and the official Ring Live view +camera for Live video and two-way audio. This recommended mix is a choice of +entities inside the same Ring View editor; it does not create a second card or +change the design. + +Event Select is a persistent Home Assistant menu. The user must open that +entity and choose the desired slot: **Ding 1** is the newest doorbell event, +**Motion 1** is the newest motion event, and higher numbers are older. Ring View +refreshes the playback link for the selected slot but does not silently switch +the user's event choice. If the original recording format does not play, choose +the matching **(Transcoded)** option. + The visual editor always offers the same fields and choices. It never reveals or removes design settings based on an entity's integration. The runtime uses the selected entity's registry identity only to adapt the transport behind the diff --git a/docs/configuration.md b/docs/configuration.md index 3b09455..7a6c405 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -389,9 +389,18 @@ For the official Ring integration, use its last-recording and live-view entities The `_last_recording` and `_live_view` suffixes in this guide are examples, not requirements. Home Assistant entity IDs can be changed and may be assigned differently. In one [field report covering fresh 2K and 4K Ring doorbell installations](https://community.home-assistant.io/t/ring-doorbell-live-stream/855118/8), both camera entries appeared alike and neither entity ID used the expected suffix. Identify the entities by their roles and default enabled state rather than relying on their displayed names or ID suffixes. -For Ring-MQTT recording playback, choose the device's **Event Select** entity as -`recording_entity`. Its current option—such as **Ding 1**, **Motion 1**, or a -transcoded variant—determines which event Ring View plays. Ring View reads the +For Ring-MQTT recording playback: + +1. Choose the device's **Event Select** entity as **Last recording** in the Ring + View editor. +2. Open **Settings → Devices & services → Entities** in Home Assistant. +3. Search for **Event Select**, open the entity for this Ring camera, and choose + the recording you want. **Ding 1** is the newest doorbell press and **Motion + 1** is the newest motion event; higher numbers are older. +4. Open Ring View and select **Last recording**. + +The current Event Select option, including a transcoded variant, determines +which event Ring View plays. Ring View reads the published `recordingUrl` directly. If the signed URL is missing, is within 30 seconds of expiry, or fails once in the browser, Ring View re-selects the current option and waits up to 15 seconds for Ring-MQTT to publish a different, diff --git a/docs/notifications.md b/docs/notifications.md index d62f320..18d1b4e 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -10,8 +10,8 @@ binary sensor such as `binary_sensor.front_door_ding`. A fresh `ring` event, an `off` to `on` Ding transition, or a newer Ring-MQTT Ding timestamp while the sensor remains `on` displays the bell alert for -twelve seconds. -Tapping the card during that alert opens Live. An already active Live session +twelve seconds. If Live is not running, Ring View also offers **Open live +view** in the shared centered message area. An already active Live session is left untouched. The alert expires from the original event time; rebuilding the card does not restart its twelve-second lifetime. From 3cef215fe5855b92f48f69262b49cb9ea2d756aa Mon Sep 17 00:00:00 2001 From: Thomas Gregg <2538782+thomasgregg@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:02:36 +0200 Subject: [PATCH 5/5] Prepare Ring View 0.10.0 stable release --- CHANGELOG.md | 53 +++- README.md | 13 +- docs/compatibility.md | 4 +- docs/notifications.md | 13 +- docs/ring-listener-reliability-analysis.md | 320 +++++++++++++++++++++ package-lock.json | 4 +- package.json | 2 +- 7 files changed, 394 insertions(+), 15 deletions(-) create mode 100644 docs/ring-listener-reliability-analysis.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f82f34c..e51f5a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,43 @@ All notable changes to this project are documented here. ## [Unreleased] +## [0.10.0] - 2026-09-13 + +### Added + +- Support Ring-MQTT Ding and motion binary sensors as last-activity sources, + including renamed same-device companions and ISO, Unix-second, and + Unix-millisecond timestamps. +- Refresh Ring-MQTT snapshots through the device's **Take Snapshot** button and + wait for an explicit new image timestamp before saving the JPEG. +- Play Ring-MQTT Event Select recordings directly in the existing **Last + recording** view, refreshing missing, expiring, failed, or transcoding URLs + through Home Assistant state updates. +- Support official Ring and Ring-MQTT entities in one phone-notification + blueprint, with the same visual inputs and notification flow. + +### Changed + +- Resolve recording, Live, snapshot, doorbell, and activity entities as + independent source roles so official Ring, Ring-MQTT, and mixed setups share + the same Ring View configuration and interface. +- Discover renamed same-device Ring-MQTT companion entities from Home + Assistant's registry while refusing disabled or ambiguous matches. +- Keep the visual editor's sections, controls, and preview geometry consistent + across official Ring and Ring-MQTT source profiles. +- Keep two-way audio limited to an official Ring Live camera because Ring-MQTT + exposes one-way RTSP without a microphone return path. +- Send the phone notification immediately, then update that same alert only + after a fresh official recording or Ring-MQTT Ding snapshot is available. + ### Fixed -- Support official Ring and Ring-MQTT entities in the included phone - notification blueprint, including repeated MQTT presses and fresh snapshot - previews without stale-image fallback. +- Prevent Ring-MQTT snapshot saves from reporting success while persisting a + stale image. +- Reject non-playable Ring-MQTT recording sentinels and URLs that are too close + to expiry, with one bounded refresh attempt after a browser playback failure. +- Cancel pending snapshot and recording refreshes when the viewer closes, + changes mode, becomes hidden, or is suspended. - Detect rapid and repeated Ring-MQTT doorbell presses from advancing `lastDingTime`, `lastDing`, or entity-update timestamps while the configured Ding sensor remains `on`, without relying on a fixed debounce window. @@ -16,6 +48,8 @@ All notable changes to this project are documented here. reconnects, while continuing to ignore stale retained MQTT state. - Prefix the visible relative time with **Activity** so it cannot be mistaken for the age of an older recording selected through Ring-MQTT Event Select. +- Ignore stale, backward, malformed, and implausibly future doorbell markers, + and avoid attaching an old notification preview after a timeout. ### Documentation @@ -25,6 +59,19 @@ All notable changes to this project are documented here. Ring-MQTT Event Select recording before opening **Last recording**. - Align the doorbell guide with the current temporary bell and centered **Open live view** behavior. +- Document how to update the blueprint and configure its official Ring or + Ring-MQTT Ding and preview sources. +- Publish the code- and test-backed analysis of the official Ring realtime + listener failure and its recommended upstream repair sequence. + +### Tests + +- Pass 194 unit and component tests and 116 browser scenarios across desktop + and phone projects. +- Cover provider parity, renamed entities, mixed sources, repeated Dings, + reconnect freshness, timestamp normalization, snapshot refresh, Event Select + playback, signed-URL expiry, cancellation, accessibility, and responsive + layout behavior. ## [0.10.0-beta.1] - 2026-09-13 diff --git a/README.md b/README.md index c8af03a..417118a 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,9 @@ See what happened, check what is happening, save the moment, and answer the door Ring View supports both Home Assistant's built-in **Ring integration** and the separate [**Ring-MQTT add-on**](https://github.com/tsightler/ring-mqtt). You do -not have to choose one provider for the -whole card: each feature can use the source that works best for it. The visual -editor, viewer, controls, and layout remain the same whichever entities you -select. +not have to choose one provider for the whole card: each feature can use the +source that works best for it. The visual editor, viewer, controls, and layout +remain the same whichever entities you select. | Ring View feature | Official Ring integration | Ring-MQTT | Our recommendation | | --- | --- | --- | --- | @@ -226,6 +225,11 @@ It works even when the dashboard is closed, and the preview does not start anoth [![Import the Ring View doorbell notification blueprint.](https://my.home-assistant.io/badges/blueprint_import.svg)](https://my.home-assistant.io/redirect/blueprint_import/?blueprint_url=https://github.com/thomasgregg/ring-view/blob/main/blueprints/automation/ring_view/doorbell_notification.yaml) +Already using an older version? Import it again and choose **Overwrite**. Your +existing automation remains compatible because the blueprint keeps the same +input fields; you can then switch its Ding and preview entities between +official Ring and Ring-MQTT in the normal visual editor. + Use the same doorbell signal in your own Home Assistant automations for porch lights, announcements, or presence-aware alerts. Those are ideas for additional automations—not actions the included blueprint runs automatically. @@ -239,6 +243,7 @@ additional automations—not actions the included blueprint runs automatically. - [Playback, talkback, iPhone rotation, and troubleshooting](docs/playback-and-troubleshooting.md) - [Optional temporary backend patch](docs/backend-patch.md) - [Doorbell notifications and automation blueprint](docs/notifications.md) +- [Official Ring realtime-listener reliability analysis](docs/ring-listener-reliability-analysis.md) - [Security, privacy, and accessibility](docs/privacy-and-accessibility.md) - [Compatibility](docs/compatibility.md) · [Testing and development](docs/testing.md) · [Rollback](docs/rollback.md) · [Changelog](CHANGELOG.md) diff --git a/docs/compatibility.md b/docs/compatibility.md index 5fadf3a..d064a3d 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -2,7 +2,9 @@ ## Supported Home Assistant versions -The release target is Home Assistant 2026.9 and the previous two monthly releases where practical. Version 0.1.0 declares Home Assistant 2026.7.0 as its minimum. +The release target is Home Assistant 2026.9 and the previous two monthly +releases where practical. Ring View declares Home Assistant 2026.7.0 as its +minimum. ## Official Ring and Ring-MQTT sources diff --git a/docs/notifications.md b/docs/notifications.md index 18d1b4e..ee01c31 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -26,18 +26,23 @@ runs in Home Assistant instead. ### Setup -1. Import the blueprint and create an automation from it. +1. Import the blueprint and create an automation from it. If it is already + installed, import it again and choose **Overwrite**; existing automations + keep their configured inputs. 2. Under **Ring View sources**, select: - For official Ring: the **Ding event** and matching **Last recording** camera. - For Ring-MQTT: the **Ding binary sensor** and matching **Snapshot** camera. -3. Choose the phone registered with the Home Assistant **Companion app**. -4. Set **Dashboard path** to the actual view containing your Ring View card, +3. For Ring-MQTT, set the camera's **Snapshot Mode** to an option that includes + **Ding**. Choose **Interval + Ding** if you also want periodic dashboard + images. +4. Choose the phone registered with the Home Assistant **Companion app**. +5. Set **Dashboard path** to the actual view containing your Ring View card, such as `/lovelace/entrance`. Replace the example/test path; the blueprint does not create or configure a dashboard for you. -5. Save, then test with a real doorbell press. Confirm phone notification +6. Save, then test with a real doorbell press. Confirm phone notification permission is enabled. ### What happens diff --git a/docs/ring-listener-reliability-analysis.md b/docs/ring-listener-reliability-analysis.md new file mode 100644 index 0000000..252939f --- /dev/null +++ b/docs/ring-listener-reliability-analysis.md @@ -0,0 +1,320 @@ +# Ring realtime-event failure: deep code and test analysis + +Date: 2026-09-12 +System examined: Home Assistant Core 2026.9.2 on HAOS 18.2, Python 3.14.6, arm64 +Primary symptom: the official Ring app reports a physical doorbell press, while Home Assistant receives no Ding event and the Ring View automation does not run + +## Executive conclusion + +The card and automation are using the correct Home Assistant entities. The failure occurs earlier, in the backend push-notification path, before Home Assistant can produce an event for either the card or the automation. + +The direct failure is an application-level rejection from Google's legacy GCM registration endpoint: + +```text +Error=PHONE_REGISTRATION_ERROR +``` + +The request reached Google and received a parsed response, so this is not consistent with a simple DNS, firewall, or TCP-connectivity failure. It also occurs before the library subscribes the resulting push token with Ring, so changing the doorbell entity, camera entity, external chime, card configuration, or automation trigger cannot repair it. + +The underlying Google-side reason cannot be proven from the returned error alone. The strongest defensible statement is that Google is rejecting the synthetic Android/GCM registration identity used by `firebase-messaging`. Google's Android SDK team has associated a similarly named error with duplicated Firebase installation identities, but that report concerns the official Android SDK and a later Firebase Installation stage. Our failure occurs at the older `c2dm/register3` GCM stage, so that explanation is a useful clue, not a confirmed diagnosis. [Google Firebase Android issue #7025](https://github.com/firebase/firebase-android-sdk/issues/7025) + +Three implementation weaknesses turn that external rejection into a persistent Home Assistant outage: + +1. `firebase-messaging` retries a terminal-looking `PHONE_REGISTRATION_ERROR` with the same identity, then converts it into a generic `RuntimeError`. It does not preserve enough structured error information for a caller to apply an appropriate recovery policy. +2. `python-ring-doorbell` does not have a reliable listener lifecycle. It can claim to be started before the FCM socket has logged in, remain started after the receiver dies, leak callbacks/tasks across retries, and fail to clean up a partially initialized receiver. +3. Home Assistant starts the Ring listener once. An exception ends that task with no retry or health monitor. Reauthentication and reconfiguration also replace the config-entry data without carrying forward the saved FCM credentials, forcing a new Google registration. + +This explains all observed behavior, including why the official Ring app still works: it has its own independent, official push registration. Its notification proves the physical doorbell and Ring cloud event are working; it does not prove Home Assistant's separate unofficial push receiver is registered. + +## Confidence assessment + +| Conclusion | Confidence | Basis | +|---|---:|---| +| The Ring View card and automation entity mapping is correct | High | `event.front_door_ding`, `camera.front_door`, and `camera.front_door_live_view` match the loaded Ring device and the simulated Ding event exercised the UI path. | +| The physical Ding is lost before Home Assistant's event entity | Very high | A real press reaches the official Ring app, while HA's listener never reaches its “started” log and the Ding entity remains unavailable. | +| The immediate failure is Google registration, not Ring account authentication | Very high | Four controlled HA listener starts returned `PHONE_REGISTRATION_ERROR` during GCM registration; Ring devices and cameras loaded successfully. | +| Removing Ring authorized devices can fix this specific state | Low | A full cleanup and reconfiguration produced one clean Ring authorized device but the same Google rejection. The failing identity belongs to the Google push path, not the Ring account-device list. | +| Duplicate Firebase installation ID is the exact Google-side cause | Low to medium | Google documents that cause for an official Android SDK case, but our rejection is at a different protocol stage and fresh identities were also rejected. | +| The libraries need lifecycle and recovery changes even if Google later accepts registration | Very high | Reproduced by focused tests and independently reported upstream. | + +## End-to-end failure chain + +```text +physical button + -> 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",