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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,267 changes: 644 additions & 623 deletions dist/ring-view.js

Large diffs are not rendered by default.

20 changes: 13 additions & 7 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Every Ring View setting is available through Home Assistant's visual card config
| `live_entity` | Yes — Config tab | Required | `camera.*` entity ID | Camera entity that starts the Ring live view. |
| `snapshot_entity` | Yes — Dashboard card | Not set | `camera.*` entity ID | Device snapshot camera, such as the snapshot entity created by Ring-MQTT. Used by snapshot previews and preferred for manual snapshots when configured and available. |
| `name` | Yes — Card appearance | Entity name | Text | Optional label used instead of the recording entity's friendly name. |
| `last_activity_entity` | Yes — Card appearance | Not set | `sensor.*`, `event.*`, or `input_datetime.*` entity ID | Shows the selected entity state's date and time as a localized relative timestamp at the top left. |
| `last_activity_entity` | Yes — Card appearance | Not set | `sensor.*`, `event.*`, `input_datetime.*`, or `binary_sensor.*` entity ID | Shows a localized relative activity timestamp at the top left. Ring-MQTT Ding and motion sensors are supported directly. |
| `default_mode` | Yes — Fullscreen viewer | `last_recording` | `last_recording`, `live` | View selected when the viewer opens. |
| `remember_last_mode` | Yes — Fullscreen viewer | `false` | `true`, `false` | Remembers the most recent view in the current browser and uses it instead of `default_mode`. |
| `autoplay_recording` | Yes — Fullscreen viewer | `true` | `true`, `false` | Starts the latest recording immediately; when disabled, the viewer waits for Play. |
Expand Down Expand Up @@ -135,12 +135,18 @@ appears directly below it. When the name is hidden, the time takes the same
top-left position without leaving an empty line. The option works in the
dashboard card and fullscreen viewer.

The selected entity's state must contain a complete date and time. Ring View
accepts ISO 8601 values, Home Assistant input-datetime values such as
`2026-09-12 10:15:30`, and Unix timestamps in seconds or milliseconds. Sensor,
event, and input-datetime entities are offered in the editor so the timestamp
can represent a Ding, motion, recording, or a template sensor that chooses the
newest relevant event.
For sensor, event, and input-datetime entities, the selected entity's state must
contain a complete date and time. Ring View accepts ISO 8601 values, Home
Assistant input-datetime values such as `2026-09-12 10:15:30`, and Unix
timestamps in seconds or milliseconds.

A Ring-MQTT Ding or motion binary sensor can be selected directly. Ring View
uses its explicit `lastDingTime`, `lastMotionTime`, `lastDing`, and `lastMotion`
attributes and, when Home Assistant's device registry is available, chooses the
freshest supported timestamp across available Ring-MQTT binary sensors on the
same device. This continues to work when entities are renamed. It deliberately
does not use the Ring-MQTT Info sensor state, `last_changed`, or `last_updated`,
because those values can change for reasons unrelated to visitor activity.

Hovering the relative time shows the exact localized date and time. Assistive
technology receives the fuller label **Last activity, 2 minutes ago**. Unknown,
Expand Down
7 changes: 5 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ npm run build

The unit suite covers configuration defaults and validation, manual snapshot
source selection, paths, timezone-aware filenames and service feedback,
snapshot timestamp parsing and freshest-preview fallbacks, last-activity
timestamp formats and localization, native editor structure and progressive
snapshot timestamp parsing and freshest-preview fallbacks, official and
Ring-MQTT last-activity timestamp formats, sibling resolution and localization,
native editor structure and progressive
dashboard fields, entity and talkback capability states, unsupported-camera
fallback, doorbell alerts, timeout invalidation, passive-dashboard privacy,
single-renderer switching, close teardown, disconnect teardown, single-offer
Expand Down Expand Up @@ -98,6 +99,8 @@ Verify each item on current stable Home Assistant and, where practical, the prev
viewer with the camera name both enabled and disabled. Confirm the relative
time updates, the exact hover time is correct, and neither line reaches the
mode or close/fullscreen controls at the narrowest supported card width.
Repeat with a Ring-MQTT Ding sensor and verify a newer motion timestamp from
the same device is reflected without using the Info sensor or HA metadata.
23. Enable manual snapshots with `/media/ring-view`. Confirm the camera button
is absent while on-demand is idle, appears after selecting Live, saves one
timestamped JPEG per tap, and reports success without changing the selected
Expand Down
14 changes: 7 additions & 7 deletions src/activity-time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { css, LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import type { HomeAssistant } from "./types";
import {
activityTimestamp,
formatActivityTime,
resolveActivityTimestamp,
} from "./utilities/activity-time";

const REFRESH_INTERVAL_MS = 30_000;
Expand Down Expand Up @@ -43,9 +43,9 @@ export class RingViewActivityTime extends LitElement {
}

protected render() {
const timestamp = activityTimestamp(
this.entityId ? this.hass?.states[this.entityId] : undefined,
);
const timestamp = this.hass && this.entityId
? resolveActivityTimestamp(this.hass, this.entityId)
: undefined;
if (timestamp === undefined) return nothing;
const display = formatActivityTime(this.hass, timestamp);
return html`
Expand All @@ -57,9 +57,9 @@ export class RingViewActivityTime extends LitElement {

protected updated(): void {
this.clearRefreshTimer();
const timestamp = activityTimestamp(
this.entityId ? this.hass?.states[this.entityId] : undefined,
);
const timestamp = this.hass && this.entityId
? resolveActivityTimestamp(this.hass, this.entityId)
: undefined;
if (timestamp === undefined) return;
this.refreshTimer = window.setTimeout(() => {
this.refreshTimer = undefined;
Expand Down
11 changes: 5 additions & 6 deletions src/ring-view-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import type {
HomeAssistant,
NormalizedConfig,
} from "./types";
import { activityTimestamp } from "./utilities/activity-time";
import { resolveActivityTimestamp } from "./utilities/activity-time";
import { isDoorbellRingTransition } from "./utilities/doorbell";
import {
entityIsUnavailable,
Expand Down Expand Up @@ -337,11 +337,10 @@ export class RingViewDialog extends LitElement {
if (!this.open || !this.hass || !this.config) return nothing;
const title = this.dialogTitle();
const showTitle = this.config.show_name;
const showActivity = activityTimestamp(
this.config.last_activity_entity
? this.hass.states[this.config.last_activity_entity]
: undefined,
) !== undefined;
const showActivity = this.config.last_activity_entity
? resolveActivityTimestamp(this.hass, this.config.last_activity_entity)
!== undefined
: false;
const ratio = this.config.aspect_ratio;
const style = {
"--ring-view-aspect-ratio":
Expand Down
1 change: 1 addition & 0 deletions src/ring-view-editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ function configSchema(
{ domain: "sensor" },
{ domain: "event" },
{ domain: "input_datetime" },
{ domain: "binary_sensor" },
],
},
},
Expand Down
14 changes: 6 additions & 8 deletions src/ring-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ import { loadMode } from "./utilities/mode-storage";
import "./ring-view-dialog";
import type { RingViewDialog } from "./ring-view-dialog";
import {
activityTimestamp,
formatActivityTime,
resolveActivityTimestamp,
} from "./utilities/activity-time";
import { isDoorbellRingTransition } from "./utilities/doorbell";
import {
Expand Down Expand Up @@ -189,8 +189,8 @@ export class RingView extends LitElement {
previous.states[this.config.snapshot_entity] !==
this.hass.states[this.config.snapshot_entity]) ||
(this.config.last_activity_entity !== undefined &&
previous.states[this.config.last_activity_entity] !==
this.hass.states[this.config.last_activity_entity]) ||
resolveActivityTimestamp(previous, this.config.last_activity_entity) !==
resolveActivityTimestamp(this.hass, this.config.last_activity_entity)) ||
(this.config.doorbell_entity !== undefined &&
previous.states[this.config.doorbell_entity] !==
this.hass.states[this.config.doorbell_entity]) ||
Expand Down Expand Up @@ -283,11 +283,9 @@ export class RingView extends LitElement {
}

const previewInteractive = !safePreview;
const activityAt = activityTimestamp(
this.config.last_activity_entity
? this.hass.states[this.config.last_activity_entity]
: undefined,
);
const activityAt = this.config.last_activity_entity
? resolveActivityTimestamp(this.hass, this.config.last_activity_entity)
: undefined;
const activity = activityAt === undefined
? undefined
: formatActivityTime(this.hass, activityAt);
Expand Down
4 changes: 2 additions & 2 deletions src/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"editor.helper_door_hold_to_activate": "Vor Ablauf einer Sekunde loslassen, um abzubrechen. Wenn deaktiviert, betätigt ein einzelnes Tippen die Tür.",
"editor.helper_door_control_location": "Lege fest, wo die Türaktion erscheint. Nur Vollbild ist für gemeinsam genutzte Dashboards sicherer.",
"editor.helper_show_name": "Wird oben links auf der Karte und in der Kameraansicht angezeigt.",
"editor.helper_last_activity_entity": "Zeigt oben links eine relative Zeit an, bei sichtbarem Kameranamen darunter. Wähle einen Zeitstempel-Sensor, eine Ereignis-Entität oder einen Datum-und-Uhrzeit-Helfer, dessen Zustand Datum und Uhrzeit enthält.",
"editor.helper_last_activity_entity": "Zeigt oben links eine relative Zeit an, bei sichtbarem Kameranamen darunter. Wähle einen Zeitstempel-Sensor, eine Ereignis-Entität, einen Datum-und-Uhrzeit-Helfer oder einen Ring-MQTT-Klingel- oder Bewegungssensor.",
"editor.helper_preview_source": "Verwendet immer ein Standbild und bindet auf dem Dashboard keinen Livestream ein.",
"editor.helper_snapshot_entity": "Wähle die von Ring-MQTT oder einer anderen Integration bereitgestellte Schnappschuss-Kamera.",
"editor.helper_preview_fallback": "Wird verwendet, wenn beide Bilder verfügbar sind, ihre Aufnahmezeiten aber nicht zuverlässig verglichen werden können.",
Expand Down Expand Up @@ -98,7 +98,7 @@
"warning.doorbell_event": "Das ausgewählte Türklingelereignis unterstützt den Ereignistyp ring nicht.",
"warning.door_open_unsupported": "Das ausgewählte Schloss unterstützt das Öffnen der Türfalle nicht. Wähle Entriegeln oder ein kompatibles Schloss.",
"warning.snapshot_required": "Wähle eine Kamera für Geräte-Schnappschüsse aus, um diese Vorschauoption zu verwenden.",
"warning.activity_timestamp": "Die ausgewählte Entität für die letzte Aktivität liefert derzeit kein gültiges Datum mit Uhrzeit in ihrem Zustand.",
"warning.activity_timestamp": "Die ausgewählte Entität für die letzte Aktivität liefert derzeit kein gültiges Aktivitätsdatum mit Uhrzeit.",
"warning.compatibility": "Die native Kamerakomponente von Home Assistant ist noch nicht geladen. Die Karte versucht, sie beim Öffnen zu laden.",
"config.invalid": "Ungültige Kartenkonfiguration.",
"config.entity_required": "{label} muss eine Kamera-Entität sein.",
Expand Down
4 changes: 2 additions & 2 deletions src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"editor.helper_door_hold_to_activate": "Release before one second to cancel. Turning this off makes a single tap operate the door.",
"editor.helper_door_control_location": "Choose where the door action appears. Fullscreen only is safer for shared dashboards.",
"editor.helper_show_name": "Shown at the top left of both the card and viewer.",
"editor.helper_last_activity_entity": "Shows relative time at the top left, below the camera name when it is visible. Choose a timestamp sensor, event entity, or Date and/or time helper whose state contains both a date and time.",
"editor.helper_last_activity_entity": "Shows relative time at the top left, below the camera name when it is visible. Choose a timestamp sensor, event entity, Date and/or time helper, or a Ring-MQTT Ding or motion sensor.",
"editor.helper_preview_source": "Always uses a still image and never mounts a live stream on the dashboard.",
"editor.helper_snapshot_entity": "Choose the snapshot camera provided by Ring-MQTT or another integration.",
"editor.helper_preview_fallback": "Used when both images are available but their capture times cannot be reliably compared.",
Expand Down Expand Up @@ -98,7 +98,7 @@
"warning.doorbell_event": "The selected doorbell event does not advertise the ring event type.",
"warning.door_open_unsupported": "The selected lock does not advertise support for opening the door latch. Choose Unlock or a compatible lock.",
"warning.snapshot_required": "Select a device snapshot camera to use this preview option.",
"warning.activity_timestamp": "The selected last activity entity does not currently provide a valid date and time in its state.",
"warning.activity_timestamp": "The selected last activity entity does not currently provide a valid activity date and time.",
"warning.compatibility": "Home Assistant’s native camera component is not loaded yet. The card will attempt to load it when opened.",
"config.invalid": "Invalid card configuration.",
"config.entity_required": "{label} must be a camera entity.",
Expand Down
71 changes: 71 additions & 0 deletions src/utilities/activity-time.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import { languageCode, localize } from "../localize";
import type { HassEntity, HomeAssistant } from "../types";
import {
resolveEntitySource,
sameDeviceEntityIds,
} from "./entity-sources";

const DATE_TIME_PATTERN =
/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:?\d{2})?$/i;
const NUMERIC_PATTERN = /^-?\d+(?:\.\d+)?$/;
const UNIX_MILLISECONDS_THRESHOLD = 100_000_000_000;
const RING_MQTT_ACTIVITY_ATTRIBUTES = [
"lastDingTime",
"lastMotionTime",
"lastDing",
"lastMotion",
] as const;
const UNAVAILABLE_STATES = new Set(["unknown", "unavailable"]);

export interface ActivityTimeDisplay {
relative: string;
Expand Down Expand Up @@ -61,6 +72,66 @@ export function activityTimestamp(entity?: HassEntity): number | undefined {
return parseTimestampValue(entity?.state);
}

function latestTimestamp(values: Array<number | undefined>): number | undefined {
const timestamps = values.filter((value): value is number => value !== undefined);
return timestamps.length > 0 ? Math.max(...timestamps) : undefined;
}

/**
* Ring-MQTT exposes the latest Ding and motion timestamps as attributes on its
* binary sensors. Restricting this to the documented keys avoids presenting
* unrelated Home Assistant metadata as camera activity.
*/
export function ringMqttActivityTimestamp(
entity?: HassEntity,
): number | undefined {
if (!entity || UNAVAILABLE_STATES.has(entity.state)) return undefined;
return latestTimestamp(
RING_MQTT_ACTIVITY_ATTRIBUTES.map((attribute) =>
parseTimestampValue(entity.attributes[attribute]),
),
);
}

/**
* Resolve the configured activity source without relying on last_changed or
* last_updated. Official Ring timestamp entities continue to use their state.
* A Ring-MQTT binary sensor additionally contributes its own approved
* attributes and those of available MQTT binary-sensor siblings on the same
* Home Assistant device.
*/
export function resolveActivityTimestamp(
hass: HomeAssistant,
entityId: string,
): number | undefined {
const selected = hass.states[entityId];
const selectedAvailable = selected && !UNAVAILABLE_STATES.has(selected.state);
const candidates = selectedAvailable
? [activityTimestamp(selected)]
: [];
if (!entityId.startsWith("binary_sensor.")) {
return latestTimestamp(candidates);
}

if (selectedAvailable) {
candidates.push(ringMqttActivityTimestamp(selected));
}
const source = resolveEntitySource(hass, "activity", entityId);
if (source.provider !== "mqtt") return latestTimestamp(candidates);

for (const siblingId of sameDeviceEntityIds(hass, entityId)) {
if (
siblingId === entityId
|| !siblingId.startsWith("binary_sensor.")
|| resolveEntitySource(hass, "activity", siblingId).provider !== "mqtt"
) {
continue;
}
candidates.push(ringMqttActivityTimestamp(hass.states[siblingId]));
}
return latestTimestamp(candidates);
}

function relativeUnit(deltaSeconds: number): {
unit: Intl.RelativeTimeFormatUnit;
seconds: number;
Expand Down
6 changes: 4 additions & 2 deletions src/utilities/entity-validation.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { localize } from "../localize";
import type { HassEntity, HomeAssistant, NormalizedConfig } from "../types";
import { activityTimestamp } from "./activity-time";
import { resolveActivityTimestamp } from "./activity-time";
import { resolveEntitySource } from "./entity-sources";

export const CAMERA_STREAM_FEATURE = 2;
Expand Down Expand Up @@ -124,7 +124,9 @@ export function validateEntities(
name: friendlyName(activity, config.last_activity_entity),
}),
});
} else if (activityTimestamp(activity) === undefined) {
} else if (
resolveActivityTimestamp(hass, config.last_activity_entity) === undefined
) {
warnings.push({
kind: "last_activity",
message: localize(hass, "warning.activity_timestamp"),
Expand Down
3 changes: 2 additions & 1 deletion tests/browser/card.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,12 +186,13 @@ test("places the optional last activity source beside the name appearance contro
{ domain: "sensor" },
{ domain: "event" },
{ domain: "input_datetime" },
{ domain: "binary_sensor" },
],
},
},
label: "Last activity timestamp (optional)",
helper:
"Shows relative time at the top left, below the camera name when it is visible. Choose a timestamp sensor, event entity, or Date and/or time helper whose state contains both a date and time.",
"Shows relative time at the top left, below the camera name when it is visible. Choose a timestamp sensor, event entity, Date and/or time helper, or a Ring-MQTT Ding or motion sensor.",
});
});

Expand Down
Loading
Loading