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
50 changes: 50 additions & 0 deletions demo/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,13 @@ const snapshot: HassEntity = {
timestamp: Date.parse("2026-09-06T12:01:00Z") / 1_000,
},
};
const snapshotRefresh: HassEntity = {
entity_id: "button.device_take_snapshot",
state: "unknown",
attributes: {
friendly_name: "Device Take Snapshot",
},
};
const activityAgeSeconds = Number(query.get("activity_age") ?? 125);
const activity: HassEntity = {
entity_id: "sensor.front_door_last_activity",
Expand Down Expand Up @@ -171,6 +178,16 @@ let hass: HomeAssistant = {
[snapshot.entity_id]: {
entity_id: snapshot.entity_id,
platform: "mqtt",
device_id: "demo-ring-device",
unique_id: "demo-ring-device_snapshot",
original_name: "Snapshot",
},
[snapshotRefresh.entity_id]: {
entity_id: snapshotRefresh.entity_id,
platform: "mqtt",
device_id: "demo-ring-device",
unique_id: "demo-ring-device_take_snapshot",
original_name: "Take Snapshot",
},
[activity.entity_id]: {
entity_id: activity.entity_id,
Expand All @@ -193,6 +210,7 @@ let hass: HomeAssistant = {
[recording.entity_id]: recording,
[live.entity_id]: live,
[snapshot.entity_id]: snapshot,
[snapshotRefresh.entity_id]: snapshotRefresh,
[activity.entity_id]: activity,
[door.entity_id]: door,
[doorContact.entity_id]: doorContact,
Expand All @@ -205,6 +223,13 @@ let hass: HomeAssistant = {
...(window.demoDoorCalls ?? []),
{ domain, service, serviceData, target },
];
if (
domain === "button"
&& service === "press"
&& target?.entity_id === snapshotRefresh.entity_id
) {
queueMicrotask(() => window.demoRefreshSnapshot());
}
},
connection: {
subscribeMessage: async <T>(callback: (message: T) => void) => {
Expand Down Expand Up @@ -318,6 +343,30 @@ window.demoSetEntityState = (entityId: string, state: string) => {
dialogManager.updateHass(hass);
};

window.demoRefreshSnapshot = () => {
const current = hass.states[snapshot.entity_id];
if (!current) return;
const previousTimestamp = Number(current.attributes.timestamp ?? 0);
hass = {
...hass,
states: {
...hass.states,
[snapshot.entity_id]: {
...current,
attributes: {
...current.attributes,
timestamp: Math.max(
Math.floor(Date.now() / 1_000),
previousTimestamp + 1,
),
},
},
},
};
card.hass = hass;
dialogManager.updateHass(hass);
};

declare global {
interface Window {
demoActiveStreams?: number;
Expand All @@ -330,5 +379,6 @@ declare global {
target?: Record<string, unknown>;
}>;
demoSetEntityState: (entityId: string, state: string) => void;
demoRefreshSnapshot: () => void;
}
}
928 changes: 500 additions & 428 deletions dist/ring-view.js

Large diffs are not rendered by default.

28 changes: 19 additions & 9 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,10 +161,10 @@ the interactive dashboard card and fullscreen viewer. It stays hidden on the
passive dashboard card, in Recording, and while an on-demand card is waiting
for its first tap.

The button is both **Take snapshot** and **Save**. Ring View asks Home Assistant
to run `camera.snapshot` and gives it a timestamped JPEG filename. No separate
automation, script, or second save button is required. The icon briefly turns
green when saving succeeds. A readable message appears only when saving fails,
The button is both **Take snapshot** and **Save**. Ring View gives Home Assistant
a timestamped JPEG filename, so no separate automation, script, or second save
button is required. The icon briefly turns green only after the full capture and
save sequence succeeds. A readable message appears when either step fails,
using the same centered status display as Ring View's loading, unavailable,
Retry, Resume, door-action, and talkback information. Talk and door controls
remain visible above the temporary status layer.
Expand All @@ -178,17 +178,27 @@ Ring View automatically chooses the capture entity:
3. Disable the button if neither camera is available.

The first choice uses the same **Device snapshot camera** already configured
for dashboard snapshot previews. The Snapshots section deliberately does not
add another camera selector. The saved image comes from the selected Home
Assistant camera entity; it is not a browser screenshot of the visible video
frame.
for dashboard snapshot previews. When it belongs to Ring-MQTT, Ring View first
finds the enabled **Take Snapshot** button on the same Home Assistant device,
presses it, and waits for the snapshot camera's explicit `timestamp` attribute
to change. Only then does it call `camera.snapshot` to save the fresh image.
This wait reacts to Home Assistant state updates and never polls. It times out
after 15 seconds rather than saving the old image. A missing or ambiguous
Ring-MQTT refresh control is also reported as an error instead of false success.
Renaming either entity is supported through registry and device identity.

The Snapshots section deliberately does not add another camera selector. The
saved image comes from the selected Home Assistant camera entity; it is not a
browser screenshot of the visible video frame.

The official Ring Live camera is a compatibility fallback, not a guarantee of
a current Live frame. `camera.snapshot` can save only the still image that the
entity exposes to Home Assistant. It cannot copy pixels from the active WebRTC
player. With current official Ring behavior, the result can be unavailable or
represent the latest recording instead. Configure the Ring-MQTT snapshot
camera when a fresh device snapshot is required.
camera when a fresh device snapshot is required. Ring devices that cannot take
a snapshot while recording or streaming can still time out; Ring View reports
that limitation and does not save a stale frame.

The default folder is `/media/ring-view`. [Home Assistant OS creates `/media`
automatically](https://www.home-assistant.io/more-info/local-media/setup-media/).
Expand Down
9 changes: 7 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ 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, official and
Ring-MQTT last-activity timestamp formats, sibling resolution and localization,
native editor structure and progressive
Ring-MQTT on-demand snapshot discovery and event-driven refresh waits, 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 @@ -108,7 +109,11 @@ Verify each item on current stable Home Assistant and, where practical, the prev
phone, verify error feedback uses the common centered status display and
never covers the bottom Talk or door controls.
24. If a Ring-MQTT snapshot camera is configured, confirm it is targeted first.
Make it unavailable and confirm the official Live camera is used instead.
Press the snapshot action and confirm its same-device Take Snapshot button
is called before `camera.snapshot`, and that saving waits for the camera's
timestamp to change. Make it unavailable and confirm the official Live
camera is used instead. Remove or disable the refresh button and confirm the
card reports an error without saving an old frame.
Also verify the button does not collide with the mode or fullscreen controls
at the narrowest supported card width.
25. Force a door-service failure and a microphone-permission failure. Confirm
Expand Down
73 changes: 73 additions & 0 deletions src/ring-view-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,10 @@ import {
import { saveMode } from "./utilities/mode-storage";
import {
buildSnapshotFilename,
findRingMqttSnapshotButton,
isRingMqttSnapshotSource,
selectSnapshotEntityId,
snapshotCaptureMarker,
} from "./utilities/snapshot";
import {
createRingViewUrl,
Expand Down Expand Up @@ -102,6 +105,9 @@ const DOOR_SUCCESS_DURATION_MS = 2_000;
const DOOR_ERROR_DURATION_MS = 3_000;
const SNAPSHOT_SUCCESS_DURATION_MS = 2_000;
const SNAPSHOT_ERROR_DURATION_MS = 3_000;
const SNAPSHOT_REFRESH_TIMEOUT_MS = 15_000;

type SnapshotUpdateResult = "updated" | "timeout" | "cancelled";

function snapshotErrorKey(error: unknown): TranslationKey {
const message = error instanceof Error
Expand All @@ -122,6 +128,9 @@ function snapshotErrorKey(error: unknown): TranslationKey {
if (/no image|could not provide|not supported|snapshot unavailable/.test(message)) {
return "snapshot.camera_failed";
}
if (/refresh control unavailable/.test(message)) {
return "snapshot.refresh_unavailable";
}
return "snapshot.failed";
}

Expand Down Expand Up @@ -181,6 +190,12 @@ export class RingViewDialog extends LitElement {
private doorActionToken = 0;
private snapshotFeedbackTimer?: number;
private snapshotActionToken = 0;
private pendingSnapshotUpdate?: {
entityId: string;
baseline?: number;
timer: number;
resolve: (result: SnapshotUpdateResult) => void;
};
private inlineVisible = false;
private inlineActive = false;

Expand Down Expand Up @@ -315,6 +330,7 @@ export class RingViewDialog extends LitElement {
protected willUpdate(changed: PropertyValues<this>): void {
if (!this.open || !this.config) return;
if (changed.has("hass")) {
this.observePendingSnapshotUpdate();
this.detectDoorbellEvent(changed.get("hass") as HomeAssistant | undefined);
}
if (!changed.has("hass")) return;
Expand Down Expand Up @@ -561,6 +577,32 @@ export class RingViewDialog extends LitElement {
if (hass.connection?.connected === false || !hass.callService) {
throw new Error("Home Assistant service API unavailable");
}
const refreshButtonId = findRingMqttSnapshotButton(hass, entityId);
if (isRingMqttSnapshotSource(hass, entityId) && !refreshButtonId) {
throw new Error("Ring-MQTT snapshot refresh control unavailable");
}
if (refreshButtonId) {
const update = this.waitForSnapshotUpdate(
entityId,
snapshotCaptureMarker(hass, entityId),
);
try {
await hass.callService(
"button",
"press",
{},
{ entity_id: refreshButtonId },
);
} catch (error) {
this.finishPendingSnapshotUpdate("cancelled");
throw error;
}
const result = await update;
if (result !== "updated") {
if (result === "cancelled") return;
throw new Error("Snapshot refresh timed out");
}
}
await hass.callService(
"camera",
"snapshot",
Expand Down Expand Up @@ -608,11 +650,42 @@ export class RingViewDialog extends LitElement {
}

private resetSnapshotAction(): void {
this.finishPendingSnapshotUpdate("cancelled");
this.clearSnapshotFeedback();
this.snapshotActionToken += 1;
this.snapshotActionStatus = "idle";
}

private waitForSnapshotUpdate(
entityId: string,
baseline?: number,
): Promise<SnapshotUpdateResult> {
this.finishPendingSnapshotUpdate("cancelled");
return new Promise((resolve) => {
const timer = window.setTimeout(() => {
this.finishPendingSnapshotUpdate("timeout");
}, SNAPSHOT_REFRESH_TIMEOUT_MS);
this.pendingSnapshotUpdate = { entityId, baseline, timer, resolve };
});
}

private observePendingSnapshotUpdate(): void {
const pending = this.pendingSnapshotUpdate;
if (!pending || !this.hass) return;
const marker = snapshotCaptureMarker(this.hass, pending.entityId);
if (marker !== undefined && marker !== pending.baseline) {
this.finishPendingSnapshotUpdate("updated");
}
}

private finishPendingSnapshotUpdate(result: SnapshotUpdateResult): void {
const pending = this.pendingSnapshotUpdate;
if (!pending) return;
this.pendingSnapshotUpdate = undefined;
window.clearTimeout(pending.timer);
pending.resolve(result);
}

private renderVisitorActions(): TemplateResult | typeof nothing {
const showDoor = this.shouldShowDoorControl();
const showTalk = this.shouldShowTalkControl();
Expand Down
3 changes: 2 additions & 1 deletion src/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,12 @@
"viewer.mode_selected_live": "Live-Ansicht ausgewählt.",
"viewer.mode_selected_recording": "Letzte Aufnahme ausgewählt.",
"snapshot.take": "Schnappschuss aufnehmen",
"snapshot.saving": "Schnappschuss wird gespeichert",
"snapshot.saving": "Schnappschuss wird aufgenommen",
"snapshot.saved": "Schnappschuss gespeichert",
"snapshot.unavailable": "Schnappschuss-Kamera nicht verfügbar.",
"snapshot.write_failed": "Home Assistant kann nicht in den Schnappschuss-Ordner schreiben.",
"snapshot.camera_failed": "Die Kamera konnte keinen Schnappschuss bereitstellen.",
"snapshot.refresh_unavailable": "Das Ring-MQTT-Bedienelement zum Aktualisieren des Schnappschusses ist nicht verfügbar.",
"snapshot.connection_failed": "Home Assistant ist nicht verfügbar. Versuche es erneut, sobald die Verbindung wiederhergestellt ist.",
"snapshot.permission_failed": "Du bist nicht berechtigt, diesen Schnappschuss aufzunehmen.",
"snapshot.timeout": "Zeitüberschreitung bei der Schnappschuss-Anfrage.",
Expand Down
3 changes: 2 additions & 1 deletion src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,12 @@
"viewer.mode_selected_live": "Live view selected.",
"viewer.mode_selected_recording": "Last recording selected.",
"snapshot.take": "Take snapshot",
"snapshot.saving": "Saving snapshot",
"snapshot.saving": "Taking snapshot",
"snapshot.saved": "Snapshot saved",
"snapshot.unavailable": "Snapshot camera unavailable.",
"snapshot.write_failed": "Home Assistant cannot write to the snapshot folder.",
"snapshot.camera_failed": "The camera could not provide a snapshot.",
"snapshot.refresh_unavailable": "The Ring-MQTT snapshot refresh control is unavailable.",
"snapshot.connection_failed": "Home Assistant is unavailable. Try again when it reconnects.",
"snapshot.permission_failed": "You do not have permission to take this snapshot.",
"snapshot.timeout": "The snapshot request timed out.",
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface HassEntityRegistryEntry {
platform?: string;
device_id?: string | null;
disabled_by?: string | null;
original_name?: string | null;
unique_id?: string;
}

export interface HomeAssistant {
Expand Down
54 changes: 54 additions & 0 deletions src/utilities/snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type { HomeAssistant, NormalizedConfig } from "../types";
import { entityIsUnavailable, friendlyName } from "./entity-validation";
import {
findSameDeviceEntityId,
resolveEntitySource,
} from "./entity-sources";
import { captureTimestamp } from "./preview-selection";

export const DEFAULT_SNAPSHOT_DIRECTORY = "/media/ring-view";

Expand Down Expand Up @@ -65,6 +70,55 @@ export function selectSnapshotEntityId(
: config.live_entity;
}

export function snapshotCaptureMarker(
hass: HomeAssistant,
entityId: string,
): number | undefined {
return captureTimestamp(hass.states[entityId]);
}

/**
* Ring-MQTT gives every camera device one `take_snapshot` button. Prefer its
* stable discovery identity, then its original integration name, and finally
* accept a single unambiguous MQTT button for compatibility with the compact
* entity registry data exposed by some Home Assistant frontends.
*/
export function findRingMqttSnapshotButton(
hass: HomeAssistant,
snapshotEntityId: string,
): string | undefined {
if (
resolveEntitySource(hass, "snapshot", snapshotEntityId).provider !== "mqtt"
) {
return undefined;
}

const exact = findSameDeviceEntityId(
hass,
snapshotEntityId,
(entityId, _entity, registry) =>
entityId.startsWith("button.")
&& registry.platform === "mqtt"
&& (registry.unique_id?.endsWith("_take_snapshot") === true
|| registry.original_name === "Take Snapshot"),
);
if (exact) return exact;

return findSameDeviceEntityId(
hass,
snapshotEntityId,
(entityId, _entity, registry) =>
entityId.startsWith("button.") && registry.platform === "mqtt",
);
}

export function isRingMqttSnapshotSource(
hass: HomeAssistant,
snapshotEntityId: string,
): boolean {
return resolveEntitySource(hass, "snapshot", snapshotEntityId).provider === "mqtt";
}

export function snapshotCameraSlug(
hass: HomeAssistant,
config: NormalizedConfig,
Expand Down
Loading
Loading