diff --git a/CHANGELOG.md b/CHANGELOG.md index ff6f0eb..e51f5a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,75 @@ 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 + +- 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. +- 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. +- Ignore stale, backward, malformed, and implausibly future doorbell markers, + and avoid attaching an old notification preview after a timeout. + +### 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. +- 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 ### Added diff --git a/README.md b/README.md index 24271be..417118a 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) @@ -39,20 +40,53 @@ 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. +## 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 +116,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 @@ -162,17 +212,27 @@ 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. +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. [Set up notifications and explore automation ideas](docs/notifications.md) @@ -183,6 +243,7 @@ Use the same Ring event in your own Home Assistant automations for porch lights, - [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/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/dist/ring-view.js b/dist/ring-view.js index 578154a..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", 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"; -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 { +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 kt = class { return this.cssText; } }; -const Jt = (e) => new kt(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 kt(i, e, Le); -}, Xt = (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 Jt(i); + return ii(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: 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 ? si : null; + e = e ? li : null; break; case Object: case Array: @@ -64,23 +64,23 @@ 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 }; -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 && ti(this.prototype, t, r); + r !== void 0 && oi(this.prototype, t, r); } } static getPropertyDescriptor(t, i, n) { - const { get: r, set: o } = ii(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 = oi(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 = [...ni(i), ...ri(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 Xt(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(), 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 = `[ -\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) { - 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 hi = (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 ? (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); - 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); + 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 [Rt(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] = hi(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(At)) { - 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); - } else v.startsWith(L) && (c.push({ type: 6, index: o }), r.removeAttribute(v)); - if (Tt.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 === St) 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 ui { +class fi { constructor(t, i) { this._$AV = [], this._$AN = void 0, this._$AD = t, this._$AM = i; } @@ -321,24 +321,24 @@ class ui { 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 fi(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) : ci(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(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 = J.createElement(Pt(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 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 pi extends ge { +class mi extends fe { 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 _i extends fe { constructor() { super(...arguments), this.type = 4; } @@ -438,12 +438,12 @@ class vi extends ge { this.element.toggleAttribute(this.name, !!t && t !== l); } } -class gi 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 gi extends ge { typeof this._$AH == "function" ? this._$AH.call(this.options?.host ?? this.element, t) : this._$AH.handleEvent(t); } } -class fi { +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 fi { return this._$AM._$AU; } _$AI(t) { - B(this, t); + W(this, t); } } -const mi = De.litHtmlPolyfillSupport; -mi?.(Y, ee), (De.litHtmlVersions ??= []).push("3.3.3"); -const _i = (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 = _i(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 bi = Ve.litElementPolyfillSupport; -bi?.({ LitElement: C }); -(Ve.litElementVersions ??= []).push("4.2.2"); -const $t = { 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 Ct = "important", wi = " !" + Ct, ke = Ue(class extends He { +const Mt = "important", Si = " !" + Mt, Ae = ze(class extends Fe { 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 !== 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 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(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 yi = { attribute: !0, type: String, converter: de, reflect: !1, hasChanged: Me }, ki = (e = yi, 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 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" ? 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 Ai = ` +const $i = ` @@ -608,7 +608,7 @@ const Ai = ` -`, Si = { +`, Ci = { "common.camera": "Kamera", "common.close": "Schließen", "common.last_recording": "Letzte Aufnahme", @@ -701,6 +701,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 Dt(e) { + return xi(e).trim().toLowerCase().split(/[-_]/)[0] === "de" ? "de" : "en"; } function s(e, t, i = {}) { - return (ot[Et(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 xt(e, t, i) { +function It(e, t, i) { return e?.localize?.(t) || s(e, i); } -function $i(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: $i(n), + provider: Pi(n), deviceId: n?.device_id ?? void 0 }; } -function Pt(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 = Pt(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 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 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" -], Lt = /* @__PURE__ */ new Set(["unknown", "unavailable"]); -function Li(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 Mt(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 (Ei.test(t)) { + if (Mi.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) < Di ? 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(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 Mi(e) { - return Mt(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 || Lt.has(e.state))) - return oe( - Pi.map( - (t) => Mt(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 && !Lt.has(i.state), r = n ? [Mi(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)) - 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 Di(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 Ii(e, t) { +function Oi(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 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 Dt(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 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 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 && Vi(t, i, r), r; + return n && r && zi(t, i, r), r; }; -const Hi = 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 = Dt(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 }) ); - }, Hi)); + }, 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 Oi = [ +], X.prototype, "entityId", 2); +X = qe([ + j("ring-view-activity-time") +], X); +const Ni = [ "video_url", "recordingUrl", "recording_url" -], zi = 3e4; -function Fi(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 Oi) { + for (const t of Ni) { const i = e.attributes[t]; - if (!(typeof i != "string" || Fi(i))) + if (!(typeof i != "string" || Wi(i))) return i.trim(); } } -function qi(e) { +function Ki(e) { let t; try { t = new URL(e, "http://homeassistant.local"); @@ -1236,8 +1238,8 @@ function qi(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 qi(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 = qi(i); - return n === void 0 || n - t > zi; + 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 It(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 Ni = 2, Bi = 1; +const ji = 2, Zi = 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 zt(e) { + return (Number(e?.attributes.supported_features ?? 0) & ji) !== 0; } -function Te(e, t) { - return z(e, "live", t).provider === "official_ring" && Vt(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) & Bi) !== 0; +function ae(e) { + return (Number(e?.attributes.supported_features ?? 0) & Zi) !== 0; } -function Ki(e) { - return !!(fe(e) || e?.attributes.entity_picture); +function Gi(e) { + return !!(me(e) || e?.attributes.entity_picture); } -function Wi(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 Wi(e, t) { message: s(e, "warning.unavailable", { name: T(i, t.recording_entity) }) - }) : Ki(i) || r.push({ + }) : Gi(i) || r.push({ kind: "recording", message: s(e, "warning.recording_media") }), w(n) ? r.push({ @@ -1317,7 +1319,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({ + }) : 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 Wi(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 Wi(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 Wi(e, t) { function T(e, t) { return e?.attributes.friendly_name || t; } -const ji = [ +const Yi = [ "timestamp", "capture_timestamp", "captured_at", @@ -1396,7 +1398,7 @@ const ji = [ "last_recording_at", "recording_timestamp" ]; -function Zi(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 Zi(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 ji) { - const i = Zi(e.attributes[t]); + for (const t of Yi) { + const i = Ji(e.attributes[t]); if (i !== void 0) return i; } } -function Gi(e) { - return Ae(e); +function Xi(e) { + return Se(e); } function x(e, t) { - return It(e, t); + return Ot(e, t); } -function Qi(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 Qi(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 Qi(e, t, i) { } return t.preview_fallback === "snapshot" ? r : x(e, t); } -function Yi(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 Qi(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 Ji = "/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 Xi(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 en(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 tn(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 nn(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 rn(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 on(e, t) { - return z(e, "snapshot", t).provider === "mqtt"; +function cn(e, t) { + return F(e, "snapshot", t).provider === "mqtt"; } -function sn(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 an(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 an(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 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 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", cn = "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", cn = "Ring View", b = { preview_source: "last_recording", preview_fallback: "last_recording", show_snapshot_button: !1, - snapshot_directory: Ji, + snapshot_directory: nn, aspect_ratio: "16:9", fit_mode: "cover" -}, ln = /* @__PURE__ */ new Set(["last_recording", "live"]), hn = /* @__PURE__ */ new Set([ +}, vn = /* @__PURE__ */ new Set(["last_recording", "live"]), gn = /* @__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([ +]), 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" -]), _n = /* @__PURE__ */ new Set(["open_viewer", "interactive"]), bn = /* @__PURE__ */ new Set(["on_demand", "last_recording", "live"]), wn = ["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 yn(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 kn(e) { +function Rn(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 (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 && !ln.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 && !hn.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 && !un.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 = Xi(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 && !pn.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 && !vn.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" || !wn.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 && !gn.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 && !fn.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 && !mn.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 && !_n.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 && !bn.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 kn(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 Ut(e) { +function Ft(e) { switch (e) { case "16:9": return 16 / 9; @@ -1668,18 +1670,18 @@ function Ut(e) { return; } } -function An(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 Ht() { +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 Sn(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 Ot(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 Tn(e) { - X = e; +const je = "ring-view-dialog", de = 12e3; +let ee; +function En(e) { + ee = e; } -function Rn(e) { - X === e && (X = void 0); +function xn(e) { + ee === e && (ee = void 0); } -function $n(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 ($n(t)) return; - const i = Ot(Ht()); - 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(() => cr), + dialogTag: je, + dialogImport: () => Promise.resolve().then(() => vr), dialogParams: { ...t, returnUrl: i }, addHistory: !0 }, @@ -1741,21 +1743,21 @@ function ye(e, t) { }) ); } -const Cn = 9e3, lt = /* @__PURE__ */ new WeakMap(); -function zt(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 En(e, t, i, n) { - const r = await xn(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 xn(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 xn(e, t) { return e.hassUrl(a.path); }); i.set(t, { - expiresAt: n + Cn, + expiresAt: n + Ln, promise: o }); try { @@ -1776,20 +1778,20 @@ async function xn(e, t) { throw i.delete(t), a; } } -function ut(e) { +function gt(e) { return e === "live" ? h`` : h` `; } -function Pn(e, t) { +function In(e, t) { return s( t, e === "live" ? "common.live" : "common.last_recording" ); } -const Ln = K` +const Vn = K` :host { display: block; min-width: 0; @@ -1942,7 +1944,7 @@ const Ln = K` display: none; } } -`, Mn = K` +`, Un = K` :host { position: fixed; inset: 0; @@ -2718,7 +2720,7 @@ const Ln = K` :host([inline]) .state-layer.with-visitor-controls .spinner { flex: 0 0 auto; } -`, Dn = K` +`, Hn = K` :host { display: block; min-width: 0; @@ -2735,29 +2737,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}`; +`, 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(Ft(e)); + const t = window.localStorage.getItem(Wt(e)); return t === "live" || t === "last_recording" ? t : e.default_mode; } catch { return e.default_mode; } } -function Vn(e, t) { +function zn(e, t) { if (e.remember_last_mode) try { - window.localStorage.setItem(Ft(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 !== $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 !== 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 Un = {}, Hn = (e, t = Un) => 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 && (Hn(e), this.key = t), i; + return t !== this.key && (qn(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 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 && On(t, i, r), r; + return n && r && Nn(t, i, r), r; }; -const pe = "ha-camera-stream", Fn = 8e3, vt = "ring-view-native-layout"; -async function qn(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 qn(e, t) { i !== void 0 && window.clearTimeout(i); } } -async function qt() { - if (customElements.get(pe)) return !0; +async function Kt() { + if (customElements.get(ve)) return !0; try { await (await window.loadCardHelpers?.())?.importMoreInfoControl?.("camera"); } catch { } - return qn(pe, Fn); + 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 qt(); + 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 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 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 && Bn(t, i, r), r; + return n && r && Zn(t, i, r), r; }; -const Wn = 3e3; -function jn(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(jn(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=${Nn(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 ? bt : wt)} + ${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(""); - }, Wn)); + }, Qn)); } dispatchTalkbackState() { const e = { @@ -3551,13 +3553,46 @@ S([ g() ], k.prototype, "readyDispatched", 2); k = S([ - W("ring-view-ring-webrtc-player") + j("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 _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 (t.state === "off" && i.state === "on") return !0; + if (t.state !== "on" || i.state !== "on") return !1; + 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; + } + 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; } -class Gn { +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); +} +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; } @@ -3581,19 +3616,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 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 && Qn(t, i, r), r; + return n && r && tr(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 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 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 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; @@ -3602,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 = nn(e, i); - if (on(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( @@ -3629,19 +3664,19 @@ let f = class extends C { if (await e.callService( "camera", "snapshot", - { filename: dn(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"); - }, rr); + }, cr); } catch (r) { if (n !== this.snapshotActionToken || !this.open) return; - const o = s(this.hass, dr(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"); - }, or); + }, 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); @@ -3669,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; } @@ -3819,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 ?? 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 ?? 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) { @@ -3828,7 +3863,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) { @@ -3860,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 }; @@ -3868,7 +3903,7 @@ let f = class extends C { ${this.inline ? l : h`

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