@@ -4354,7 +4370,7 @@ let f = class extends C {
`;
- if (this.inline && !this.inlineStarted) return c;
+ if (this.inline && !this.inlineStarted) return l;
if (this.mode === "live" && this.mediaStatus === "awaiting-resume")
return h`
@@ -4398,7 +4414,7 @@ let f = class extends C {
${s(this.hass, "viewer.open_ha_camera")}
- ${this.inline ? c : this.renderAlternateModeButton()}
+ ${this.inline ? l : this.renderAlternateModeButton()}
@@ -4413,12 +4429,12 @@ let f = class extends C {
${this.mode === "last_recording" ? h`
${n}
-
` : c}
+ ` : l}
${s(this.hass, "common.retry")}
- ${this.inline ? c : this.renderAlternateModeButton()}
+ ${this.inline ? l : this.renderAlternateModeButton()}
@@ -4439,14 +4455,14 @@ let f = class extends C {
aria-live=${this.viewerFeedback.kind === "error" ? "assertive" : "polite"}
>
-
+
${this.viewerFeedback.message}
`;
}
- return c;
+ return l;
}
renderAlternateModeButton() {
const e = this.mode === "live" ? "last_recording" : "live";
@@ -4465,7 +4481,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, In(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, 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.hass,
e === "live" ? "viewer.mode_selected_live" : "viewer.mode_selected_recording"
), this.syncUrl(), this.startMedia();
@@ -4488,7 +4504,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 && st(this.hass, this.activeEntityId()) && (!be(this.activeEntity()) || this.recordingVideoFailed)) {
+ if (this.mode === "last_recording" && this.hass && Se(this.hass, this.activeEntityId()) && (!we(this.activeEntity()) || this.recordingVideoFailed)) {
if (this.recordingRefreshAttempted) {
this.failRecordingPreparation("viewer.recording_playback_failed");
return;
@@ -4496,7 +4512,7 @@ let f = class extends C {
this.recordingRefreshAttempted = !0, this.refreshRingMqttRecording();
return;
}
- if (this.mode === "last_recording" && this.activeEntityId().startsWith("select.") && !be(this.activeEntity())) {
+ if (this.mode === "last_recording" && this.activeEntityId().startsWith("select.") && !we(this.activeEntity())) {
this.failRecordingPreparation("viewer.recording_source_unsupported");
return;
}
@@ -4506,7 +4522,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(),
- Yn * 1e3
+ Jn * 1e3
);
}
resetMediaAttempt() {
@@ -4527,7 +4543,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,
- ke(i)
+ Ae(i)
);
Promise.resolve().then(() => e.callService(
"select",
@@ -4554,15 +4570,15 @@ let f = class extends C {
return this.finishPendingRecordingUpdate("cancelled"), new Promise((i) => {
const n = window.setTimeout(() => {
this.finishPendingRecordingUpdate("timeout");
- }, sr);
+ }, ar);
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 = ke(t);
- i !== void 0 && i !== e.baseline && be(t) && this.finishPendingRecordingUpdate("updated");
+ const t = this.hass.states[e.entityId], i = Ae(t);
+ i !== void 0 && i !== e.baseline && we(t) && this.finishPendingRecordingUpdate("updated");
}
finishPendingRecordingUpdate(e) {
const t = this.pendingRecordingUpdate;
@@ -4594,7 +4610,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);
- }, Xn);
+ }, er);
}
resetRecordingControls() {
this.clearRecordingControlsTimer(), this.recordingControlsVisible = !0, this.recordingEnded = !1, this.recordingPaused = !1;
@@ -4639,7 +4655,7 @@ let f = class extends C {
return;
}
if (this.automaticLiveRetry && this.mode === "live" && this.retryCount < 1) {
- this.retryCount += 1, this.scheduleLiveReconnect(Jn);
+ this.retryCount += 1, this.scheduleLiveReconnect(Xn);
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");
@@ -4695,7 +4711,7 @@ let f = class extends C {
const t = this.config?.doorbell_entity;
if (!t || !this.hass) return;
const i = this.hass.states[t], n = e?.states[t]?.state ?? this.lastDoorbellState;
- this.lastDoorbellState = i?.state, qt(t, n, i) && this.setRingingUntil(Date.now() + ae);
+ this.lastDoorbellState = i?.state, Nt(t, n, i) && this.setRingingUntil(Date.now() + ae);
}
setRingingUntil(e) {
this.ringAlertTimer !== void 0 && (window.clearTimeout(this.ringAlertTimer), this.ringAlertTimer = void 0);
@@ -4710,25 +4726,25 @@ let f = class extends C {
}
syncUrl() {
if (this.inline || !this.open || !this.config || !this.returnUrl) return;
- const e = An(this.returnUrl, {
+ const e = Sn(this.returnUrl, {
liveEntity: this.config.live_entity,
recordingEntity: this.config.recording_entity,
mode: this.mode,
ringingUntil: this.ringingUntil
});
- Ce(e, e);
+ xe(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, Tn(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 && $e(Re(), o) && Ce(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, 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(
new CustomEvent("viewer-closed", { bubbles: !0, composed: !0 })
), t && this.dispatchEvent(
new CustomEvent("dialog-closed", {
- detail: { dialog: Ne },
+ detail: { dialog: Be },
bubbles: !0,
composed: !0
})
@@ -4755,7 +4771,7 @@ let f = class extends C {
!this.inline || !this.open || this.mode !== "live" || (this.resetVisitorActions(), this.clearAutomaticLiveRecovery(), this.lifecycle.dispose(), this.releaseInlineLive(), this.session = this.lifecycle.current(), this.automaticLiveRetry = !1, this.mediaStatus = "awaiting-resume", this.statusAnnouncement = s(this.hass, "viewer.resume_live"));
}
};
-f.styles = Ln;
+f.styles = Mn;
m([
_({ attribute: !1 })
], f.prototype, "hass", 2);
@@ -4841,20 +4857,20 @@ m([
g()
], f.prototype, "inlineStarted", 2);
f = m([
- W(Ne)
+ W(Be)
], f);
-const ne = /* @__PURE__ */ new Map(), dr = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
+const ne = /* @__PURE__ */ new Map(), cr = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
__proto__: null,
get RingViewDialog() {
return f;
}
}, Symbol.toStringTag, { value: "Module" }));
-var cr = Object.defineProperty, lr = Object.getOwnPropertyDescriptor, I = (e, t, i, n) => {
- for (var r = n > 1 ? void 0 : n ? lr(t, i) : t, o = e.length - 1, a; o >= 0; o--)
+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--)
(a = e[o]) && (r = (n ? a(t, i, r) : a(r)) || r);
- return n && r && cr(t, i, r), r;
+ return n && r && lr(t, i, r), r;
};
-const hr = 1e4, ur = 640, pr = 16 / 9, gt = 16, vr = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(
+const ur = 1e4, pr = 640, vr = 16 / 9, gt = 16, gr = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(
Ai
)}`;
let E = class extends C {
@@ -4865,7 +4881,7 @@ let E = class extends C {
if (this.preview || this.isInCardPicker()) return;
this.updateRingAlert();
const e = this.renderRoot.querySelector(".preview") ?? void 0;
- we(e ?? this, {
+ ye(e ?? this, {
config: this.config,
mode: this.ringAlertVisible ? "live" : ie(this.config),
opener: e,
@@ -4875,7 +4891,7 @@ let E = class extends C {
if (!this.config) return;
e.stopPropagation();
const t = e.currentTarget;
- t.stopInline(), this.inlinePausedForViewer = !0, we(t, {
+ t.stopInline(), this.inlinePausedForViewer = !0, ye(t, {
config: this.config,
mode: e.detail.mode,
opener: t,
@@ -4892,22 +4908,27 @@ let E = class extends C {
};
}
static async getConfigElement() {
- return await Promise.resolve().then(() => wr), document.createElement("ring-view-editor");
+ return await Promise.resolve().then(() => yr), document.createElement("ring-view-editor");
}
static getStubConfig(e) {
- const t = Object.keys(e?.states ?? {}).filter((r) => r.startsWith("camera.")), i = t.find(
- (r) => (Number(e?.states[r]?.attributes.supported_features ?? 0) & 2) !== 0
- ) ?? t.find((r) => /live(_view)?$/i.test(r)) ?? t[1] ?? "camera.live_view", n = t.find(
- (r) => r !== i && (Number(e?.states[r]?.attributes.supported_features ?? 0) & 2) === 0
- ) ?? t.find((r) => r !== i) ?? t[0] ?? "camera.latest_recording";
+ const t = Object.keys(e?.states ?? {}).filter((d) => d.startsWith("camera.")), i = t.find(
+ (d) => (Number(e?.states[d]?.attributes.supported_features ?? 0) & 2) !== 0
+ ) ?? 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
+ ), 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;
return {
- type: He,
- recording_entity: n,
- live_entity: i
+ type: ze,
+ recording_entity: o,
+ live_entity: i,
+ ...a ? { snapshot_entity: a } : {}
};
}
setConfig(e) {
- const t = Te(e);
+ const t = $e(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() {
@@ -4950,18 +4971,18 @@ let E = class extends C {
(t || e.has("preview")) && !this.isInCardPicker() && (this.teardownPreviewLifecycle(), this.setupPreviewLifecycle()), !this.isInCardPicker() && this.config?.dashboard_behavior === "open_viewer" && (e.has("hass") || t) && this.previewVisible && this.refreshPreview(!0), !this.isInCardPicker() && this.hass && this.config && this.scheduleViewerRestore(), !this.isInCardPicker() && !this.preview && this.config?.dashboard_behavior === "interactive" && this.initializeInlineViewer();
}
render() {
- if (!this.hass || !this.config) return c;
+ if (!this.hass || !this.config) return l;
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), l = {
- "--ring-view-aspect-ratio": kn(this.config.aspect_ratio),
+ ), n = this.ringAlertVisible ? "live" : ie(this.config), r = this.isInCardPicker(), o = r || this.preview, a = r ? !1 : w(t), d = {
+ "--ring-view-aspect-ratio": An(this.config.aspect_ratio),
"--ring-view-fit-mode": this.config.fit_mode
};
if (this.config.dashboard_behavior === "interactive" && !o)
return h`
-
+
`;
- const d = !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 = d ? s(this.hass, "card.open_viewer", {
+ const c = !o, u = this.config.last_activity_entity ? H(this.hass, this.config.last_activity_entity) : void 0, p = u === void 0 ? void 0 : Dt(this.hass, u), v = c ? s(this.hass, "card.open_viewer", {
name: i,
- mode: xn(n, this.hass)
+ mode: Pn(n, this.hass)
}) : s(this.hass, "card.preview_alt", { name: i }), y = p ? `${v}. ${p.accessible}.` : v;
return h`
-
+
${!a && !this.previewFailed ? h`
@@ -5000,7 +5021,7 @@ let E = class extends C {
`}
${this.config.show_name || p ? h`
- ${this.config.show_name ? h`
${i}
` : c}
+ ${this.config.show_name ? h`
${i}
` : l}
${p ? h`
- ` : c}
+ ` : l}
- ` : c}
+ ` : l}
${this.ringAlertVisible ? h`
${s(this.hass, "ring.alert")}
-
` : c}
+
` : l}
`;
@@ -5032,7 +5053,7 @@ let E = class extends C {
observePreviewMedia() {
const e = Gi(
this.hass?.states[this.config.recording_entity]
- ), t = le(
+ ), t = he(
this.config.snapshot_entity ? this.hass?.states[this.config.snapshot_entity] : void 0
);
if (!this.previewMarkersInitialized) {
@@ -5056,13 +5077,13 @@ let E = class extends C {
}
scheduleViewerRestore() {
if (this.restoreViewerTimer !== void 0 || !this.config) return;
- const e = Re();
- $e(e, this.config) && (this.restoreViewerTimer = window.setTimeout(() => {
+ const e = Ce();
+ Ee(e, this.config) && (this.restoreViewerTimer = window.setTimeout(() => {
if (this.restoreViewerTimer = void 0, !this.isConnected || !this.hass || !this.config) return;
- const t = Re();
- if (!$e(t, this.config)) return;
+ const t = Ce();
+ if (!Ee(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), we(i ?? this, {
+ this.config.dashboard_behavior === "interactive" && (this.renderRoot.querySelector("ring-view-dialog[inline]")?.stopInline(), this.inlinePausedForViewer = !0), ye(i ?? this, {
config: this.config,
mode: t.mode,
opener: i,
@@ -5075,7 +5096,7 @@ let E = class extends C {
const t = this.config?.doorbell_entity;
if (!t || !e || !this.hass) return;
const i = e.states[t], n = this.hass.states[t];
- if (!qt(t, i?.state, n)) return;
+ if (!Nt(t, i?.state, n)) return;
const r = Date.now();
r - this.lastRingAlertAt < 5e3 || (this.lastRingAlertAt = r, this.updateRingAlert());
}
@@ -5115,7 +5136,7 @@ let E = class extends C {
if (e) {
this.refreshPreview(!0), this.previewRefreshTimer = window.setInterval(() => {
this.refreshPreview(!0);
- }, hr);
+ }, ur);
return;
}
this.stopPreviewRefreshTimer(), this.previewRequestId += 1;
@@ -5128,13 +5149,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 : ur, o = t.clientHeight || t.getBoundingClientRect().height, a = Ut(this.config.aspect_ratio) ?? pr, l = o > 0 ? o : r / a, d = Math.ceil(r * i), u = Math.ceil(l * i);
- if (!e && this.lastPreviewSize && Math.abs(d - 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 : 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)
return;
- this.lastPreviewSize = { width: d, height: u };
+ this.lastPreviewSize = { width: c, height: u };
const p = this.previewEntityId(), v = this.hass, y = ++this.previewRequestId;
try {
- const A = await Cn(v, p, d, u);
+ const A = await En(v, p, c, u);
if (y !== this.previewRequestId || !this.isConnected || !this.previewVisible || this.previewEntityId() !== p)
return;
this.lastPoster = A, this.previewFailed = !1;
@@ -5156,7 +5177,7 @@ let E = class extends C {
return !1;
}
};
-E.styles = Pn;
+E.styles = Ln;
I([
_({ attribute: !1 })
], E.prototype, "hass", 2);
@@ -5179,12 +5200,12 @@ I([
g()
], E.prototype, "ringAlertVisible", 2);
E = I([
- W(Se)
+ W(Re)
], E);
window.customCards = window.customCards || [];
-window.customCards.some((e) => e.type === Se) || window.customCards.push({
- type: Se,
- name: dn,
+window.customCards.some((e) => e.type === Re) || window.customCards.push({
+ type: Re,
+ name: cn,
description: s(void 0, "card.description"),
preview: !0,
getEntitySuggestion: (e, t) => {
@@ -5194,19 +5215,19 @@ window.customCards.some((e) => e.type === Se) || window.customCards.push({
);
return i ? {
config: {
- type: He,
+ type: ze,
recording_entity: t,
live_entity: i
}
} : null;
}
});
-var gr = Object.defineProperty, fr = Object.getOwnPropertyDescriptor, te = (e, t, i, n) => {
- for (var r = n > 1 ? void 0 : n ? fr(t, i) : t, o = e.length - 1, a; o >= 0; o--)
+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--)
(a = e[o]) && (r = (n ? a(t, i, r) : a(r)) || r);
- return n && r && gr(t, i, r), r;
+ return n && r && fr(t, i, r), r;
};
-function mr(e, t) {
+function _r(e, t) {
const i = [
{
name: "dashboard_behavior",
@@ -5282,11 +5303,7 @@ function mr(e, t) {
}
}
}
- ), t.dashboard_behavior === "open_viewer" && ["snapshot", "newest"].includes(t.preview_source) && i.push({
- name: "snapshot_entity",
- required: !0,
- selector: { entity: { domain: "camera" } }
- }), t.dashboard_behavior === "open_viewer" && t.preview_source === "newest" && i.push({
+ ), t.dashboard_behavior === "open_viewer" && t.preview_source === "newest" && i.push({
name: "preview_fallback",
required: !0,
selector: {
@@ -5430,6 +5447,11 @@ function mr(e, t) {
flatten: !0,
iconPath: mt,
schema: [
+ {
+ name: "snapshot_entity",
+ required: t.dashboard_behavior === "open_viewer" && ["snapshot", "newest"].includes(t.preview_source),
+ selector: { entity: { domain: "camera" } }
+ },
{ name: "show_snapshot_button", selector: { boolean: {} } },
...t.show_snapshot_button ? [{
name: "snapshot_directory",
@@ -5539,7 +5561,7 @@ function mr(e, t) {
}
];
}
-const _r = {
+const br = {
recording_entity: "editor.recording_entity",
live_entity: "editor.live_entity",
snapshot_entity: "editor.snapshot_entity",
@@ -5573,8 +5595,9 @@ const _r = {
preview_fallback: "editor.preview_fallback",
aspect_ratio: "editor.aspect_ratio",
fit_mode: "editor.fit_mode"
-}, br = {
+}, wr = {
recording_entity: "editor.helper_recording_entity",
+ live_entity: "editor.helper_live_entity",
dashboard_behavior: "editor.helper_dashboard_behavior",
dashboard_start: "editor.helper_dashboard_start",
dashboard_live_muted: "editor.helper_dashboard_live_muted",
@@ -5599,10 +5622,15 @@ const _r = {
let D = class extends C {
constructor() {
super(...arguments), this.nativeChecked = !1, this.nativeAvailable = !1, this.computeLabel = (e) => {
- const t = _r[e.name];
+ const t = br[e.name];
if (!t) return;
const i = s(this.hass, t);
- if (!["name", "door_contact_entity", "last_activity_entity"].includes(
+ if (e.required || ![
+ "name",
+ "snapshot_entity",
+ "door_contact_entity",
+ "last_activity_entity"
+ ].includes(
e.name
)) return i;
const n = xt(
@@ -5616,11 +5644,11 @@ let D = class extends C {
const i = en(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 = br[e.name];
+ const t = wr[e.name];
return t ? s(this.hass, t) : void 0;
}, this.valueChanged = (e) => {
if (!this.config) return;
- const t = Te({
+ const t = $e({
...this.config,
...e.detail.value
});
@@ -5646,15 +5674,15 @@ let D = class extends C {
};
}
setConfig(e) {
- this.config = Te(e);
+ this.config = $e(e);
}
connectedCallback() {
- super.connectedCallback(), Nt().then((e) => {
+ super.connectedCallback(), qt().then((e) => {
this.isConnected && (this.nativeAvailable = e, this.nativeChecked = !0);
});
}
render() {
- if (!this.hass || !this.config) return c;
+ if (!this.hass || !this.config) return l;
const e = Wi(this.hass, this.config).filter(
(t) => t.kind !== "compatibility" || this.nativeChecked && !this.nativeAvailable
);
@@ -5671,11 +5699,11 @@ let D = class extends C {
`
)}
- ` : c}
+ ` : l}
= {
const HELPERS: Record = {
recording_entity: "editor.helper_recording_entity",
+ live_entity: "editor.helper_live_entity",
dashboard_behavior: "editor.helper_dashboard_behavior",
dashboard_start: "editor.helper_dashboard_start",
dashboard_live_muted: "editor.helper_dashboard_live_muted",
@@ -515,7 +511,13 @@ export class RingViewEditor extends LitElement {
if (!key) return undefined;
const label = localize(this.hass, key);
if (
- !["name", "door_contact_entity", "last_activity_entity"].includes(
+ schema.required
+ || ![
+ "name",
+ "snapshot_entity",
+ "door_contact_entity",
+ "last_activity_entity",
+ ].includes(
schema.name,
)
) return label;
diff --git a/src/ring-view.ts b/src/ring-view.ts
index a1a73ca..e44cc1d 100644
--- a/src/ring-view.ts
+++ b/src/ring-view.ts
@@ -39,6 +39,8 @@ import {
recordingMediaMarker,
selectPreviewEntityId,
} from "./utilities/preview-selection";
+import { isRingMqttEventSelect } from "./utilities/recording-source";
+import { findRingMqttSnapshotCamera } from "./utilities/snapshot";
const PREVIEW_REFRESH_INTERVAL_MS = 10_000;
const PREVIEW_FALLBACK_WIDTH = 640;
@@ -93,8 +95,18 @@ export class RingView extends LitElement {
cameraIds.find((id) => /live(_view)?$/i.test(id)) ??
cameraIds[1] ??
"camera.live_view";
- const recording =
- cameraIds.find(
+ const officialRecording = cameraIds.find(
+ (id) =>
+ id !== live
+ && hass?.entities?.[id]?.platform === "ring"
+ && (Number(hass.states[id]?.attributes.supported_features ?? 0) & 2) === 0,
+ );
+ const ringMqttRecording = Object.keys(hass?.states ?? {}).find((id) =>
+ hass ? isRingMqttEventSelect(hass, id) : false,
+ );
+ const recording = officialRecording
+ ?? ringMqttRecording
+ ?? cameraIds.find(
(id) =>
id !== live &&
(Number(hass?.states[id]?.attributes.supported_features ?? 0) & 2) === 0,
@@ -102,10 +114,14 @@ export class RingView extends LitElement {
cameraIds.find((id) => id !== live) ??
cameraIds[0] ??
"camera.latest_recording";
+ const snapshot = hass && recording === ringMqttRecording
+ ? findRingMqttSnapshotCamera(hass, recording)
+ : undefined;
return {
type: CARD_TYPE,
recording_entity: recording,
live_entity: live,
+ ...(snapshot ? { snapshot_entity: snapshot } : {}),
};
}
diff --git a/src/translations/de.json b/src/translations/de.json
index 39b8ae1..21e951d 100644
--- a/src/translations/de.json
+++ b/src/translations/de.json
@@ -51,6 +51,7 @@
"editor.fit_mode": "Bildanpassung",
"editor.helper_dashboard_behavior": "Behalte die gewohnte Vorschau oder zeige die Kamerasteuerung direkt auf dem Dashboard.",
"editor.helper_recording_entity": "Wähle die offizielle Ring-Kamera „Letzte Aufnahme“ oder Ring-MQTT „Event Select“. Bei Ring-MQTT bestimmt die ausgewählte Ereignisoption, welche Aufnahme gezeigt wird.",
+ "editor.helper_live_entity": "Wähle die offizielle Ring-Kamera „Live-Ansicht“ oder eine Home-Assistant-Kamera, die mit dem Live-RTSP-Pfad von Ring-MQTT eingerichtet wurde.",
"editor.helper_dashboard_start": "Bei Bedarf wird die ausgewählte Ansicht als Standbild angezeigt. Medien starten erst nach Tippen auf das Bild oder eine Ansichtssteuerung.",
"editor.helper_dashboard_live_muted": "Empfohlen für Wand-Tablets und automatische Starts. Ton kann im Player aktiviert werden.",
"editor.helper_default_mode": "Beim direkten Öffnen der Live-Ansicht wird eine Ring-Live-Sitzung gestartet.",
diff --git a/src/translations/en.json b/src/translations/en.json
index 67e485c..dfcc344 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -51,6 +51,7 @@
"editor.fit_mode": "Image crop",
"editor.helper_dashboard_behavior": "Keep the familiar preview, or put camera controls directly on the dashboard.",
"editor.helper_recording_entity": "Choose the official Ring Last recording camera or Ring-MQTT Event Select. For Ring-MQTT, the selected event option determines which recording is shown.",
+ "editor.helper_live_entity": "Choose the official Ring Live view camera or a Home Assistant camera configured from Ring-MQTT’s live RTSP path.",
"editor.helper_dashboard_start": "On demand shows the selected view as a still image and starts media only after you tap the image or a view control.",
"editor.helper_dashboard_live_muted": "Recommended for wall tablets and automatic starts. You can enable sound from the player.",
"editor.helper_default_mode": "Opening directly on Live starts a Ring live session.",
diff --git a/src/utilities/snapshot.ts b/src/utilities/snapshot.ts
index 61b5dc4..470cb06 100644
--- a/src/utilities/snapshot.ts
+++ b/src/utilities/snapshot.ts
@@ -112,6 +112,36 @@ export function findRingMqttSnapshotButton(
);
}
+/** Find the device snapshot camera associated with another Ring-MQTT entity. */
+export function findRingMqttSnapshotCamera(
+ hass: HomeAssistant,
+ anchorEntityId: string,
+): string | undefined {
+ if (
+ resolveEntitySource(hass, "snapshot", anchorEntityId).provider !== "mqtt"
+ ) {
+ return undefined;
+ }
+
+ const exact = findSameDeviceEntityId(
+ hass,
+ anchorEntityId,
+ (entityId, _entity, registry) =>
+ entityId.startsWith("camera.")
+ && registry.platform === "mqtt"
+ && (registry.unique_id?.endsWith("_snapshot") === true
+ || registry.original_name === "Snapshot"),
+ );
+ if (exact) return exact;
+
+ return findSameDeviceEntityId(
+ hass,
+ anchorEntityId,
+ (entityId, _entity, registry) =>
+ entityId.startsWith("camera.") && registry.platform === "mqtt",
+ );
+}
+
export function isRingMqttSnapshotSource(
hass: HomeAssistant,
snapshotEntityId: string,
diff --git a/tests/browser/card.spec.ts b/tests/browser/card.spec.ts
index 9527dbb..7597c9c 100644
--- a/tests/browser/card.spec.ts
+++ b/tests/browser/card.spec.ts
@@ -88,9 +88,17 @@ test("progressively reveals related dashboard preview settings", async ({ page }
name: field.name,
required: field.required ?? false,
}));
- const result = { initial: visiblePreviewFields() } as Record<
+ const snapshotRequired = () => form.schema
+ .find((field) => field.name === "snapshots")
+ ?.schema?.find((field) => field.name === "snapshot_entity")
+ ?.required ?? false;
+ const current = () => ({
+ preview: visiblePreviewFields(),
+ snapshotRequired: snapshotRequired(),
+ });
+ const result = { initial: current() } as Record<
string,
- ReturnType
+ ReturnType
>;
for (const source of ["snapshot", "newest"]) {
@@ -108,27 +116,34 @@ test("progressively reveals related dashboard preview settings", async ({ page }
}),
);
await editor.updateComplete;
- result[source] = visiblePreviewFields();
+ result[source] = current();
}
return result;
});
expect(states).toEqual({
- initial: [
- { name: "dashboard_behavior", required: true },
- { name: "preview_source", required: true },
- ],
- snapshot: [
- { name: "dashboard_behavior", required: true },
- { name: "preview_source", required: true },
- { name: "snapshot_entity", required: true },
- ],
- newest: [
- { name: "dashboard_behavior", required: true },
- { name: "preview_source", required: true },
- { name: "snapshot_entity", required: true },
- { name: "preview_fallback", required: true },
- ],
+ initial: {
+ preview: [
+ { name: "dashboard_behavior", required: true },
+ { name: "preview_source", required: true },
+ ],
+ snapshotRequired: false,
+ },
+ snapshot: {
+ preview: [
+ { name: "dashboard_behavior", required: true },
+ { name: "preview_source", required: true },
+ ],
+ snapshotRequired: true,
+ },
+ newest: {
+ preview: [
+ { name: "dashboard_behavior", required: true },
+ { name: "preview_source", required: true },
+ { name: "preview_fallback", required: true },
+ ],
+ snapshotRequired: true,
+ },
});
});
@@ -578,6 +593,66 @@ test("refreshes a Ring-MQTT Event Select before mounting its recording", async (
)).toBe(1);
});
+test("keeps card and viewer geometry identical across provider profiles", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.route("**/pending-recording.mp4?event=*", async () => undefined);
+ const profiles = [
+ "/demo/?name=1&activity=1&door=1&door_visibility=all",
+ "/demo/?recording_source=mqtt&name=1&activity=1&activity_source=mqtt&door=1&door_visibility=all",
+ ];
+ const measurements: Array> = [];
+
+ for (const profile of profiles) {
+ await page.goto(profile);
+ const cardBox = await page.locator("ring-view ha-card").boundingBox();
+ await page.getByRole("button", { name: /Open Entrance viewer/ }).click();
+ const directRecording = page.locator("video.video-fallback");
+ const nativeRecording = page.locator("ring-view-native-camera-adapter");
+ await expect(directRecording.or(nativeRecording)).toBeAttached();
+ if (await directRecording.count()) {
+ await directRecording.evaluate((element) => {
+ const video = element as HTMLVideoElement;
+ video.play = () => {
+ video.dispatchEvent(new Event("play"));
+ return Promise.resolve();
+ };
+ video.dispatchEvent(new Event("canplay"));
+ });
+ } else {
+ await expect(
+ page.getByRole("img", { name: "Synthetic demo camera media" }),
+ ).toBeVisible();
+ }
+ await expect(page.locator("ring-view-dialog .state-layer")).toHaveCount(0);
+
+ const selectors = {
+ dialog: "ring-view-dialog .dialog",
+ media: "ring-view-dialog .media-frame",
+ modes: "ring-view-dialog .mode-switch",
+ title: "ring-view-dialog .header-copy",
+ actions: "ring-view-dialog .header-actions",
+ visitor: "ring-view-dialog .visitor-action-dock",
+ };
+ const boxes: Record = {
+ card: [cardBox!.x, cardBox!.y, cardBox!.width, cardBox!.height],
+ };
+ const dialogBox = await page.locator(selectors.dialog).boundingBox();
+ for (const [name, selector] of Object.entries(selectors)) {
+ const box = await page.locator(selector).boundingBox();
+ if (!box || !dialogBox) throw new Error(`Missing ${name} geometry`);
+ boxes[name] = [
+ Math.round((box.x - dialogBox.x) * 10) / 10,
+ Math.round((box.y - dialogBox.y) * 10) / 10,
+ Math.round(box.width * 10) / 10,
+ Math.round(box.height * 10) / 10,
+ ];
+ }
+ measurements.push(boxes);
+ }
+
+ expect(measurements[1]).toEqual(measurements[0]);
+});
+
test("dismisses idle direct-recording controls and resumes or replays from the video", async ({
page,
}) => {
diff --git a/tests/unit/card-lifecycle.test.ts b/tests/unit/card-lifecycle.test.ts
index 00d344e..8ef59a2 100644
--- a/tests/unit/card-lifecycle.test.ts
+++ b/tests/unit/card-lifecycle.test.ts
@@ -65,6 +65,55 @@ describe("card stream lifecycle", () => {
expect(TestCameraStream.active).toBe(0);
});
+ it("creates an equivalent Ring-MQTT stub from renamed registry entities", () => {
+ const mqttHass: HomeAssistant = {
+ ...hass,
+ entities: {
+ "select.renamed_events": {
+ entity_id: "select.renamed_events",
+ platform: "mqtt",
+ device_id: "front-door",
+ unique_id: "083a8804c4c5_event_select",
+ original_name: "Event Select",
+ },
+ "camera.renamed_snapshot": {
+ entity_id: "camera.renamed_snapshot",
+ platform: "mqtt",
+ device_id: "front-door",
+ unique_id: "083a8804c4c5_snapshot",
+ original_name: "Snapshot",
+ },
+ "camera.front_door_stream": {
+ entity_id: "camera.front_door_stream",
+ platform: "generic",
+ },
+ },
+ states: {
+ "select.renamed_events": {
+ entity_id: "select.renamed_events",
+ state: "Ding 1",
+ attributes: {
+ eventId: "event-1",
+ recordingUrl: "https://example.test/event.mp4",
+ },
+ },
+ "camera.renamed_snapshot": camera("camera.renamed_snapshot", 0),
+ "camera.front_door_stream": camera("camera.front_door_stream", 2),
+ },
+ };
+ const cardClass = customElements.get("ring-view") as typeof RingView;
+
+ expect(cardClass.getStubConfig(mqttHass)).toMatchObject({
+ recording_entity: "select.renamed_events",
+ live_entity: "camera.front_door_stream",
+ snapshot_entity: "camera.renamed_snapshot",
+ });
+ expect(cardClass.getStubConfig(hass)).toMatchObject({
+ recording_entity: "camera.recording",
+ live_entity: "camera.live",
+ });
+ });
+
it("enforces a 12-column by 3-row minimum in both dashboard modes", () => {
for (const dashboardBehavior of ["open_viewer", "interactive"] as const) {
const card = document.createElement("ring-view");
diff --git a/tests/unit/editor.test.ts b/tests/unit/editor.test.ts
index 2f30dbe..add3aa4 100644
--- a/tests/unit/editor.test.ts
+++ b/tests/unit/editor.test.ts
@@ -25,6 +25,16 @@ const hass: HomeAssistant = {
},
"camera.live": { entity_id: "camera.live", platform: "ring" },
"camera.snapshot": { entity_id: "camera.snapshot", platform: "mqtt" },
+ "select.front_door_events": {
+ entity_id: "select.front_door_events",
+ platform: "mqtt",
+ unique_id: "front-door_event_select",
+ original_name: "Event Select",
+ },
+ "camera.mqtt_live": {
+ entity_id: "camera.mqtt_live",
+ platform: "generic",
+ },
"sensor.front_door_last_activity": {
entity_id: "sensor.front_door_last_activity",
platform: "template",
@@ -39,6 +49,15 @@ const hass: HomeAssistant = {
"camera.recording": entity("camera.recording", 0),
"camera.live": entity("camera.live", 2),
"camera.snapshot": entity("camera.snapshot", 0),
+ "select.front_door_events": {
+ entity_id: "select.front_door_events",
+ state: "Ding 1",
+ attributes: {
+ recordingUrl: "https://example.test/event.mp4",
+ eventId: "event-1",
+ },
+ },
+ "camera.mqtt_live": entity("camera.mqtt_live", 2),
"sensor.front_door_last_activity": {
...entity("sensor.front_door_last_activity", 0),
state: "2026-09-12T10:15:30Z",
@@ -65,20 +84,23 @@ const hass: HomeAssistant = {
};
describe("visual editor", () => {
- it("keeps the same schema when configured entity platforms change", async () => {
+ it("keeps the same schema for official Ring and Ring-MQTT source profiles", async () => {
const schemas: ConfigFormSchema[][] = [];
- for (const livePlatform of ["ring", "generic"]) {
- const editor = document.createElement("ring-view-editor");
- editor.hass = {
- ...hass,
- entities: {
- ...hass.entities,
- "camera.live": { entity_id: "camera.live", platform: livePlatform },
- },
- };
- editor.setConfig({
+ const profiles = [
+ {
recording_entity: "camera.recording",
live_entity: "camera.live",
+ },
+ {
+ recording_entity: "select.front_door_events",
+ live_entity: "camera.mqtt_live",
+ },
+ ];
+ for (const profile of profiles) {
+ const editor = document.createElement("ring-view-editor");
+ editor.hass = hass;
+ editor.setConfig({
+ ...profile,
snapshot_entity: "camera.snapshot",
doorbell_entity: "binary_sensor.front_door_contact",
last_activity_entity: "sensor.front_door_last_activity",
@@ -260,7 +282,7 @@ describe("visual editor", () => {
?.find((field) => field.name === "snapshots")
?.schema?.map((field) => field.name);
- expect(snapshotFields()).toEqual(["show_snapshot_button"]);
+ expect(snapshotFields()).toEqual(["snapshot_entity", "show_snapshot_button"]);
form?.dispatchEvent(
new CustomEvent("value-changed", {
detail: {
@@ -276,6 +298,7 @@ describe("visual editor", () => {
);
await editor.updateComplete;
expect(snapshotFields()).toEqual([
+ "snapshot_entity",
"show_snapshot_button",
"snapshot_directory",
]);
@@ -293,7 +316,7 @@ describe("visual editor", () => {
);
});
- it("only reveals the snapshot settings required by the selected image source", async () => {
+ it("keeps the snapshot source in one section and marks it required when needed", async () => {
const editor = document.createElement("ring-view-editor");
editor.hass = hass;
editor.setConfig({
@@ -313,11 +336,15 @@ describe("visual editor", () => {
name: field.name,
required: field.required ?? false,
}));
+ const snapshotField = () => form?.schema
+ ?.find((field) => field.name === "snapshots")
+ ?.schema?.find((field) => field.name === "snapshot_entity");
expect(previewFields()).toEqual([
{ name: "dashboard_behavior", required: true },
{ name: "preview_source", required: true },
]);
+ expect(snapshotField()?.required).toBe(false);
form?.dispatchEvent(
new CustomEvent("value-changed", {
@@ -336,8 +363,8 @@ describe("visual editor", () => {
expect(previewFields()).toEqual([
{ name: "dashboard_behavior", required: true },
{ name: "preview_source", required: true },
- { name: "snapshot_entity", required: true },
]);
+ expect(snapshotField()?.required).toBe(true);
form?.dispatchEvent(
new CustomEvent("value-changed", {
@@ -356,9 +383,9 @@ describe("visual editor", () => {
expect(previewFields()).toEqual([
{ name: "dashboard_behavior", required: true },
{ name: "preview_source", required: true },
- { name: "snapshot_entity", required: true },
{ name: "preview_fallback", required: true },
]);
+ expect(snapshotField()?.required).toBe(true);
});
it("localizes native form labels, help, and options from the Home Assistant language", async () => {
@@ -389,7 +416,7 @@ describe("visual editor", () => {
"Vollbildansicht",
);
expect(form?.computeLabel?.({ name: "snapshot_entity" })).toBe(
- "Kamera für Geräte-Schnappschuss",
+ "Kamera für Geräte-Schnappschuss (optional)",
);
expect(form?.computeLabel?.({ name: "snapshots" })).toBe("Schnappschüsse");
expect(form?.computeLabel?.({ name: "name" })).toBe("Kameraname (optional)");
diff --git a/tests/unit/snapshot.test.ts b/tests/unit/snapshot.test.ts
index 22c6d21..5c15f62 100644
--- a/tests/unit/snapshot.test.ts
+++ b/tests/unit/snapshot.test.ts
@@ -4,6 +4,7 @@ import type { HassEntity, HomeAssistant } from "../../src/types";
import {
buildSnapshotFilename,
findRingMqttSnapshotButton,
+ findRingMqttSnapshotCamera,
formatSnapshotTimestamp,
normalizeSnapshotDirectory,
selectSnapshotEntityId,
@@ -123,6 +124,39 @@ describe("manual snapshots", () => {
expect(snapshotCaptureMarker(hass, snapshotId)).toBe(1_789_208_130_000);
});
+ it("finds a renamed same-device Ring-MQTT snapshot camera", () => {
+ const { hass } = setup();
+ const anchorId = "select.renamed_events";
+ const snapshotId = "camera.renamed_snapshot";
+ hass.states[anchorId] = {
+ entity_id: anchorId,
+ state: "Ding 1",
+ attributes: {},
+ };
+ hass.states[snapshotId] = camera(snapshotId);
+ hass.entities = {
+ [anchorId]: {
+ entity_id: anchorId,
+ platform: "mqtt",
+ device_id: "front-door",
+ },
+ [snapshotId]: {
+ entity_id: snapshotId,
+ platform: "mqtt",
+ device_id: "front-door",
+ unique_id: "083a8804c4c5_snapshot",
+ original_name: "Snapshot",
+ },
+ };
+
+ expect(findRingMqttSnapshotCamera(hass, anchorId)).toBe(snapshotId);
+ hass.entities[snapshotId] = {
+ ...hass.entities[snapshotId]!,
+ disabled_by: "user",
+ };
+ expect(findRingMqttSnapshotCamera(hass, anchorId)).toBeUndefined();
+ });
+
it("uses only an unambiguous MQTT button when registry identity is compact", () => {
const { hass } = setup();
const snapshotId = "camera.front_door_snapshot";