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
504 changes: 259 additions & 245 deletions dist/ring-view.js

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export interface HassEntity {
export interface HassEntityRegistryEntry {
entity_id: string;
platform?: string;
device_id?: string | null;
disabled_by?: string | null;
}

export interface HomeAssistant {
Expand Down
118 changes: 118 additions & 0 deletions src/utilities/entity-sources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type {
HassEntity,
HassEntityRegistryEntry,
HomeAssistant,
NormalizedConfig,
} from "../types";

export type EntityProvider = "official_ring" | "mqtt" | "other";
export type EntitySourceRole =
| "recording"
| "live"
| "snapshot"
| "doorbell"
| "activity";

export interface EntitySource {
role: EntitySourceRole;
entityId: string;
entity?: HassEntity;
registry?: HassEntityRegistryEntry;
provider: EntityProvider;
deviceId?: string;
}

export type ResolvedEntitySources = Record<"recording" | "live", EntitySource> &
Partial<Record<"snapshot" | "doorbell" | "activity", EntitySource>>;

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;
}
3 changes: 2 additions & 1 deletion src/utilities/entity-validation.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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])
);
}
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
114 changes: 114 additions & 0 deletions tests/unit/entity-sources.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading