`;
@@ -3877,7 +3891,7 @@ let f = class extends x {
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();
- }, $o));
+ }, Co));
}
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");
@@ -3899,7 +3913,7 @@ let f = class extends x {
);
this.doorActionStatus = "success", this.clearViewerFeedback("door"), this.statusAnnouncement = s, this.doorFeedbackTimer = window.setTimeout(() => {
o === this.doorActionToken && (this.doorFeedbackTimer = void 0, this.clearViewerFeedback("door"), this.doorActionStatus = "idle");
- }, To);
+ }, xo);
} catch {
if (o !== this.doorActionToken || !this.open) return;
const s = r(
@@ -3908,7 +3922,7 @@ let f = class extends x {
);
this.doorActionStatus = "error", this.setViewerFeedback("door", s, "error"), this.statusAnnouncement = s, this.doorFeedbackTimer = window.setTimeout(() => {
o === this.doorActionToken && (this.doorFeedbackTimer = void 0, this.clearViewerFeedback("door"), this.doorActionStatus = "idle");
- }, Co);
+ }, Ro);
}
}
clearDoorFeedback() {
@@ -3924,7 +3938,7 @@ let f = class extends x {
this.viewerFeedback?.source === e && (this.viewerFeedback = void 0);
}
renderMedia() {
- const e = this.activeEntity(), t = this.activeEntityId(), i = w(e), o = !i && !this.suspended && (this.mode === "live" || this.recordingStarted), s = kt(this.config.aspect_ratio), n = $t(this.hass, e, t), a = this.mode === "last_recording" && typeof e?.attributes.video_url == "string" ? e.attributes.video_url : void 0, c = o && ["pending", "ready", "playback-blocked"].includes(this.mediaStatus), d = !!(c && a && !this.recordingVideoFailed), p = !!(o && this.mode === "live" && this.config.two_way_audio && me(this.hass, t)), v = !!(this.inline && !this.inlineStarted || this.mode === "last_recording" && !this.recordingStarted);
+ const e = this.activeEntity(), t = this.activeEntityId(), i = w(e), o = !i && !this.suspended && (this.mode === "live" || this.recordingStarted), s = kt(this.config.aspect_ratio), n = $t(this.hass, e, t), a = this.mode === "last_recording" && typeof e?.attributes.video_url == "string" ? e.attributes.video_url : void 0, l = o && ["pending", "ready", "playback-blocked"].includes(this.mediaStatus), d = !!(l && a && !this.recordingVideoFailed), p = !!(o && this.mode === "live" && this.config.two_way_audio && me(this.hass, t)), v = !!(this.inline && !this.inlineStarted || this.mode === "last_recording" && !this.recordingStarted);
return h`
- ` : l}
- ${c && !d ? it(
+ ` : c}
+ ${l && !d ? it(
`${t}:${this.session}`,
h`
${p ? h`
@@ -3986,7 +4000,7 @@ let f = class extends x {
>
`}
`
- ) : l}
+ ) : c}
${d ? it(
`${t}:${this.session}:recording-video`,
h`
@@ -4004,7 +4018,7 @@ let f = class extends x {
.controls=${this.recordingControlsVisible}
.muted=${this.recordingMuted}
tabindex="0"
- aria-label=${this.recordingControlsVisible ? l : r(this.hass, "viewer.play_recording")}
+ aria-label=${this.recordingControlsVisible ? c : r(this.hass, "viewer.play_recording")}
@canplay=${this.handleRecordingCanPlay}
@error=${this.handleRecordingVideoError}
@play=${this.handleRecordingPlay}
@@ -4017,7 +4031,7 @@ let f = class extends x {
@focus=${this.handleRecordingControlsInteraction}
>
`
- ) : l}
+ ) : c}
${this.renderStateLayer(i, d)}
`;
@@ -4062,7 +4076,7 @@ let f = class extends x {
${r(this.hass, "common.retry")}
- ${this.inline ? l : this.renderAlternateModeButton()}
+ ${this.inline ? c : this.renderAlternateModeButton()}
@@ -4078,13 +4092,13 @@ let f = class extends x {
`;
- if (this.inline && !this.inlineStarted) return l;
+ if (this.inline && !this.inlineStarted) return c;
if (this.mode === "live" && this.mediaStatus === "awaiting-resume")
return h`
@@ -4122,7 +4136,7 @@ let f = class extends x {
${r(this.hass, "viewer.open_ha_camera")}
- ${this.inline ? l : this.renderAlternateModeButton()}
+ ${this.inline ? c : this.renderAlternateModeButton()}
@@ -4136,12 +4150,12 @@ let f = class extends x {
${this.mode === "last_recording" ? h`
${r(this.hass, "viewer.ring_protect")}
-
` : l}
+ ` : c}
${r(this.hass, "common.retry")}
- ${this.inline ? l : this.renderAlternateModeButton()}
+ ${this.inline ? c : this.renderAlternateModeButton()}
@@ -4161,14 +4175,14 @@ let f = class extends x {
aria-live=${this.viewerFeedback.kind === "error" ? "assertive" : "polite"}
>
-
+
${this.viewerFeedback.message}
`;
}
- return l;
+ return c;
}
renderAlternateModeButton() {
const e = this.mode === "live" ? "last_recording" : "live";
@@ -4187,7 +4201,7 @@ let f = class extends x {
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, no(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 = r(
+ this.resetVisitorActions(), this.resetSnapshotAction(), this.clearAutomaticLiveRecovery(), this.lifecycle.dispose(), this.releaseInlineLive(), this.automaticLiveRetry = !0, this.mode = e, ao(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 = r(
this.hass,
e === "live" ? "viewer.mode_selected_live" : "viewer.mode_selected_recording"
), this.syncUrl(), this.startMedia();
@@ -4216,7 +4230,7 @@ let f = class extends x {
}
this.mode === "last_recording" && this.resetRecordingControls(), this.mediaStatus = "pending", this.session = this.lifecycle.next(), this.lifecycle.scheduleTimeout(
() => this.failMedia(),
- ko * 1e3
+ So * 1e3
);
}
resetMediaAttempt() {
@@ -4242,7 +4256,7 @@ let f = class extends x {
scheduleRecordingControlsHide(e, t) {
this.clearRecordingControlsTimer(), this.recordingControlsTimer = window.setTimeout(() => {
this.recordingControlsTimer = void 0, this.isCurrentRecording(e, t) && (e.controls = !1, this.recordingControlsVisible = !1);
- }, So);
+ }, To);
}
resetRecordingControls() {
this.clearRecordingControlsTimer(), this.recordingControlsVisible = !0, this.recordingEnded = !1, this.recordingPaused = !1;
@@ -4287,7 +4301,7 @@ let f = class extends x {
return;
}
if (this.automaticLiveRetry && this.mode === "live" && this.retryCount < 1) {
- this.retryCount += 1, this.scheduleLiveReconnect(Ao);
+ this.retryCount += 1, this.scheduleLiveReconnect($o);
return;
}
this.lifecycle.dispose(), this.releaseInlineLive(), this.session = this.lifecycle.current(), this.mediaStatus = "error", this.statusAnnouncement = this.mode === "live" ? r(this.hass, "viewer.live_failed") : r(this.hass, "viewer.recording_unavailable");
@@ -4358,7 +4372,7 @@ let f = class extends x {
}
syncUrl() {
if (this.inline || !this.open || !this.config || !this.returnUrl) return;
- const e = ji(this.returnUrl, {
+ const e = Gi(this.returnUrl, {
liveEntity: this.config.live_entity,
recordingEntity: this.config.recording_entity,
mode: this.mode,
@@ -4371,7 +4385,7 @@ let f = class extends x {
const i = this.inline;
this.clearAutomaticLiveRecovery();
const o = this.opener, s = this.returnUrl, n = 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, Gi(this), this.suspended = !1, this.resetMediaAttempt(), this.automaticLiveRetry = !0, this.recordingStarted = !0, this.statusAnnouncement = "", this.resetVisitorActions(), this.resetSnapshotAction(), this.setRingingUntil(), this.lastDoorbellState = void 0, this.detachGlobalListeners(), document.fullscreenElement && document.exitFullscreen().catch(() => {
+ this.renderRoot.querySelector("ring-view-ring-webrtc-player")?.stopTalking(), this.lifecycle.dispose(), this.releaseInlineLive(), this.session = this.lifecycle.current(), this.mediaStatus = "idle", this.open = !1, Qi(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 && s && n && ye(we(), n) && ke(s, null), i || this.dispatchEvent(
new CustomEvent("viewer-closed", { bubbles: !0, composed: !0 })
), t && this.dispatchEvent(
@@ -4403,7 +4417,7 @@ let f = class extends x {
!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 = r(this.hass, "viewer.resume_live"));
}
};
-f.styles = io;
+f.styles = so;
m([
_({ attribute: !1 })
], f.prototype, "hass", 2);
@@ -4485,12 +4499,12 @@ m([
f = m([
N(He)
], f);
-const te = /* @__PURE__ */ new Map(), Lo = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
+const te = /* @__PURE__ */ new Map(), Mo = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
__proto__: null,
get RingViewDialog() {
return f;
}
-}, Symbol.toStringTag, { value: "Module" })), Po = [
+}, Symbol.toStringTag, { value: "Module" })), Do = [
"timestamp",
"capture_timestamp",
"captured_at",
@@ -4498,7 +4512,7 @@ const te = /* @__PURE__ */ new Map(), Lo = /* @__PURE__ */ Object.freeze(/* @__P
"last_recording_at",
"recording_timestamp"
];
-function Mo(e) {
+function Io(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;
@@ -4510,12 +4524,12 @@ function Mo(e) {
}
function Ae(e) {
if (e)
- for (const t of Po) {
- const i = Mo(e.attributes[t]);
+ for (const t of Do) {
+ const i = Io(e.attributes[t]);
if (i !== void 0) return i;
}
}
-function Do(e) {
+function Vo(e) {
if (e)
for (const t of ["last_video_id", "video_url", "entity_picture"]) {
const i = e.attributes[t];
@@ -4523,13 +4537,13 @@ function Do(e) {
if (typeof i == "number" && Number.isFinite(i)) return `${t}:${i}`;
}
}
-function Io(e, t, i) {
+function Ho(e, t, i) {
const o = t.recording_entity, s = t.snapshot_entity;
if (!s) return o;
- const n = e.states[o], a = e.states[s], c = !w(n), d = !w(a);
- if (c && !d) return o;
- if (d && !c) return s;
- if (c && d) {
+ const n = e.states[o], a = e.states[s], l = !w(n), d = !w(a);
+ if (l && !d) return o;
+ if (d && !l) return s;
+ if (l && d) {
const p = Ae(n), v = Ae(a);
if (p !== void 0 && v !== void 0)
return p !== v ? v > p ? s : o : t.preview_fallback === "snapshot" ? s : o;
@@ -4538,26 +4552,26 @@ function Io(e, t, i) {
}
return t.preview_fallback === "snapshot" ? s : o;
}
-function Vo(e, t, i, o) {
+function Oo(e, t, i, o) {
switch (t.preview_source) {
case "live":
return t.live_entity;
case "snapshot":
return t.snapshot_entity ?? t.recording_entity;
case "newest":
- return Io(e, t, o);
+ return Ho(e, t, o);
case "default":
return i === "live" ? t.live_entity : t.recording_entity;
default:
return t.recording_entity;
}
}
-var Ho = Object.defineProperty, Oo = Object.getOwnPropertyDescriptor, D = (e, t, i, o) => {
- for (var s = o > 1 ? void 0 : o ? Oo(t, i) : t, n = e.length - 1, a; n >= 0; n--)
+var Uo = Object.defineProperty, zo = Object.getOwnPropertyDescriptor, D = (e, t, i, o) => {
+ for (var s = o > 1 ? void 0 : o ? zo(t, i) : t, n = e.length - 1, a; n >= 0; n--)
(a = e[n]) && (s = (o ? a(t, i, s) : a(s)) || s);
- return o && s && Ho(t, i, s), s;
+ return o && s && Uo(t, i, s), s;
};
-const Uo = 1e4, zo = 640, Fo = 16 / 9, st = 16, No = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(
+const Fo = 1e4, No = 640, qo = 16 / 9, st = 16, Bo = `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(
di
)}`;
let R = class extends x {
@@ -4595,7 +4609,7 @@ let R = class extends x {
};
}
static async getConfigElement() {
- return await Promise.resolve().then(() => Zo), document.createElement("ring-view-editor");
+ return await Promise.resolve().then(() => Yo), document.createElement("ring-view-editor");
}
static getStubConfig(e) {
const t = Object.keys(e?.states ?? {}).filter((s) => s.startsWith("camera.")), i = t.find(
@@ -4653,18 +4667,18 @@ let R = class extends x {
(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 l;
+ if (!this.hass || !this.config) return c;
const e = this.previewEntityId(), t = this.hass.states[e], i = this.config.name || S(
this.hass.states[this.config.recording_entity],
r(this.hass, "common.camera")
- ), o = this.ringAlertVisible ? "live" : ee(this.config), s = this.isInCardPicker(), n = s || this.preview, a = s ? !1 : w(t), c = {
- "--ring-view-aspect-ratio": Wi(this.config.aspect_ratio),
+ ), o = this.ringAlertVisible ? "live" : ee(this.config), s = this.isInCardPicker(), n = s || this.preview, a = s ? !1 : w(t), l = {
+ "--ring-view-aspect-ratio": Zi(this.config.aspect_ratio),
"--ring-view-fit-mode": this.config.fit_mode
};
if (this.config.dashboard_behavior === "interactive" && !n)
return h`
-
+
+
${!a && !this.previewFailed ? h`
@@ -4705,7 +4719,7 @@ let R = class extends x {
`}
${this.config.show_name || v ? h`
- ${this.config.show_name ? h`
${i}
` : l}
+ ${this.config.show_name ? h`
${i}
` : c}
${v ? h`
- ` : l}
+ ` : c}
- ` : l}
+ ` : c}
${this.ringAlertVisible ? h`
${r(this.hass, "ring.alert")}
-
` : l}
+
` : c}
`;
}
previewEntityId() {
- return Vo(
+ return Oo(
this.hass,
this.config,
ee(this.config),
@@ -4735,7 +4749,7 @@ let R = class extends x {
);
}
observePreviewMedia() {
- const e = Do(
+ const e = Vo(
this.hass?.states[this.config.recording_entity]
), t = Ae(
this.config.snapshot_entity ? this.hass?.states[this.config.snapshot_entity] : void 0
@@ -4820,7 +4834,7 @@ let R = class extends x {
if (e) {
this.refreshPreview(!0), this.previewRefreshTimer = window.setInterval(() => {
this.refreshPreview(!0);
- }, Uo);
+ }, Fo);
return;
}
this.stopPreviewRefreshTimer(), this.previewRequestId += 1;
@@ -4833,13 +4847,13 @@ let R = class extends x {
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), o = t.clientWidth || t.getBoundingClientRect().width, s = o > 0 ? o : zo, n = t.clientHeight || t.getBoundingClientRect().height, a = kt(this.config.aspect_ratio) ?? Fo, c = n > 0 ? n : s / a, d = Math.ceil(s * i), p = Math.ceil(c * i);
+ const i = Math.max(1, window.devicePixelRatio || 1), o = t.clientWidth || t.getBoundingClientRect().width, s = o > 0 ? o : No, n = t.clientHeight || t.getBoundingClientRect().height, a = kt(this.config.aspect_ratio) ?? qo, l = n > 0 ? n : s / a, d = Math.ceil(s * i), p = Math.ceil(l * i);
if (!e && this.lastPreviewSize && Math.abs(d - this.lastPreviewSize.width) < st && Math.abs(p - this.lastPreviewSize.height) < st)
return;
this.lastPreviewSize = { width: d, height: p };
const v = this.previewEntityId(), g = this.hass, A = ++this.previewRequestId;
try {
- const T = await Ji(g, v, d, p);
+ const T = await eo(g, v, d, p);
if (A !== this.previewRequestId || !this.isConnected || !this.previewVisible || this.previewEntityId() !== v)
return;
this.lastPoster = T, this.previewFailed = !1;
@@ -4861,7 +4875,7 @@ let R = class extends x {
return !1;
}
};
-R.styles = to;
+R.styles = oo;
D([
_({ attribute: !1 })
], R.prototype, "hass", 2);
@@ -4889,7 +4903,7 @@ R = D([
window.customCards = window.customCards || [];
window.customCards.some((e) => e.type === _e) || window.customCards.push({
type: _e,
- name: Mi,
+ name: Ii,
description: r(void 0, "card.description"),
preview: !0,
getEntitySuggestion: (e, t) => {
@@ -4906,12 +4920,12 @@ window.customCards.some((e) => e.type === _e) || window.customCards.push({
} : null;
}
});
-var qo = Object.defineProperty, Bo = Object.getOwnPropertyDescriptor, J = (e, t, i, o) => {
- for (var s = o > 1 ? void 0 : o ? Bo(t, i) : t, n = e.length - 1, a; n >= 0; n--)
+var Ko = Object.defineProperty, Wo = Object.getOwnPropertyDescriptor, J = (e, t, i, o) => {
+ for (var s = o > 1 ? void 0 : o ? Wo(t, i) : t, n = e.length - 1, a; n >= 0; n--)
(a = e[n]) && (s = (o ? a(t, i, s) : a(s)) || s);
- return o && s && qo(t, i, s), s;
+ return o && s && Ko(t, i, s), s;
};
-function Ko(e, t) {
+function jo(e, t) {
const i = [
{
name: "dashboard_behavior",
@@ -5239,7 +5253,7 @@ function Ko(e, t) {
}
];
}
-const Wo = {
+const Zo = {
recording_entity: "editor.recording_entity",
live_entity: "editor.live_entity",
snapshot_entity: "editor.snapshot_entity",
@@ -5273,7 +5287,7 @@ const Wo = {
preview_fallback: "editor.preview_fallback",
aspect_ratio: "editor.aspect_ratio",
fit_mode: "editor.fit_mode"
-}, jo = {
+}, Go = {
dashboard_behavior: "editor.helper_dashboard_behavior",
dashboard_start: "editor.helper_dashboard_start",
dashboard_live_muted: "editor.helper_dashboard_live_muted",
@@ -5298,7 +5312,7 @@ const Wo = {
let M = class extends x {
constructor() {
super(...arguments), this.nativeChecked = !1, this.nativeAvailable = !1, this.computeLabel = (e) => {
- const t = Wo[e.name];
+ const t = Zo[e.name];
if (!t) return;
const i = r(this.hass, t);
if (!["name", "door_contact_entity", "last_activity_entity"].includes(
@@ -5312,10 +5326,10 @@ let M = class extends x {
return `${i} (${o})`;
}, this.computeHelper = (e) => {
if (e.name === "snapshot_directory" && this.config) {
- const i = xi(this.config.snapshot_directory);
+ const i = Ei(this.config.snapshot_directory);
return i === "public" ? r(this.hass, "editor.helper_snapshot_directory_public") : i === "custom" ? r(this.hass, "editor.helper_snapshot_directory_custom") : void 0;
}
- const t = jo[e.name];
+ const t = Go[e.name];
return t ? r(this.hass, t) : void 0;
}, this.valueChanged = (e) => {
if (!this.config) return;
@@ -5353,8 +5367,8 @@ let M = class extends x {
});
}
render() {
- if (!this.hass || !this.config) return l;
- const e = $i(this.hass, this.config).filter(
+ if (!this.hass || !this.config) return c;
+ const e = Ci(this.hass, this.config).filter(
(t) => t.kind !== "compatibility" || this.nativeChecked && !this.nativeAvailable
);
return h`
@@ -5370,11 +5384,11 @@ let M = class extends x {
`
)}
- ` : l}
+ ` : c}
&
+ Partial>;
+
+export function entityProvider(
+ registry?: HassEntityRegistryEntry,
+): EntityProvider {
+ if (registry?.platform === "ring") return "official_ring";
+ if (registry?.platform === "mqtt") return "mqtt";
+ return "other";
+}
+
+export function resolveEntitySource(
+ hass: HomeAssistant,
+ role: EntitySourceRole,
+ entityId: string,
+): EntitySource {
+ const registry = hass.entities?.[entityId];
+ return {
+ role,
+ entityId,
+ entity: hass.states[entityId],
+ registry,
+ provider: entityProvider(registry),
+ deviceId: registry?.device_id ?? undefined,
+ };
+}
+
+export function resolveEntitySources(
+ hass: HomeAssistant,
+ config: NormalizedConfig,
+): ResolvedEntitySources {
+ const sources: ResolvedEntitySources = {
+ recording: resolveEntitySource(
+ hass,
+ "recording",
+ config.recording_entity,
+ ),
+ live: resolveEntitySource(hass, "live", config.live_entity),
+ };
+ if (config.snapshot_entity) {
+ sources.snapshot = resolveEntitySource(
+ hass,
+ "snapshot",
+ config.snapshot_entity,
+ );
+ }
+ if (config.doorbell_entity) {
+ sources.doorbell = resolveEntitySource(
+ hass,
+ "doorbell",
+ config.doorbell_entity,
+ );
+ }
+ if (config.last_activity_entity) {
+ sources.activity = resolveEntitySource(
+ hass,
+ "activity",
+ config.last_activity_entity,
+ );
+ }
+ return sources;
+}
+
+export function sameDeviceEntityIds(
+ hass: HomeAssistant,
+ anchorEntityId: string,
+): string[] {
+ const deviceId = hass.entities?.[anchorEntityId]?.device_id;
+ if (!deviceId) return [];
+ return Object.values(hass.entities ?? {})
+ .filter((entry) =>
+ entry.device_id === deviceId
+ && entry.disabled_by == null
+ && Boolean(hass.states[entry.entity_id]))
+ .map((entry) => entry.entity_id)
+ .sort();
+}
+
+export function findSameDeviceEntityId(
+ hass: HomeAssistant,
+ anchorEntityId: string,
+ predicate: (
+ entityId: string,
+ entity: HassEntity,
+ registry: HassEntityRegistryEntry,
+ ) => boolean,
+): string | undefined {
+ const matches = sameDeviceEntityIds(hass, anchorEntityId).filter((entityId) => {
+ const entity = hass.states[entityId];
+ const registry = hass.entities?.[entityId];
+ return Boolean(entity && registry && predicate(entityId, entity, registry));
+ });
+ return matches.length === 1 ? matches[0] : undefined;
+}
diff --git a/src/utilities/entity-validation.ts b/src/utilities/entity-validation.ts
index aa263af..cd86775 100644
--- a/src/utilities/entity-validation.ts
+++ b/src/utilities/entity-validation.ts
@@ -1,6 +1,7 @@
import { localize } from "../localize";
import type { HassEntity, HomeAssistant, NormalizedConfig } from "../types";
import { activityTimestamp } from "./activity-time";
+import { resolveEntitySource } from "./entity-sources";
export const CAMERA_STREAM_FEATURE = 2;
export const LOCK_OPEN_FEATURE = 1;
@@ -19,7 +20,7 @@ export function supportsRingTalkback(
entityId: string,
): boolean {
return (
- hass.entities?.[entityId]?.platform === "ring"
+ resolveEntitySource(hass, "live", entityId).provider === "official_ring"
&& supportsStream(hass.states[entityId])
);
}
diff --git a/tests/unit/editor.test.ts b/tests/unit/editor.test.ts
index 1eea29c..6570685 100644
--- a/tests/unit/editor.test.ts
+++ b/tests/unit/editor.test.ts
@@ -65,6 +65,37 @@ const hass: HomeAssistant = {
};
describe("visual editor", () => {
+ it("keeps the same schema when configured entity platforms change", 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({
+ recording_entity: "camera.recording",
+ live_entity: "camera.live",
+ snapshot_entity: "camera.snapshot",
+ doorbell_entity: "binary_sensor.front_door_contact",
+ last_activity_entity: "sensor.front_door_last_activity",
+ show_snapshot_button: true,
+ });
+ document.body.append(editor);
+ await editor.updateComplete;
+ const form = editor.shadowRoot?.querySelector("ha-form") as
+ | (HTMLElement & { schema?: ConfigFormSchema[] })
+ | null;
+ schemas.push(form?.schema ?? []);
+ editor.remove();
+ }
+
+ expect(schemas[1]).toEqual(schemas[0]);
+ });
+
it("emits the compact flat configuration and removes obsolete options", async () => {
const editor = document.createElement("ring-view-editor");
editor.hass = hass;
diff --git a/tests/unit/entity-sources.test.ts b/tests/unit/entity-sources.test.ts
new file mode 100644
index 0000000..dfadaae
--- /dev/null
+++ b/tests/unit/entity-sources.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from "vitest";
+import { normalizeConfig } from "../../src/config";
+import type { HassEntity, HomeAssistant } from "../../src/types";
+import {
+ findSameDeviceEntityId,
+ resolveEntitySources,
+ sameDeviceEntityIds,
+} from "../../src/utilities/entity-sources";
+
+const state = (entityId: string): HassEntity => ({
+ entity_id: entityId,
+ state: "idle",
+ attributes: {},
+});
+
+function mixedHass(): HomeAssistant {
+ const states = Object.fromEntries([
+ "camera.recording",
+ "camera.live",
+ "camera.renamed_snapshot",
+ "binary_sensor.renamed_ding",
+ "button.renamed_refresh",
+ "button.ambiguous_refresh",
+ ].map((entityId) => [entityId, state(entityId)]));
+ return {
+ states,
+ entities: {
+ "camera.recording": {
+ entity_id: "camera.recording",
+ platform: "ring",
+ device_id: "ring-device",
+ },
+ "camera.live": {
+ entity_id: "camera.live",
+ platform: "ring",
+ device_id: "ring-device",
+ },
+ "camera.renamed_snapshot": {
+ entity_id: "camera.renamed_snapshot",
+ platform: "mqtt",
+ device_id: "mqtt-device",
+ },
+ "binary_sensor.renamed_ding": {
+ entity_id: "binary_sensor.renamed_ding",
+ platform: "mqtt",
+ device_id: "mqtt-device",
+ },
+ "button.renamed_refresh": {
+ entity_id: "button.renamed_refresh",
+ platform: "mqtt",
+ device_id: "mqtt-device",
+ },
+ "button.ambiguous_refresh": {
+ entity_id: "button.ambiguous_refresh",
+ platform: "mqtt",
+ device_id: "mqtt-device",
+ },
+ "sensor.disabled": {
+ entity_id: "sensor.disabled",
+ platform: "mqtt",
+ device_id: "mqtt-device",
+ disabled_by: "user",
+ },
+ },
+ hassUrl: (path = "") => path,
+ callWS: async () => ({}) as never,
+ };
+}
+
+describe("entity feature sources", () => {
+ it("resolves every configured feature independently in a mixed setup", () => {
+ const hass = mixedHass();
+ const sources = resolveEntitySources(hass, normalizeConfig({
+ recording_entity: "camera.recording",
+ live_entity: "camera.live",
+ snapshot_entity: "camera.renamed_snapshot",
+ doorbell_entity: "binary_sensor.renamed_ding",
+ }));
+
+ expect(sources.recording.provider).toBe("official_ring");
+ expect(sources.live.provider).toBe("official_ring");
+ expect(sources.snapshot?.provider).toBe("mqtt");
+ expect(sources.doorbell?.provider).toBe("mqtt");
+ expect(sources.activity).toBeUndefined();
+ });
+
+ it("finds renamed companions by device identity rather than name", () => {
+ const hass = mixedHass();
+ expect(sameDeviceEntityIds(hass, "camera.renamed_snapshot")).toEqual([
+ "binary_sensor.renamed_ding",
+ "button.ambiguous_refresh",
+ "button.renamed_refresh",
+ "camera.renamed_snapshot",
+ ]);
+ expect(findSameDeviceEntityId(
+ hass,
+ "camera.renamed_snapshot",
+ (entityId) => entityId === "button.renamed_refresh",
+ )).toBe("button.renamed_refresh");
+ });
+
+ it("refuses an ambiguous companion and degrades without registry data", () => {
+ const hass = mixedHass();
+ expect(findSameDeviceEntityId(
+ hass,
+ "camera.renamed_snapshot",
+ (entityId) => entityId.startsWith("button."),
+ )).toBeUndefined();
+ expect(sameDeviceEntityIds(
+ { ...hass, entities: undefined },
+ "camera.renamed_snapshot",
+ )).toEqual([]);
+ });
+});