Repository: dermotduffy/frigate-hass-card
Pull request #1597 (2024-09-29 22:52:24)

refactor: Refactor entity register manager


Patch:
diff --git a/src/camera-manager/browse-media/camera.ts b/src/camera-manager/browse-media/camera.ts
index 031ef5c2..eace0a73 100644
--- a/src/camera-manager/browse-media/camera.ts
+++ b/src/camera-manager/browse-media/camera.ts
@@ -1,7 +1,7 @@
 import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
 import { localize } from '../../localize/localize';
-import { EntityRegistryManager } from '../../utils/ha/entity-registry';
-import { Entity } from '../../utils/ha/entity-registry/types';
+import { EntityRegistryManager } from '../../utils/ha/registry/entity';
+import { Entity } from '../../utils/ha/registry/entity/types';
 import { Camera, CameraInitializationOptions } from '../camera';
 import { CameraInitializationError } from '../error';
 
diff --git a/src/camera-manager/browse-media/engine-browse-media.ts b/src/camera-manager/browse-media/engine-browse-media.ts
index 997cf9d3..da1a7fe4 100644
--- a/src/camera-manager/browse-media/engine-browse-media.ts
+++ b/src/camera-manager/browse-media/engine-browse-media.ts
@@ -10,7 +10,7 @@ import {
   MEDIA_CLASS_VIDEO,
   RichBrowseMedia,
 } from '../../utils/ha/browse-media/types';
-import { EntityRegistryManager } from '../../utils/ha/entity-registry';
+import { EntityRegistryManager } from '../../utils/ha/registry/entity';
 import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media';
 import { ViewMedia } from '../../view/media';
 import { RequestCache } from '../cache';
diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts
index 00ac031b..47438c75 100644
--- a/src/camera-manager/engine-factory.ts
+++ b/src/camera-manager/engine-factory.ts
@@ -4,8 +4,7 @@ import { CameraConfig } from '../config/types';
 import { localize } from '../localize/localize';
 import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager';
 import { BrowseMedia } from '../utils/ha/browse-media/types';
-import { EntityRegistryManager } from '../utils/ha/entity-registry';
-import { Entity } from '../utils/ha/entity-registry/types';
+import { EntityRegistryManager } from '../utils/ha/registry/entity';
 import { ResolvedMediaCache } from '../utils/ha/resolved-media';
 import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cache';
 import { CameraManagerEngine } from './engine';
@@ -81,10 +80,8 @@ export class CameraManagerEngineFactory {
       const cameraEntity = getCameraEntityFromConfig(cameraConfig);
 
       if (cameraEntity) {
-        let entity: Entity | null;
-        try {
-          entity = await this._entityRegistryManager.getEntity(hass, cameraEntity);
-        } catch (e) {
+        const entity = await this._entityRegistryManager.getEntity(hass, cameraEntity);
+        if (!entity) {
           // If the camera is not in the registry, but is in the HA states it is
           // assumed to be a generic camera.
           if (hass.states[cameraEntity]) {
diff --git a/src/camera-manager/frigate/camera.ts b/src/camera-manager/frigate/camera.ts
index 34012192..28707947 100644
--- a/src/camera-manager/frigate/camera.ts
+++ b/src/camera-manager/frigate/camera.ts
@@ -5,8 +5,8 @@ import { CameraConfig } from '../../config/types';
 import { localize } from '../../localize/localize';
 import { PTZCapabilities, PTZMovementType } from '../../types';
 import { errorToConsole } from '../../utils/basic';
-import { EntityRegistryManager } from '../../utils/ha/entity-registry';
-import { Entity } from '../../utils/ha/entity-registry/types';
+import { EntityRegistryManager } from '../../utils/ha/registry/entity';
+import { Entity } from '../../utils/ha/registry/entity/types';
 import { Camera, CameraInitializationOptions } from '../camera';
 import { Capabilities } from '../capabilities';
 import { CameraManagerEngine } from '../engine';
@@ -67,9 +67,8 @@ export class FrigateCamera extends Camera {
     // Entity information is required if the Frigate camera name is missing, or
     // if the entity requires automatic resolution of motion/occupancy sensors.
     if (cameraEntity && (!hasCameraName || hasAutoTriggers)) {
-      try {
-        entity = await entityRegistryManager.getEntity(hass, cameraEntity);
-      } catch (e) {
+      entity = await entityRegistryManager.getEntity(hass, cameraEntity);
+      if (!entity) {
         throw new CameraInitializationError(localize('error.no_camera_entity'), config);
       }
     }
diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts
index d8d871e5..802874ef 100644
--- a/src/camera-manager/frigate/engine-frigate.ts
+++ b/src/camera-manager/frigate/engine-frigate.ts
@@ -15,7 +15,7 @@ import {
   runWhenIdleIfSupported,
 } from '../../utils/basic';
 import { getEntityTitle } from '../../utils/ha';
-import { EntityRegistryManager } from '../../utils/ha/entity-registry';
+import { EntityRegistryManager } from '../../utils/ha/registry/entity';
 import { ViewMedia } from '../../view/media';
 import { ViewMediaClassifier } from '../../view/media-classifier';
 import { RecordingSegmentsCache, RequestCache } from '../cache';
diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts
index 859f42ab..02ce4624 100644
--- a/src/card-controller/controller.ts
+++ b/src/card-controller/controller.ts
@@ -2,11 +2,12 @@ import { LovelaceCardEditor } from '@dermotduffy/custom-card-helpers';
 import { ReactiveController } from 'lit';
 import { CameraManager } from '../camera-manager/manager';
 import { FrigateCardConfig } from '../config/types';
-import { EntityRegistryManager } from '../utils/ha/entity-registry';
-import { EntityCache } from '../utils/ha/entity-registry/cache';
+import {
+  createEntityRegistryCache,
+  EntityRegistryManager,
+} from '../utils/ha/registry/entity';
 import { ResolvedMediaCache } from '../utils/ha/resolved-media';
 import { ActionsManager } from './actions/actions-manager';
-import { DefaultManager } from './default-manager';
 import { AutomationsManager } from './automations-manager';
 import { CameraURLManager } from './camera-url-manager';
 import {
@@ -17,12 +18,14 @@ import {
 } from './card-element-manager';
 import { ConditionsManager, ConditionsManagerListener } from './conditions-manager';
 import { ConfigManager } from './config/config-manager';
+import { DefaultManager } from './default-manager';
 import { DownloadManager } from './download-manager';
 import { ExpandManager } from './expand-manager';
 import { FullscreenManager } from './fullscreen-manager';
 import { HASSManager } from './hass/hass-manager';
 import { InitializationManager } from './initialization-manager';
 import { InteractionManager } from './interaction-manager';
+import { KeyboardStateManager } from './keyboard-state-manager';
 import { MediaLoadedInfoManager } from './media-info-manager';
 import { MediaPlayerManager } from './media-player-manager';
 import { MessageManager } from './message-manager';
@@ -57,7 +60,6 @@ import {
   CardViewAPI,
 } from './types';
 import { ViewManager } from './view/view-manager';
-import { KeyboardStateManager } from './keyboard-state-manager';
 
 export class CardController
   implements
@@ -88,7 +90,9 @@ export class CardController
 {
   // These properties may be used in the construction of 'managers' (and should
   // be created first).
-  protected _entityRegistryManager = new EntityRegistryManager(new EntityCache());
+  protected _entityRegistryManager = new EntityRegistryManager(
+    createEntityRegistryCache(),
+  );
   protected _resolvedMediaCache = new ResolvedMediaCache();
 
   protected _actionsManager = new ActionsManager(this);
diff --git a/src/card-controller/media-player-manager.ts b/src/card-controller/media-player-manager.ts
index ff26d201..76095eab 100644
--- a/src/card-controller/media-player-manager.ts
+++ b/src/card-controller/media-player-manager.ts
@@ -2,7 +2,7 @@ import { CameraConfig, FrigateCardConfig } from '../config/types';
 import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA } from '../const';
 import { localize } from '../localize/localize';
 import { errorToConsole } from '../utils/basic';
-import { Entity } from '../utils/ha/entity-registry/types';
+import { Entity } from '../utils/ha/registry/entity/types';
 import { supportsFeature } from '../utils/ha/update';
 import { ViewMedia } from '../view/media';
 import { ViewMediaClassifier } from '../view/media-classifier';
diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts
index 41884bb5..f55dae66 100644
--- a/src/card-controller/types.ts
+++ b/src/card-controller/types.ts
@@ -1,6 +1,6 @@
 import type { CameraManager } from '../camera-manager/manager';
 import type { Automation } from '../config/types';
-import type { EntityRegistryManager } from '../utils/ha/entity-registry';
+import type { EntityRegistryManager } from '../utils/ha/registry/entity';
 import type { ResolvedMediaCache } from '../utils/ha/resolved-media';
 import type { ActionsManager } from './actions/actions-manager';
 import type { AutomationsManager } from './automations-manager';
diff --git a/src/components/menu.ts b/src/components/menu.ts
index 1a37fb73..4ced31bb 100644
--- a/src/components/menu.ts
+++ b/src/components/menu.ts
@@ -8,7 +8,7 @@ import { MenuController } from '../components-lib/menu-controller.js';
 import type { MenuConfig, MenuItem } from '../config/types.js';
 import menuStyle from '../scss/menu.scss';
 import { frigateCardHasAction } from '../utils/action.js';
-import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js';
+import { EntityRegistryManager } from '../utils/ha/registry/entity/index.js';
 import './submenu.js';
 
 @customElement('frigate-card-menu')
diff --git a/src/components/submenu.ts b/src/components/submenu.ts
index 4bc361f0..2e675920 100644
--- a/src/components/submenu.ts
+++ b/src/components/submenu.ts
@@ -19,8 +19,8 @@ import {
   stopEventFromActivatingCardWideActions,
 } from '../utils/action.js';
 import { isHassDifferent, refreshDynamicStateParameters } from '../utils/ha';
-import { EntityRegistryManager } from '../utils/ha/entity-registry/index.js';
 import { getEntityStateTranslation } from '../utils/ha/entity-state-translation.js';
+import { EntityRegistryManager } from '../utils/ha/registry/entity/index.js';
 import { domainIcon } from '../utils/icons/domain-icon.js';
 
 @customElement('frigate-card-submenu')
diff --git a/src/utils/diagnostics.ts b/src/utils/diagnostics.ts
index a36f92c5..05706f4a 100644
--- a/src/utils/diagnostics.ts
+++ b/src/utils/diagnostics.ts
@@ -2,7 +2,8 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
 import pkg from '../../package.json';
 import { RawFrigateCardConfig } from '../config/types';
 import { getLanguage } from '../localize/localize';
-import { DeviceList, getAllDevices } from './ha/device-registry';
+import { getAllDevices } from './ha/registry/device';
+import { DeviceList } from './ha/registry/device/types';
 
 type FrigateVersions = Record<string, string>;
 
diff --git a/src/utils/ha/entity-state-translation.ts b/src/utils/ha/entity-state-translation.ts
index f09a4fac..1a44ae20 100644
--- a/src/utils/ha/entity-state-translation.ts
+++ b/src/utils/ha/entity-state-translation.ts
@@ -1,6 +1,6 @@
 import { computeDomain, HomeAssistant } from '@dermotduffy/custom-card-helpers';
 import { HassEntity } from 'home-assistant-js-websocket';
-import { Entity } from './entity-registry/types';
+import { Entity } from './registry/entity/types';
 
 /**
  * Get the translation of an entity state. Inspired by:
diff --git a/src/utils/ha/entity-registry/cache.ts b/src/utils/ha/registry/cache.ts
similarity index 59%
rename from src/utils/ha/entity-registry/cache.ts
rename to src/utils/ha/registry/cache.ts
index 36056805..9b955ba4 100644
--- a/src/utils/ha/entity-registry/cache.ts
+++ b/src/utils/ha/registry/cache.ts
@@ -1,7 +1,10 @@
-import { Entity } from './types.js';
+export class RegistryCache<T> {
+  protected _cache: Map<string, T> = new Map();
+  protected _keyCallback: (_data: T) => string;
 
-export class EntityCache {
-  protected _cache: Map<string, Entity> = new Map();
+  constructor(keyCallback: (_data: T) => string) {
+    this._keyCallback = keyCallback;
+  }
 
   /**
    * Determine if the cache has a given entity_id.
@@ -17,11 +20,7 @@ export class EntityCache {
    * @param func A callback function that returns a boolean.
    * @returns The first matching value.
    */
-  // public getFirstMatch(func: (arg: T) => boolean): T | null {
-  //   return [...this._cache.values()].find(func) ?? null;
-  // }
-
-  public getMatches(func: (arg: Entity) => boolean): Entity[] {
+  public getMatches(func: (arg: T) => boolean): T[] {
     return [...this._cache.values()].filter(func);
   }
 
@@ -30,16 +29,16 @@ export class EntityCache {
    * @param id The entity id.
    * @returns The entity for this id.
    */
-  public get(id: string): Entity | undefined {
-    return this._cache.get(id);
+  public get(id: string): T | null {
+    return this._cache.get(id) ?? null;
   }
 
   /**
    * Add a given entity to the cache.
    * @param input The entity.
    */
-  public set(input: Entity | Entity[]): void {
-    const _set = (entity: Entity) => this._cache.set(entity.entity_id, entity);
+  public add(input: T | T[]): void {
+    const _set = (arg: T) => this._cache.set(this._keyCallback(arg), arg);
 
     if (Array.isArray(input)) {
       input.forEach(_set);
diff --git a/src/utils/ha/device-registry.ts b/src/utils/ha/registry/device/index.ts
similarity index 57%
rename from src/utils/ha/device-registry.ts
rename to src/utils/ha/registry/device/index.ts
index 0bc1b187..54d39f94 100644
--- a/src/utils/ha/device-registry.ts
+++ b/src/utils/ha/registry/device/index.ts
@@ -1,14 +1,6 @@
 import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
-import { z } from 'zod';
-import { homeAssistantWSRequest } from '.';
-
-const deviceSchema = z.object({
-  model: z.string().nullable(),
-  config_entries: z.string().array(),
-  manufacturer: z.string().nullable(),
-});
-const deviceListSchema = deviceSchema.array();
-export type DeviceList = z.infer<typeof deviceListSchema>;
+import { homeAssistantWSRequest } from '../..';
+import { DeviceList, deviceListSchema } from './types';
 
 /**
  * Get a list of all entities from the entity registry. May throw.
diff --git a/src/utils/ha/registry/device/types.ts b/src/utils/ha/registry/device/types.ts
new file mode 100644
index 00000000..a70e6f2e
--- /dev/null
+++ b/src/utils/ha/registry/device/types.ts
@@ -0,0 +1,12 @@
+import { z } from 'zod';
+
+export const deviceSchema = z.object({
+  id: z.string(),
+  model: z.string().nullable(),
+  config_entries: z.string().array(),
+  manufacturer: z.string().nullable(),
+});
+export type Device = z.infer<typeof deviceSchema>;
+
+export const deviceListSchema = deviceSchema.array();
+export type DeviceList = z.infer<typeof deviceListSchema>;
diff --git a/src/utils/ha/entity-registry/index.ts b/src/utils/ha/registry/entity/index.ts
similarity index 68%
rename from src/utils/ha/entity-registry/index.ts
rename to src/utils/ha/registry/entity/index.ts
index 69057849..191cd243 100644
--- a/src/utils/ha/entity-registry/index.ts
+++ b/src/utils/ha/registry/entity/index.ts
@@ -1,17 +1,21 @@
 import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
-import { homeAssistantWSRequest } from '..';
-import { EntityCache } from './cache';
+import { homeAssistantWSRequest } from '../..';
 import { Entity, EntityList, entitySchema, entityListSchema } from './types.js';
+import { RegistryCache } from '../cache';
+
+export const createEntityRegistryCache = (): RegistryCache<Entity> => {
+  return new RegistryCache<Entity>((entity) => entity.entity_id);
+};
 
 // This class manages interactions with entities, caching results and fetching
 // as necessary. Some calls require every entity to be fetched, which may be
-// non-trivial in size (after which it is cached forever).
+// non-trivial in size (after which they are cached forever).
 
 export class EntityRegistryManager {
-  protected _cache: EntityCache;
+  protected _cache: RegistryCache<Entity>;
   protected _fetchedEntityList = false;
 
-  constructor(cache: EntityCache) {
+  constructor(cache: RegistryCache<Entity>) {
     this._cache = cache;
   }
 
@@ -21,11 +25,16 @@ export class EntityRegistryManager {
       return cachedEntity;
     }
 
-    const entity = await homeAssistantWSRequest<Entity>(hass, entitySchema, {
-      type: 'config/entity_registry/get',
-      entity_id: entityID,
-    });
-    this._cache.set(entity);
+    let entity: Entity | null = null;
+    try {
+      entity = await homeAssistantWSRequest<Entity>(hass, entitySchema, {
+        type: 'config/entity_registry/get',
+        entity_id: entityID,
+      });
+    } catch {
+      return null;
+    }
+    this._cache.add(entity);
     return entity;
   }
 
@@ -43,15 +52,11 @@ export class EntityRegistryManager {
   ): Promise<Map<string, Entity>> {
     const output: Map<string, Entity> = new Map();
     const _storeEntity = async (entityID: string): Promise<void> => {
-      let entity: Entity | null = null;
-      try {
-        entity = await this.getEntity(hass, entityID);
-      } catch {
+      const entity = await this.getEntity(hass, entityID);
+
+      if (entity) {
         // When asked to fetch multiple entities, ignore missing entities (they
         // will just not feature in the output).
-        return;
-      }
-      if (entity) {
         output.set(entityID, entity);
       }
     };
@@ -66,7 +71,7 @@ export class EntityRegistryManager {
     const entityList = await homeAssistantWSRequest<EntityList>(hass, entityListSchema, {
       type: 'config/entity_registry/list',
     });
-    this._cache.set(entityList);
+    this._cache.add(entityList);
     this._fetchedEntityList = true;
   }
 }
diff --git a/vite.config.ts b/vite.config.ts
index f5ddd5f1..e85dffcd 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -25,7 +25,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [
   'utils/download.ts',
   'utils/embla/**/*.ts',
   'utils/endpoint.ts',
-  'utils/ha/entity-registry/types.ts',
+  'utils/ha/registry/entity/**/*.ts',
   'utils/ha/types.ts',
   'utils/initializer.ts',
   'utils/interaction-mode.ts',


Test patch:
diff --git a/src/utils/ha/entity-registry/types.ts b/src/utils/ha/registry/entity/types.ts
similarity index 100%
rename from src/utils/ha/entity-registry/types.ts
rename to src/utils/ha/registry/entity/types.ts
diff --git a/tests/camera-manager/browse-media/camera.test.ts b/tests/camera-manager/browse-media/camera.test.ts
index eaf96699..d5e76269 100644
--- a/tests/camera-manager/browse-media/camera.test.ts
+++ b/tests/camera-manager/browse-media/camera.test.ts
@@ -2,10 +2,10 @@ import { describe, expect, it } from 'vitest';
 import { mock } from 'vitest-mock-extended';
 import { BrowseMediaCamera } from '../../../src/camera-manager/browse-media/camera';
 import { CameraManagerEngine } from '../../../src/camera-manager/engine';
-import { EntityRegistryManager } from '../../../src/utils/ha/entity-registry';
-import { Entity } from '../../../src/utils/ha/entity-registry/types';
-import { createCameraConfig, createHASS } from '../../test-utils';
 import { StateWatcherSubscriptionInterface } from '../../../src/card-controller/hass/state-watcher';
+import { EntityRegistryManager } from '../../../src/utils/ha/registry/entity';
+import { Entity } from '../../../src/utils/ha/registry/entity/types';
+import { createCameraConfig, createHASS } from '../../test-utils';
 
 describe('BrowseMediaCamera', () => {
   describe('should initialize', () => {
diff --git a/tests/camera-manager/engine-factory.test.ts b/tests/camera-manager/engine-factory.test.ts
index fe0e9ac1..7a38e62c 100644
--- a/tests/camera-manager/engine-factory.test.ts
+++ b/tests/camera-manager/engine-factory.test.ts
@@ -7,8 +7,10 @@ import { MotionEyeCameraManagerEngine } from '../../src/camera-manager/motioneye
 import { Engine } from '../../src/camera-manager/types.js';
 import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
 import { CardWideConfig } from '../../src/config/types.js';
-import { EntityRegistryManager } from '../../src/utils/ha/entity-registry';
-import { EntityCache } from '../../src/utils/ha/entity-registry/cache';
+import {
+  createEntityRegistryCache,
+  EntityRegistryManager,
+} from '../../src/utils/ha/registry/entity/index.js';
 import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
 import {
   createCameraConfig,
@@ -25,7 +27,8 @@ const createFactory = (options?: {
   cardWideConfig?: CardWideConfig;
 }): CameraManagerEngineFactory => {
   return new CameraManagerEngineFactory(
-    options?.entityRegistryManager ?? new EntityRegistryManager(new EntityCache()),
+    options?.entityRegistryManager ??
+      new EntityRegistryManager(createEntityRegistryCache()),
   );
 };
 
@@ -40,7 +43,9 @@ describe('getEngineForCamera()', () => {
 
     it('from auto detection', async () => {
       const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
-      const entityRegistryManager = new EntityRegistryManager(new EntityCache());
+      const entityRegistryManager = new EntityRegistryManager(
+        createEntityRegistryCache(),
+      );
 
       entityRegistryManager.getEntity = vi
         .fn()
@@ -75,7 +80,9 @@ describe('getEngineForCamera()', () => {
 
     it('from auto detection', async () => {
       const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
-      const entityRegistryManager = new EntityRegistryManager(new EntityCache());
+      const entityRegistryManager = new EntityRegistryManager(
+        createEntityRegistryCache(),
+      );
 
       entityRegistryManager.getEntity = vi
         .fn()
@@ -101,7 +108,9 @@ describe('getEngineForCamera()', () => {
 
     it('from auto detection', async () => {
       const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
-      const entityRegistryManager = new EntityRegistryManager(new EntityCache());
+      const entityRegistryManager = new EntityRegistryManager(
+        createEntityRegistryCache(),
+      );
 
       entityRegistryManager.getEntity = vi
         .fn()
@@ -121,9 +130,11 @@ describe('getEngineForCamera()', () => {
         engine: 'auto',
         webrtc_card: { entity: 'camera.foo' },
       });
-      const entityRegistryManager = new EntityRegistryManager(new EntityCache());
+      const entityRegistryManager = new EntityRegistryManager(
+        createEntityRegistryCache(),
+      );
 
-      entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error());
+      entityRegistryManager.getEntity = vi.fn().mockResolvedValue(null);
 
       expect(
         await createFactory({
@@ -137,6 +148,24 @@ describe('getEngineForCamera()', () => {
       ).toBe(Engine.Generic);
     });
 
+    it('from entity not in registry and not in state', async () => {
+      const config = createCameraConfig({
+        engine: 'auto',
+        webrtc_card: { entity: 'camera.foo' },
+      });
+      const entityRegistryManager = new EntityRegistryManager(
+        createEntityRegistryCache(),
+      );
+      entityRegistryManager.getEntity = vi.fn().mockResolvedValue(null);
+
+      expect(
+        async () =>
+          await createFactory({
+            entityRegistryManager: entityRegistryManager,
+          }).getEngineForCamera(createHASS(), config),
+      ).rejects.toThrow(/Could not find camera entity/);
+    });
+
     it('from webrtc-card url', async () => {
       const config = createCameraConfig({
         engine: 'auto',
@@ -167,7 +196,7 @@ describe('getEngineForCamera()', () => {
 
   it('should throw error on invalid entity', async () => {
     const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
-    const entityRegistryManager = new EntityRegistryManager(new EntityCache());
+    const entityRegistryManager = new EntityRegistryManager(createEntityRegistryCache());
 
     entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error());
 
diff --git a/tests/camera-manager/frigate/camera.test.ts b/tests/camera-manager/frigate/camera.test.ts
index 470d0b65..81aadf01 100644
--- a/tests/camera-manager/frigate/camera.test.ts
+++ b/tests/camera-manager/frigate/camera.test.ts
@@ -7,8 +7,8 @@ import { getPTZInfo } from '../../../src/camera-manager/frigate/requests';
 import { FrigateEventChange } from '../../../src/camera-manager/frigate/types';
 import { StateWatcher } from '../../../src/card-controller/hass/state-watcher';
 import { CameraTriggerEventType } from '../../../src/config/types';
-import { EntityRegistryManager } from '../../../src/utils/ha/entity-registry';
-import { Entity } from '../../../src/utils/ha/entity-registry/types';
+import { EntityRegistryManager } from '../../../src/utils/ha/registry/entity';
+import { Entity } from '../../../src/utils/ha/registry/entity/types';
 import { createCameraConfig, createHASS, createRegistryEntity } from '../../test-utils';
 
 vi.mock('../../../src/camera-manager/frigate/requests');
@@ -54,7 +54,7 @@ describe('FrigateCamera', () => {
           mock<CameraManagerEngine>(),
         );
         const entityRegistryManager = mock<EntityRegistryManager>();
-        entityRegistryManager.getEntity.mockRejectedValue(null);
+        entityRegistryManager.getEntity.mockResolvedValue(null);
 
         expect(
           async () =>
diff --git a/tests/camera-manager/store.test.ts b/tests/camera-manager/store.test.ts
index f0184b83..0cfdf350 100644
--- a/tests/camera-manager/store.test.ts
+++ b/tests/camera-manager/store.test.ts
@@ -5,10 +5,10 @@ import { Capabilities } from '../../src/camera-manager/capabilities.js';
 import { CameraManagerEngineFactory } from '../../src/camera-manager/engine-factory.js';
 import { CameraManagerStore } from '../../src/camera-manager/store.js';
 import { Engine } from '../../src/camera-manager/types.js';
-import { EntityRegistryManager } from '../../src/utils/ha/entity-registry/index.js';
+import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
+import { EntityRegistryManager } from '../../src/utils/ha/registry/entity/index.js';
 import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media.js';
 import { TestViewMedia, createCameraConfig } from '../test-utils.js';
-import { StateWatcherSubscriptionInterface } from '../../src/card-controller/hass/state-watcher.js';
 
 describe('CameraManagerStore', async () => {
   const configVisible = createCameraConfig({
diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts
index a211175e..d5268e4f 100644
--- a/tests/card-controller/controller.test.ts
+++ b/tests/card-controller/controller.test.ts
@@ -28,7 +28,7 @@ import { StyleManager } from '../../src/card-controller/style-manager';
 import { TriggersManager } from '../../src/card-controller/triggers-manager';
 import { ViewManager } from '../../src/card-controller/view/view-manager';
 import { FrigateCardEditor } from '../../src/editor';
-import { EntityRegistryManager } from '../../src/utils/ha/entity-registry';
+import { EntityRegistryManager } from '../../src/utils/ha/registry/entity';
 import { ResolvedMediaCache } from '../../src/utils/ha/resolved-media';
 
 vi.mock('../../src/camera-manager/manager');
@@ -55,7 +55,7 @@ vi.mock('../../src/card-controller/status-bar-item-manager');
 vi.mock('../../src/card-controller/style-manager');
 vi.mock('../../src/card-controller/triggers-manager');
 vi.mock('../../src/card-controller/view/view-manager');
-vi.mock('../../src/utils/ha/entity-registry');
+vi.mock('../../src/utils/ha/registry/entity');
 vi.mock('../../src/utils/ha/resolved-media');
 
 const createCardElement = (): CardHTMLElement => {
diff --git a/tests/card-controller/media-player-manager.test.ts b/tests/card-controller/media-player-manager.test.ts
index 85776868..633f0539 100644
--- a/tests/card-controller/media-player-manager.test.ts
+++ b/tests/card-controller/media-player-manager.test.ts
@@ -3,7 +3,7 @@ import { mock } from 'vitest-mock-extended';
 import { MediaPlayerManager } from '../../src/card-controller/media-player-manager';
 import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA } from '../../src/const';
 import { ExtendedHomeAssistant } from '../../src/types';
-import { EntityRegistryManager } from '../../src/utils/ha/entity-registry';
+import { EntityRegistryManager } from '../../src/utils/ha/registry/entity';
 import {
   createCameraConfig,
   createCameraManager,
diff --git a/tests/test-utils.ts b/tests/test-utils.ts
index d35bbfcb..b66acbcb 100644
--- a/tests/test-utils.ts
+++ b/tests/test-utils.ts
@@ -55,8 +55,8 @@ import {
 } from '../src/config/types';
 import { CapabilitiesRaw, ExtendedHomeAssistant, MediaLoadedInfo } from '../src/types';
 import { HassStateDifference } from '../src/utils/ha';
-import { EntityRegistryManager } from '../src/utils/ha/entity-registry';
-import { Entity } from '../src/utils/ha/entity-registry/types';
+import { EntityRegistryManager } from '../src/utils/ha/registry/entity';
+import { Entity } from '../src/utils/ha/registry/entity/types';
 import { ViewMedia, ViewMediaType } from '../src/view/media';
 import { MediaQueriesResults } from '../src/view/media-queries-results';
 import { View, ViewParameters } from '../src/view/view';
diff --git a/tests/utils/diagnostics.test.ts b/tests/utils/diagnostics.test.ts
index a020e264..5befe19c 100644
--- a/tests/utils/diagnostics.test.ts
+++ b/tests/utils/diagnostics.test.ts
@@ -2,7 +2,7 @@ import { HassConfig } from 'home-assistant-js-websocket';
 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
 import { getLanguage } from '../../src/localize/localize';
 import { getDiagnostics, getReleaseVersion } from '../../src/utils/diagnostics.js';
-import { getAllDevices } from '../../src/utils/ha/device-registry.js';
+import { getAllDevices } from '../../src/utils/ha/registry/device/index.js';
 import { createHASS } from '../test-utils';
 
 vi.mock('../../package.json', () => ({
@@ -14,7 +14,7 @@ vi.mock('../../package.json', () => ({
 }));
 vi.mock('../../src/utils/ha');
 vi.mock('../../src/localize/localize.js');
-vi.mock('../../src/utils/ha/device-registry');
+vi.mock('../../src/utils/ha/registry/device/index.js');
 
 describe('getReleaseVersion', () => {
   it('should get release version', () => {
@@ -37,6 +37,7 @@ describe('getDiagnostics', () => {
 
     vi.mocked(getAllDevices).mockResolvedValue([
       {
+        id: 'id',
         model: '4.0.0/0.13.0-aded314',
         config_entries: [
           'ac4e79d258449a83bc0cf6d47a021c46',
@@ -97,6 +98,7 @@ describe('getDiagnostics', () => {
   it('should fetch diagnostics without device model', async () => {
     vi.mocked(getAllDevices).mockResolvedValue([
       {
+        id: 'id',
         model: null,
         config_entries: [
           'ac4e79d258449a83bc0cf6d47a021c46',
diff --git a/tests/utils/ha/registry/cache.test.ts b/tests/utils/ha/registry/cache.test.ts
new file mode 100644
index 00000000..ba6ffc65
--- /dev/null
+++ b/tests/utils/ha/registry/cache.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+import { RegistryCache } from '../../../../src/utils/ha/registry/cache';
+
+interface TestCacheValue {
+  id: string;
+  val?: number;
+}
+
+describe('RegistryCache', () => {
+  describe('has', () => {
+    it('positive', () => {
+      const cache = new RegistryCache<TestCacheValue>((arg) => arg.id);
+      cache.add({ id: 'test' });
+      expect(cache.has('test')).toBeTruthy();
+    });
+
+    it('negative', () => {
+      const cache = new RegistryCache<TestCacheValue>((arg) => arg.id);
+      cache.add({ id: 'test' });
+      expect(cache.has('absent')).toBeFalsy();
+    });
+  });
+
+  it('getMatches', () => {
+    const cache = new RegistryCache<TestCacheValue>((arg) => arg.id);
+    cache.add([
+      { id: 'test-1', val: 1 },
+      { id: 'test-5', val: 5 },
+      { id: 'test-8', val: 8 },
+    ]);
+    expect(cache.getMatches((obj) => !!obj.val && obj.val >= 5)).toEqual([
+      { id: 'test-5', val: 5 },
+      { id: 'test-8', val: 8 },
+    ]);
+  });
+
+  describe('get', () => {
+    it('positive', () => {
+      const cache = new RegistryCache<TestCacheValue>((arg) => arg.id);
+      cache.add({ id: 'test', val: 42 });
+      expect(cache.get('test')).toEqual({ id: 'test', val: 42 });
+    });
+
+    it('negative', () => {
+      const cache = new RegistryCache<TestCacheValue>((arg) => arg.id);
+      expect(cache.get('test')).toBeNull();
+    });
+  });
+});
diff --git a/tests/utils/ha/registry/entity/index.test.ts b/tests/utils/ha/registry/entity/index.test.ts
new file mode 100644
index 00000000..0f143fb2
--- /dev/null
+++ b/tests/utils/ha/registry/entity/index.test.ts
@@ -0,0 +1,110 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { homeAssistantWSRequest } from '../../../../../src/utils/ha';
+import {
+  createEntityRegistryCache,
+  EntityRegistryManager,
+} from '../../../../../src/utils/ha/registry/entity';
+import { createHASS, createRegistryEntity } from '../../../../test-utils.js';
+
+vi.mock('../../../../../src/utils/ha');
+
+describe('EntityRegistryManager', () => {
+  afterEach(() => {
+    vi.clearAllMocks();
+  });
+
+  describe('getEntity', () => {
+    it('should not fetch when cached', async () => {
+      const cache = createEntityRegistryCache();
+      const testEntity = createRegistryEntity({ entity_id: 'test' });
+
+      cache.add(testEntity);
+
+      const manager = new EntityRegistryManager(cache);
+      expect(await manager.getEntity(createHASS(), 'test')).toEqual(testEntity);
+
+      expect(homeAssistantWSRequest).not.toHaveBeenCalled();
+    });
+
+    it('should fetch and cache when not cached', async () => {
+      const testEntity = createRegistryEntity({ entity_id: 'test' });
+
+      const manager = new EntityRegistryManager(createEntityRegistryCache());
+      vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(testEntity);
+
+      expect(await manager.getEntity(createHASS(), 'test')).toEqual(testEntity);
+      expect(homeAssistantWSRequest).toBeCalledTimes(1);
+
+      expect(await manager.getEntity(createHASS(), 'test')).toEqual(testEntity);
+      expect(homeAssistantWSRequest).toBeCalledTimes(1);
+    });
+
+    it('should return null when entity does not exist', async () => {
+      vi.mocked(homeAssistantWSRequest).mockRejectedValueOnce(new Error('Not found'));
+
+      const manager = new EntityRegistryManager(createEntityRegistryCache());
+      expect(await manager.getEntity(createHASS(), 'missing')).toBeNull();
+    });
+  });
+
+  it('getEntities', () => {
+    const cachedEntity = createRegistryEntity({ entity_id: 'cached' });
+    const notCachedEntity = createRegistryEntity({ entity_id: 'not-cached' });
+
+    const cache = createEntityRegistryCache();
+    cache.add(cachedEntity);
+
+    const manager = new EntityRegistryManager(cache);
+    vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce(notCachedEntity);
+    vi.mocked(homeAssistantWSRequest).mockRejectedValueOnce(new Error('Not found'));
+
+    expect(
+      manager.getEntities(createHASS(), ['cached', 'not-cached', 'missing']),
+    ).resolves.toEqual(
+      new Map([
+        ['cached', cachedEntity],
+        ['not-cached', notCachedEntity],
+      ]),
+    );
+  });
+
+  it('fetchEntityList', async () => {
+    const hass = createHASS();
+    const entity = createRegistryEntity({ entity_id: 'cached' });
+    vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce([entity]);
+
+    const manager = new EntityRegistryManager(createEntityRegistryCache());
+
+    await manager.fetchEntityList(hass);
+
+    expect(homeAssistantWSRequest).toBeCalledTimes(1);
+    expect(homeAssistantWSRequest).toBeCalledWith(expect.anything(), expect.anything(), {
+      type: 'config/entity_registry/list',
+    });
+
+    expect(await manager.getEntity(hass, 'cached')).toEqual(entity);
+    expect(homeAssistantWSRequest).toBeCalledTimes(1);
+
+    await manager.fetchEntityList(hass);
+    expect(homeAssistantWSRequest).toBeCalledTimes(1);
+  });
+
+  it('getMatchingEntities', async () => {
+    const matchingEntity = createRegistryEntity({ entity_id: 'matching' });
+    const notMatchingEntity = createRegistryEntity({ entity_id: 'not-matching' });
+    const hass = createHASS();
+
+    vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce([
+      matchingEntity,
+      notMatchingEntity,
+    ]);
+
+    const manager = new EntityRegistryManager(createEntityRegistryCache());
+    expect(
+      await manager.getMatchingEntities(
+        hass,
+        (entity) => entity.entity_id == 'matching',
+      ),
+    ).toEqual([matchingEntity]);
+  });
+});