diff --git a/README.md b/README.md index dcf5b3b6..9a9900a9 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,9 @@ See the [fully expanded cameras configuration example](#config-expanded-cameras) | `webrtc_card` | | :heavy_multiplication_x: | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See below. | | `id` | `camera_entity`, `webrtc_card.entity` or `camera_name` if set (in that preference order). | :heavy_multiplication_x: | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. See [camera IDs](#camera-ids). | | `dependent_cameras` | | :heavy_multiplication_x: | An optional array of other camera identifiers (see [camera IDs](#camera-ids)). If specified the card will fetch events for this camera and *also* recursively events for the named `dependent_cameras`. All `dependent_cameras` must themselves be a configured camera in the card. This can be useful to group events for cameras that are close together, or to show events for the `birdseye` camera that otherwise would not have events itself.| +| `trigger_by_motion` | `false` | :heavy_multiplication_x: | Whether to not to trigger the camera (used to trigger [scan mode](#scan-mode) or reseting the default view) by automatically detecting and using the motion `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the motion `binary_sensor` entity has been enabled in Home Assistant.| +| `trigger_by_occupancy` | `true` | :heavy_multiplication_x: | Whether to not to trigger the camera (used to trigger [scan mode](#scan-mode) or reseting the default view) by automatically detecting and using the occupancy `binary_sensor` for this camera. This autodetection only works for Frigate cameras, and only when the occupancy `binary_sensor` entity has been enabled in Home Assistant.| +| `trigger_by_entities` | | :heavy_multiplication_x: | Whether to not to trigger the camera (used to trigger [scan mode](#scan-mode) or reseting the default view) when the state of any Home Assistant entity becomes active (i.e. state becomes `on` or `open`). This works for Frigate or non-Frigate cameras.| @@ -171,10 +174,32 @@ See the [fully expanded view configuration example](#config-expanded-view) for h | `timeout_seconds` | `300` | :white_check_mark: | A numbers of seconds of inactivity after user interaction, after which the card will reset to the default configured view (i.e. 'screensaver' functionality). Inactivity is defined as lack of mouse/touch interaction with the Frigate card. If the default view occurs sooner (e.g. via `update_seconds` or manually) the timer will be stopped. `0` means disable this functionality. | | `update_seconds` | `0` | :white_check_mark: | A number of seconds after which to automatically update/refresh the default view. See [card updates](#card-updates) below for behavior and usecases. If the default view occurs sooner (e.g. manually) the timer will start over. `0` disables this functionality.| | `update_force` | `false` | :white_check_mark: | Whether automated card updates/refreshes should ignore user interaction. See [card updates](#card-updates) below for behavior and usecases.| -| `update_entities` | | :white_check_mark: | **YAML only**: A list of entity ids that should cause the view to reset to the default. See [card updates](#card-updates) below for behavior and usecases.| +| `update_entities` | | :white_check_mark: | **YAML only**: A card-wide list of entities that should cause the view to reset to the default (if the entity only pertains to a particular camera use `trigger_by_entities` for the selected camera instead) See [card updates](#card-updates) below for behavior and usecases.| | `update_cycle_camera` | `false` | :white_check_mark: | When set to `true` the selected camera is cycled on each default view change. | | `render_entities` | | :white_check_mark: | **YAML only**: A list of entity ids that should cause the card to re-render 'in-place'. The view/camera is not changed. `update_*` flags do not pertain/relate to the behavior of this flag. This should **very** rarely be needed, but could be useful if the card is both setting and changing HA state of the same object as could be the case for some complex `card_mod` scenarios ([example](https://github.com/dermotduffy/frigate-hass-card/issues/343)). | +| `scan` | | :white_check_mark: | Configuration for [scan mode](#scan-mode). | | `actions` | | :white_check_mark: | Actions to use for all views, individual actions may be overriden by view-specific actions. See [actions](#actions) below.| + + + +#### View: Scan Mode configuration + +All configuration is under: + +```yaml +view: + scan: +``` + +Scan mode allows the card to automatically "follow the action". In this mode the card will automatically select a camera in the `live` view when it is triggered (as defined by your camera configuration, see `trigger_by_motion`, `trigger_by_occupancy` and `trigger_by_entities` parameters). When the camera untriggers, the camera selection will return to the next most recently triggered camera (as long as it is still triggered) -- if there are no triggered cameras remaining, the camera will return to the default. Triggering is only allowed when there is no ongoing human interaction with the card -- interaction will automatically untrigger it and further triggering will not occur until after the card has been unattended for `view.timeout_seconds`. + +Scan mode tracks Home Assistant state changes -- when the card is first started, it takes a positive change in state to trigger (i.e. an already occupied room will not trigger it, but a newly occupied room would trigger it). + +| Option | Default | Overridable | Description | +| - | - | - | - | +| `enabled` | `false` | :white_check_mark: | Whether to enable scan mode. | +| `show_trigger_status` | `true` | :white_check_mark: | Whether or not the card should show a visual indication that it is triggered (a pulsing border around the card edge). | + ### Menu Options All configuration is under: @@ -896,6 +921,44 @@ This card supports fully configurable submenus. Configurable submenus +### Select Entity Submenus + +Automatically generate submenus from `select` entities. + +Select based submenus + +### Cast media from the card + +Cast media from the card to a local player. + +Cast media + +### Scan Mode + +Automatically choose the camera with the action! + +Cast media + +### Thumbnail Drawers + +View thumbnails in side-drawers. + +Thumbnail drawers + +### Event Timeline + +View events in the timeline. + +Event Timeline + + + +### Card Casting + +A dashboard with the card can be cast onto a suitable device (such as the Nest Hub shown below). + +Card on Nest Hub + ## Examples ### Illustrative Expanded Configuration Reference @@ -921,6 +984,10 @@ cameras: # Show events for camera-2 when this camera is viewed. dependent_cameras: - camera-2 + trigger_by_motion: false + trigger_by_occupancy: true + trigger_by_entities: + - binary_sensor.front_door_sensor - frigate_url: http://my-other.frigate.local client_id: frigate-other camera_name: entrance @@ -935,6 +1002,10 @@ cameras: webrtc_card: entity: camera.entrance_rtsp url: 'rtsp://username:password@camera:554/av_stream/ch0' + trigger_by_motion: false + trigger_by_occupancy: true + trigger_by_entities: + - binary_sensor.entrance_sensor ``` @@ -958,6 +1029,9 @@ view: render_entities: - switch.render_card dark_mode: 'off' + scan: + enabled: false + show_trigger_status: true actions: entity: light.office_main_lights tap_action: @@ -2234,7 +2308,7 @@ the card to trigger a card update based on that entity -- which causes it to use the new overriden default immediately. Alternatives to trigger the card to change view but without `update_entities` would just be having an `update_seconds` parameter which reloads the default view that many seconds -after user interaction stops. +after user interaction stops or through the use of the `trigger_by_entities` option for a given camera. ```yaml view: @@ -2365,6 +2439,30 @@ cameras: ``` +### Using Scan Mode + +Have your card follow the action with Scan Mode. + +
+ Expand: Using scan mode + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.back_yard + # This camera will automatically trigger by occupancy. + - camera_entity: camera.front_door + trigger_by_occupancy: false + trigger_by_motion: true + trigger_by_entities: + - binary_sensor.door_opened +view: + scan: + enabled: true + trigger_show_border: true +``` +
+ ## Card Refreshes @@ -2376,7 +2474,11 @@ The following table describes the behavior these flags have. ### Card Update Truth Table -| `view . update_seconds` | `view . timeout_seconds` | `view . update_force` | `view . update_entities` | Behavior | +Note that no (other) automated updates are permitted when [scan mode](#scan-mode) is being triggered. + +In the below "Trigger Entities" refers to the combination of `view.update_entities` and the `trigger_by_entities` for the currently selected camera (which in turn will also include the occupancy and motion sensor entities for Frigate cameras if `trigger_by_occupancy` and `trigger_by_motion` options are enabled). + +| `view . update_seconds` | `view . timeout_seconds` | `view . update_force` | Trigger Entities | Behavior | | :-: | :-: | :-: | :-: | - | | `0` | `0` | *(Any value)* | Unset | Card will not automatically refresh. | | `0` | `0` | *(Any value)* | *(Any entity)* | Card will reload default view & camera when entity state changes. | @@ -2400,13 +2502,11 @@ view: ``` * Using `clip` or `snapshot` as the default view (for the most recent clip or snapshot respectively) and having the card automatically refresh (to fetch a - newer clip/snapshot) when an entity state changes. Use the Frigate - binary_sensor for that camera (or any other entity at your discretion) to - trigger the update: + newer clip/snapshot) on motion. ```yaml -view: - update_entities: - - binary_sensor.office_person_motion +cameras: + - entity: camera.office + trigger_by_motion: true ``` * Cycle the live view of the camera every 60 seconds ```yaml @@ -2422,6 +2522,33 @@ view: timeout_seconds: 30 ``` +### Casting the Card + +This card can be (Chrome) casted to a device (such as a [Nest Hub](https://store.google.com/us/product/nest_hub_2nd_gen)) through the use of [Home Assistant Cast](https://cast.home-assistant.io/). + +#### Limitations + +Casting Home Assistant dashboards comes with a number of caveats: + +* Home Assistant Casting does not support the HA `streaming` component ([source](https://cast.home-assistant.io/faq.html)). This means clips playing and the `ha` live provider can not work. Other live providers such as `jsmpeg` and `webrtc-card` function correctly. +* The Javascript fullscreen API does not work (so the fullscreen button does not work, but see below for an equivalent). + +#### Recommended configuration for a Nest Hub + +Using a `panel` dashboard with the following base configuration will result in the card consuming the entire device screen: + +```yaml +type: custom:frigate-card +cameras: + - camera_entity: camera.front_door + live_provider: frigate-jsmpeg +dimensions: + aspect_ratio: 1024:600 + aspect_ratio_mode: static +``` + +See [screenshot above](#screenshots-card-casting). + ## Troubleshooting diff --git a/images/card-on-nest-hub.jpg b/images/card-on-nest-hub.jpg new file mode 100644 index 00000000..7a911edb Binary files /dev/null and b/images/card-on-nest-hub.jpg differ diff --git a/images/cast-your-events.gif b/images/cast-your-events.gif new file mode 100644 index 00000000..5db40e96 Binary files /dev/null and b/images/cast-your-events.gif differ diff --git a/images/scan-mode.gif b/images/scan-mode.gif new file mode 100644 index 00000000..ba6e2182 Binary files /dev/null and b/images/scan-mode.gif differ diff --git a/images/submenu-select.gif b/images/submenu-select.gif new file mode 100644 index 00000000..b17c460e Binary files /dev/null and b/images/submenu-select.gif differ diff --git a/images/thumbnails-in-drawer.gif b/images/thumbnails-in-drawer.gif new file mode 100644 index 00000000..2c0122a3 Binary files /dev/null and b/images/thumbnails-in-drawer.gif differ diff --git a/images/timeline.gif b/images/timeline.gif new file mode 100644 index 00000000..cae668d8 Binary files /dev/null and b/images/timeline.gif differ diff --git a/src/card.ts b/src/card.ts index cba2ea2b..1c7559f1 100644 --- a/src/card.ts +++ b/src/card.ts @@ -48,23 +48,21 @@ import './patches/ha-camera-stream.js'; import './patches/ha-hls-player.js'; import './patches/ha-web-rtc-player.ts'; import cardStyle from './scss/card.scss'; -import type { - Entity, - ExtendedHomeAssistant, - FrigateCardConfig, - MediaShowInfo, - MenuButton, - Message, -} from './types.js'; import { Actions, ActionType, CameraConfig, - entitySchema, + EntityList, + ExtendedEntity, + ExtendedHomeAssistant, + FrigateCardConfig, frigateCardConfigSchema, FrigateCardCustomAction, FrigateCardView, FRIGATE_CARD_VIEWS_USER_SPECIFIED, + MediaShowInfo, + MenuButton, + Message, RawFrigateCardConfig, } from './types.js'; import { @@ -79,15 +77,22 @@ import { getCameraIcon, getCameraID, getCameraTitle } from './utils/camera.js'; import { getEntityIcon, getEntityTitle, + getHassDifferences, homeAssistantSignPath, - homeAssistantWSRequest, - shouldUpdateBasedOnHass, + isHassDifferent, + isTriggeredState, sideLoadHomeAssistantElements, } from './utils/ha'; import { getEventID } from './utils/ha/browse-media.js'; +import { + ExtendedEntityCache, + getAllEntities, + getExtendedEntities, + getExtendedEntity, +} from './utils/ha/entity-registry.js'; +import { ResolvedMediaCache } from './utils/ha/resolved-media.js'; import { supportsFeature } from './utils/ha/update.js'; import { isValidMediaShowInfo } from './utils/media-info.js'; -import { ResolvedMediaCache } from './utils/resolved-media.js'; import { View } from './view.js'; /** A note on media callbacks: @@ -191,6 +196,8 @@ export class FrigateCard extends LitElement { // Whether the card has been successfully initialized. protected _initialized = false; + protected _triggers: Map = new Map(); + /** * Set the Home Assistant object. */ @@ -488,7 +495,6 @@ export class FrigateCard extends LitElement { const isValidMediaPlayer = (entity: string): boolean => { if (entity.startsWith('media_player.')) { const stateObj = this._hass?.states[entity]; - if (stateObj && supportsFeature(stateObj, MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA)) { return true; } @@ -571,23 +577,136 @@ export class FrigateCard extends LitElement { } } + /** + * Get the motion sensor entity for a given camera. + * @param cache The ExtendedEntityCache of entity registry information. + * @param cameraConfig The camera config in question. + * @returns The entity id of the motion sensor or null. + */ + protected _getMotionSensor( + cache: ExtendedEntityCache, + cameraConfig: CameraConfig, + ): string | null { + if (cameraConfig.camera_name) { + return ( + cache.getMatch( + (ent) => + !!ent.unique_id?.match( + new RegExp( + `:motion_sensor:${cameraConfig.zone || cameraConfig.camera_name}`, + ), + ), + )?.entity_id ?? null + ); + } + return null; + } + + /** + * Get the occupancy sensor entity for a given camera. + * @param cache The ExtendedEntityCache of entity registry information. + * @param cameraConfig The camera config in question. + * @returns The entity id of the occupancy sensor or null. + */ + protected _getOccupancySensor( + cache: ExtendedEntityCache, + cameraConfig: CameraConfig, + ): string | null { + if (cameraConfig.camera_name) { + return ( + cache.getMatch( + (ent) => + !!ent.unique_id?.match( + new RegExp( + `:occupancy_sensor:${cameraConfig.zone || cameraConfig.camera_name}_${ + cameraConfig.label || 'all' + }`, + ), + ), + )?.entity_id ?? null + ); + } + return null; + } + /** * Fully load the configured cameras. */ protected async _loadCameras(): Promise { + if (!this._hass) { + return; + } + + const cache = new ExtendedEntityCache(); + let entityList: EntityList | undefined; + try { + entityList = await getAllEntities(this._hass); + } catch (e) { + console.error(e, (e as Error).stack); + } + const cameras: Map = new Map(); let errorFree = true; const addCameraConfig = async (config: CameraConfig) => { - if (!config.camera_name && config.camera_entity) { - const resolvedName = await this._getFrigateCameraNameFromEntity( - config.camera_entity, - ); + if (!this._hass) { + return; + } + + let entity: ExtendedEntity | null = null; + if (config.camera_entity) { + try { + entity = await getExtendedEntity(this._hass, config.camera_entity, cache); + } catch (e) { + console.error(e, (e as Error).stack); + } + } + + if (!config.camera_name && entity) { + const resolvedName = this._getFrigateCameraNameFromEntity(entity); if (resolvedName) { config.camera_name = resolvedName; } } + if (entity && entityList) { + // Try to find the correct entities for the motion & occupancy sensors. + // We know they are binary_sensors, and that they'll have the same + // config entry ID as the camera. Searching via unique_id ensures this + // search still works if the user renames the entity_id. + const binarySensorEntities = entityList.filter( + (ent) => + ent.config_entry_id === entity?.config_entry_id && + !ent.disabled_by && + ent.entity_id.startsWith('binary_sensor.'), + ); + + try { + await getExtendedEntities( + this._hass, + binarySensorEntities.map((ent) => ent.entity_id), + cache, + ); + } catch (e) { + console.error(e, (e as Error).stack); + } + + if (config.trigger_by_motion) { + const motionEntity = this._getMotionSensor(cache, config); + if (motionEntity) { + config.trigger_by_entities.push(motionEntity); + } + } + + if (config.trigger_by_occupancy) { + const occupancyEntity = this._getOccupancySensor(cache, config); + if (occupancyEntity) { + config.trigger_by_entities.push(occupancyEntity); + } + } + } + config.trigger_by_entities = [...new Set(config.trigger_by_entities)]; + const id = getCameraID(config); if (!id) { this._setMessageAndUpdate({ @@ -641,37 +760,16 @@ export class FrigateCard extends LitElement { } /** - * Get the Frigate camera name from an entity name. + * Get the Frigate camera name from an entity. * @returns The Frigate camera name or null if unavailable. */ - protected async _getFrigateCameraNameFromEntity( - entity: string, - ): Promise { - if (!this._hass) { - return null; - } - - // Find entity unique_id in registry. - const request = { - type: 'config/entity_registry/get', - entity_id: entity, - }; - try { - const entityResult = await homeAssistantWSRequest( - this._hass, - entitySchema, - request, - ); - if (entityResult && entityResult.platform == 'frigate') { - const match = entityResult.unique_id.match(/:camera:(?[^:]+)$/); - if (match && match.groups) { - return match.groups['camera']; - } + protected _getFrigateCameraNameFromEntity(entity: ExtendedEntity): string | null { + if (entity.unique_id && entity.platform === 'frigate') { + const match = entity.unique_id.match(/:camera:(?[^:]+)$/); + if (match && match.groups) { + return match.groups['camera']; } - } catch (e: unknown) { - // Pass. } - return null; } @@ -775,6 +873,7 @@ export class FrigateCard extends LitElement { this._message = null; this._generateConditionState(); this._setLightOrDarkMode(); + this._untrigger(); } /** @@ -850,6 +949,7 @@ export class FrigateCard extends LitElement { * Called before each update. */ protected willUpdate(): void { + // Side load the necessary elements if not already initialized. if (!this._initialized) { sideLoadHomeAssistantElements().then((success) => { if (success) { @@ -859,6 +959,73 @@ export class FrigateCard extends LitElement { } } + /** + * Get the most recent triggered camera. + */ + protected _getMostRecentTrigger(): string | null { + const sorted = [...this._triggers.entries()].sort( + (a: [string, Date], b: [string, Date]) => b[1].getTime() - a[1].getTime(), + ); + return sorted.length ? sorted[0][0] : null; + } + + /** + * Determine if a camera has been triggered. + * @param oldHass The old HA object. + * @returns A boolean indicating whether the camera was changed. + */ + protected _updateTriggeredCameras(oldHass: HomeAssistant): boolean { + if (!this._view) { + return false; + } + + const now = new Date(); + let changedCamera = false; + let triggerChanges = false; + + for (const [camera, config] of this._cameras?.entries() ?? []) { + const triggerEntities = config?.trigger_by_entities ?? []; + const diffs = getHassDifferences(this._hass, oldHass, triggerEntities, { + stateOnly: true, + }); + const shouldTrigger = diffs.some((diff) => isTriggeredState(diff.newState)); + const shouldUntrigger = triggerEntities.every( + (entity) => !isTriggeredState(this._hass?.states[entity]), + ); + if (shouldTrigger) { + this._triggers.set(camera, now); + triggerChanges = true; + } else if (shouldUntrigger && this._triggers.has(camera)) { + this._triggers.delete(camera); + triggerChanges = true; + } + } + + if (triggerChanges && this._isAutomatedViewUpdateAllowed(true)) { + if (!this._triggers.size) { + this._changeView(); + changedCamera = true; + } else { + const targetCamera = this._getMostRecentTrigger(); + if ( + targetCamera && + (this._view.camera !== targetCamera || !this._view.is('live')) + ) { + this._changeView({ view: new View({ view: 'live', camera: targetCamera }) }); + changedCamera = true; + } + } + } + return changedCamera; + } + + /** + * Untrigger the card. + */ + protected _untrigger(): void { + this._triggers.clear(); + } + /** * Determine whether the element should be updated. * @param changedProps The changed properties if any. @@ -869,19 +1036,21 @@ export class FrigateCard extends LitElement { let shouldUpdate = !oldHass || changedProps.size != 1; if (oldHass) { - // Home Assistant pumps a lot of updates through. Re-rendering the card is - // necessary at times (e.g. to update the 'clip' view as new clips - // arrive), but also is a jarring experience for the user (e.g. if they - // are browsing the mini-gallery). Do not allow re-rendering from a Home - // Assistant update if there's been recent interaction (e.g. clicks on the - // card) or if there is media active playing. - if ( + const selectedCamera = this._getSelectedCameraConfig(); + if (this._getConfig().view.scan.enabled && this._updateTriggeredCameras(oldHass)) { + shouldUpdate ||= true; + } else if ( + // Home Assistant pumps a lot of updates through. Re-rendering the card is + // necessary at times (e.g. to update the 'clip' view as new clips + // arrive), but also is a jarring experience for the user (e.g. if they + // are browsing the mini-gallery). Do not allow re-rendering from a Home + // Assistant update if there's been recent interaction (e.g. clicks on the + // card) or if there is media active playing. this._isAutomatedViewUpdateAllowed() && - shouldUpdateBasedOnHass( - this._hass, - oldHass, - this._getConfig().view.update_entities || [], - ) + isHassDifferent(this._hass, oldHass, [ + ...(this._getConfig().view.update_entities || []), + ...(selectedCamera?.trigger_by_entities || []), + ]) ) { // If entities being monitored have changed then reset the view to the // default. Note that as per the Lit lifecycle, the setting of the view @@ -889,7 +1058,7 @@ export class FrigateCard extends LitElement { this._changeView(); shouldUpdate ||= true; } else { - shouldUpdate ||= shouldUpdateBasedOnHass( + shouldUpdate ||= isHassDifferent( this._hass, oldHass, this._getConfig().view.render_entities || [], @@ -1177,11 +1346,17 @@ export class FrigateCard extends LitElement { */ protected _startInteractionTimer(): void { this._clearInteractionTimer(); + + // Interactions reset the trigger state. + this._untrigger(); + if (this._getConfig().view.timeout_seconds) { this._interactionTimerID = window.setTimeout(() => { - this._changeView(); this._clearInteractionTimer(); - this._setLightOrDarkMode(); + if (this._isAutomatedViewUpdateAllowed()) { + this._changeView(); + this._setLightOrDarkMode(); + } }, this._getConfig().view.timeout_seconds * 1000); } this._setLightOrDarkMode(); @@ -1213,8 +1388,11 @@ export class FrigateCard extends LitElement { * Determine if an automated view update is allowed. * @returns `true` if it's allowed, `false` otherwise. */ - protected _isAutomatedViewUpdateAllowed(): boolean { - return this._getConfig().view.update_force || !this._interactionTimerID; + protected _isAutomatedViewUpdateAllowed(ignoreTriggers?: boolean): boolean { + return ( + (ignoreTriggers || !this._triggers.size) && + (this._getConfig().view.update_force || !this._interactionTimerID) + ); } /** @@ -1342,21 +1520,21 @@ export class FrigateCard extends LitElement { * required). * @returns A padding percentage. */ - protected _getAspectRatioPadding(): number | null { + protected _getAspectRatioStyle(): string { if (!this._isAspectRatioEnforced()) { - return null; + return 'auto'; } const aspectRatioMode = this._getConfig().dimensions.aspect_ratio_mode; if (aspectRatioMode == 'dynamic' && this._mediaShowInfo) { - return (this._mediaShowInfo.height / this._mediaShowInfo.width) * 100; + return `${this._mediaShowInfo.width} / ${this._mediaShowInfo.height}`; } const defaultAspectRatio = this._getConfig().dimensions.aspect_ratio; if (defaultAspectRatio) { - return (defaultAspectRatio[1] / defaultAspectRatio[0]) * 100; + return `${defaultAspectRatio[0]} / ${defaultAspectRatio[1]}`; } else { - return (9 / 16) * 100; + return '16 / 9'; } } @@ -1389,17 +1567,13 @@ export class FrigateCard extends LitElement { return; } - const padding = this._getAspectRatioPadding(); - const outerStyle = {}; - - // Padding to force a particular aspect ratio. - if (padding != null) { - outerStyle['padding-top'] = `${padding}%`; - } - - const contentClasses = { - 'frigate-card-contents': true, - absolute: padding != null, + const cardStyle = { + 'aspect-ratio': this._getAspectRatioStyle(), + }; + const mainClasses = { + main: true, + triggered: + !!this._triggers.size && this._getConfig().view.scan.show_trigger_status, }; const actions = this._getMergedActions(); @@ -1412,6 +1586,7 @@ export class FrigateCard extends LitElement { hasHold: frigateCardHasAction(actions.hold_action), hasDoubleClick: frigateCardHasAction(actions.double_tap_action), })} + style="${styleMap(cardStyle)}" @action=${(ev: CustomEvent) => this._actionHandler(ev, actions)} @ll-custom=${this._cardActionHandler.bind(this)} @frigate-card:message=${this._messageHandler} @@ -1420,49 +1595,47 @@ export class FrigateCard extends LitElement { @frigate-card:render=${() => this.requestUpdate()} > ${renderMenuAbove ? this._renderMenu() : ''} -
-
- ${this._cameras === undefined - ? until( - (async () => { - await this._loadCameras(); - // Don't reset messages as errors may have been generated - // during the camera load. - this._changeView({ resetMessage: false }); - return this._render(); - })(), - renderProgressIndicator(), - ) - : // Always want to call render even if there's a message, to - // ensure live preload is always present (even if not displayed). - this._render()} - ${this._getConfig().elements - ? // Always show elements to allow for custom menu items (etc.) to - // be present even if a particular view has an error. Elements - // need to render after the main views so it can render 'on top'. - html` { - this._addDynamicMenuButton(e.detail); - }} - @frigate-card:menu-remove=${(e) => { - this._removeDynamicMenuButton(e.detail); - }} - @frigate-card:condition-state-request=${(ev) => { - conditionStateRequestHandler(ev, this._conditionState); - }} - > - ` - : ``} - ${ - // Keep message rendering to last to show messages that may have - // been generated during the render. - this._message ? renderMessage(this._message) : '' - } -
+
+ ${this._cameras === undefined + ? until( + (async () => { + await this._loadCameras(); + // Don't reset messages as errors may have been generated + // during the camera load. + this._changeView({ resetMessage: false }); + return this._render(); + })(), + renderProgressIndicator(), + ) + : // Always want to call render even if there's a message, to + // ensure live preload is always present (even if not displayed). + this._render()} + ${this._getConfig().elements + ? // Always show elements to allow for custom menu items (etc.) to + // be present even if a particular view has an error. Elements + // need to render after the main views so it can render 'on top'. + html` { + this._addDynamicMenuButton(e.detail); + }} + @frigate-card:menu-remove=${(e) => { + this._removeDynamicMenuButton(e.detail); + }} + @frigate-card:condition-state-request=${(ev) => { + conditionStateRequestHandler(ev, this._conditionState); + }} + > + ` + : ``} + ${ + // Keep message rendering to last to show messages that may have + // been generated during the render. + this._message ? renderMessage(this._message) : '' + }
${!renderMenuAbove ? this._renderMenu() : ''} `; diff --git a/src/components/image.ts b/src/components/image.ts index 0ba8f042..98e6e10b 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -1,11 +1,11 @@ import { HomeAssistant } from 'custom-card-helpers'; import { - CSSResultGroup, - html, - LitElement, - PropertyValues, - TemplateResult, - unsafeCSS + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js'; @@ -14,7 +14,7 @@ import defaultImage from '../images/frigate-bird-in-sky.jpg'; import { localize } from '../localize/localize.js'; import imageStyle from '../scss/image.scss'; import { CameraConfig, ImageViewConfig } from '../types.js'; -import { shouldUpdateBasedOnHass } from '../utils/ha'; +import { isHassDifferent } from '../utils/ha'; import { dispatchMediaShowEvent } from '../utils/media-info.js'; import { View } from '../view.js'; import { dispatchErrorMessageEvent } from './message.js'; @@ -96,7 +96,7 @@ export class FrigateCardImage extends LitElement { this._imageConfig?.mode === 'camera' && cameraEntity ) { - if (shouldUpdateBasedOnHass(this.hass, changedProps.get('hass'), [cameraEntity])) { + if (isHassDifferent(this.hass, changedProps.get('hass'), [cameraEntity])) { // If the state of the camera entity has changed, remove the cached // value (will be re-calculated in willUpdate). This is important to // ensure a changed access token is immediately used. diff --git a/src/components/live.ts b/src/components/live.ts index 83ad8627..8cf3c6f0 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -1090,7 +1090,8 @@ export class FrigateCardLiveJSMPEG extends LitElement { if (!this.cameraConfig?.camera_name) { return dispatchErrorMessageEvent( this, - localize('error.no_camera_name') + `: ${JSON.stringify(this.cameraConfig)}`, + localize('error.no_camera_name'), + this.cameraConfig, ); } diff --git a/src/components/submenu.ts b/src/components/submenu.ts index f7a77562..270bfb76 100644 --- a/src/components/submenu.ts +++ b/src/components/submenu.ts @@ -1,12 +1,12 @@ import type { Corner } from '@material/mwc-menu'; import { HomeAssistant } from 'custom-card-helpers'; import { - CSSResultGroup, - html, - LitElement, - PropertyValues, - TemplateResult, - unsafeCSS + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, + unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { ifDefined } from 'lit/directives/if-defined.js'; @@ -15,10 +15,10 @@ import { actionHandler } from '../action-handler-directive.js'; import submenuStyle from '../scss/submenu.scss'; import { MenuSubmenu, MenuSubmenuItem, MenuSubmenuSelect } from '../types.js'; import { - frigateCardHasAction, - stopEventFromActivatingCardWideActions + frigateCardHasAction, + stopEventFromActivatingCardWideActions } from '../utils/action.js'; -import { refreshDynamicStateParameters, shouldUpdateBasedOnHass } from '../utils/ha'; +import { isHassDifferent, refreshDynamicStateParameters } from '../utils/ha'; import { domainIcon } from '../utils/icons/domain-icon.js'; @customElement('frigate-card-submenu') @@ -139,7 +139,7 @@ export class FrigateCardSubmenuSelect extends LitElement { changedProps.size != 1 || !this.submenuSelect || (!!oldHass && - shouldUpdateBasedOnHass(this.hass, oldHass, [this.submenuSelect.entity])) + isHassDifferent(this.hass, oldHass, [this.submenuSelect.entity])) ); } diff --git a/src/components/viewer.ts b/src/components/viewer.ts index 0819b960..f48975c6 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -37,7 +37,7 @@ import { overrideMultiBrowseMediaQueryParameters } from '../utils/ha/browse-media.js'; import { createMediaShowInfo } from '../utils/media-info.js'; -import { ResolvedMediaCache, resolveMedia } from '../utils/resolved-media.js'; +import { ResolvedMediaCache, resolveMedia } from '../utils/ha/resolved-media.js'; import { View } from '../view.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { Lazyload, LazyloadType } from './embla-plugins/lazyload.js'; diff --git a/src/const.ts b/src/const.ts index e61138ac..717e82f4 100644 --- a/src/const.ts +++ b/src/const.ts @@ -23,6 +23,12 @@ export const CONF_CAMERAS_ARRAY_LIVE_PROVIDER = `${CONF_CAMERAS}.#.live_provider` as const; export const CONF_CAMERAS_ARRAY_DEPENDENT_CAMERAS = `${CONF_CAMERAS}.#.dependent_cameras` as const; +export const CONF_CAMERAS_ARRAY_TRIGGER_BY_MOTION = + `${CONF_CAMERAS}.#.trigger_by_motion` as const; +export const CONF_CAMERAS_ARRAY_TRIGGER_BY_OCCUPANCY = + `${CONF_CAMERAS}.#.trigger_by_occupancy` as const; +export const CONF_CAMERAS_ARRAY_TRIGGER_BY_ENTITIES = + `${CONF_CAMERAS}.#.trigger_by_entities` as const; export const CONF_VIEW = 'view' as const; export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const; @@ -33,6 +39,10 @@ export const CONF_VIEW_UPDATE_CYCLE_CAMERA = `${CONF_VIEW}.update_cycle_camera` export const CONF_VIEW_UPDATE_FORCE = `${CONF_VIEW}.update_force` as const; export const CONF_VIEW_UPDATE_ENTITIES = `${CONF_VIEW}.update_entities` as const; export const CONF_VIEW_UPDATE_SECONDS = `${CONF_VIEW}.update_seconds` as const; +export const CONF_VIEW_SCAN = `${CONF_VIEW}.scan` as const; +export const CONF_VIEW_SCAN_ENABLED = `${CONF_VIEW_SCAN}.enabled` as const; +export const CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS = + `${CONF_VIEW_SCAN}.show_trigger_status` as const; export const CONF_EVENT_GALLERY = 'event_gallery' as const; export const CONF_EVENT_GALLERY_CONTROLS_THUMBNAILS_SHOW_DETAILS = @@ -142,4 +152,4 @@ export const CONF_DIMENSIONS_ASPECT_RATIO_MODE = export const CONF_OVERRIDES = 'overrides' as const; // Taken from https://github.dev/home-assistant/frontend/blob/b5861869e39290fd2e15737e89571dfc543b3ad3/src/data/media-player.ts#L93 -export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072; \ No newline at end of file +export const MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA = 131072; diff --git a/src/editor.ts b/src/editor.ts index df36a05b..f0bf24b7 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -21,6 +21,9 @@ import { CONF_CAMERAS_ARRAY_LABEL, CONF_CAMERAS_ARRAY_LIVE_PROVIDER, CONF_CAMERAS_ARRAY_TITLE, + CONF_CAMERAS_ARRAY_TRIGGER_BY_ENTITIES, + CONF_CAMERAS_ARRAY_TRIGGER_BY_MOTION, + CONF_CAMERAS_ARRAY_TRIGGER_BY_OCCUPANCY, CONF_CAMERAS_ARRAY_URL, CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY, CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, @@ -81,6 +84,9 @@ import { CONF_VIEW_CAMERA_SELECT, CONF_VIEW_DARK_MODE, CONF_VIEW_DEFAULT, + CONF_VIEW_SCAN, + CONF_VIEW_SCAN_ENABLED, + CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS, CONF_VIEW_TIMEOUT_SECONDS, CONF_VIEW_UPDATE_CYCLE_CAMERA, CONF_VIEW_UPDATE_FORCE, @@ -99,11 +105,12 @@ import { } from './types.js'; import { arrayMove } from './utils/basic.js'; import { getCameraID, getCameraTitle } from './utils/camera.js'; -import { sideLoadHomeAssistantElements } from './utils/ha'; +import { getEntitiesFromHASS, sideLoadHomeAssistantElements } from './utils/ha'; const MENU_BUTTONS = 'buttons'; const MENU_CAMERAS = 'cameras'; const MENU_OPTIONS = 'options'; +const MENU_VIEW_SCAN = 'scan'; interface EditorOptionsSet { icon: string; @@ -398,20 +405,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor } } - protected _getEntities(domain: string): string[] { - if (!this.hass) { - return []; - } - const entities = Object.keys(this.hass.states).filter( - (eid) => eid.substr(0, eid.indexOf('.')) === domain, - ); - entities.sort(); - - // Add a blank entry to unset a selection. - entities.unshift(''); - return entities; - } - /** * Render an option set header * @param optionSetName The name of the EditorOptionsSet. @@ -601,6 +594,38 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ); } + protected _renderViewScanMenu(): TemplateResult { + return html` + + ${this._expandedMenus[MENU_VIEW_SCAN] + ? html`
+ ${this._renderSwitch( + CONF_VIEW_SCAN_ENABLED, + frigateCardConfigDefaults.view.scan.enabled ?? true, + { + label: localize(`config.${CONF_VIEW_SCAN_ENABLED}`), + }, + )} + ${this._renderSwitch( + CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS, + frigateCardConfigDefaults.view.scan.show_trigger_status ?? true, + { + label: localize(`config.${CONF_VIEW_SCAN_SHOW_TRIGGER_STATUS}`), + }, + )} +
` + : ''} + `; + } + /** * Render an editor menu for the card menu buttons. * @param button The name of the button. @@ -700,6 +725,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor protected _renderCamera( cameras: RawFrigateCardConfigArray, cameraIndex: number, + entities: string[], addNewCamera?: boolean, ): TemplateResult | void { const liveProviders: EditorSelectOption[] = [ @@ -848,6 +874,21 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor multiple: true, }, )} + ${this._renderSwitch( + getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGER_BY_OCCUPANCY, cameraIndex), + frigateCardConfigDefaults.cameras.trigger_by_occupancy, + )} + ${this._renderSwitch( + getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGER_BY_MOTION, cameraIndex), + frigateCardConfigDefaults.cameras.trigger_by_motion, + )} + ${this._renderOptionSelector( + getArrayConfigPath(CONF_CAMERAS_ARRAY_TRIGGER_BY_ENTITIES, cameraIndex), + entities, + { + multiple: true, + }, + )}
` : ``} `; @@ -935,7 +976,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor } const defaults = frigateCardConfigDefaults; - + const entities = getEntitiesFromHASS(this.hass); const cameras = (getConfigValue(this._config, CONF_CAMERAS) || []) as RawFrigateCardConfigArray; @@ -964,8 +1005,8 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderOptionSetHeader('cameras')} ${this._expandedMenus[MENU_OPTIONS] === 'cameras' ? html` ` : ''} ${this._renderOptionSetHeader('view')} @@ -985,6 +1026,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor CONF_VIEW_UPDATE_CYCLE_CAMERA, defaults.view.update_cycle_camera, )} + ${this._renderViewScanMenu()} ` : ''} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 20d1b7bd..4f7dd1a5 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -27,6 +27,9 @@ "frigate-jsmpeg": "Frigate JSMpeg", "webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)" }, + "trigger_by_entities": "Trigger from other entities", + "trigger_by_motion": "Trigger by auto-detecting the motion sensor", + "trigger_by_occupancy": "Trigger by auto-detecting the occupancy sensor", "webrtc_card": { "entity": "WebRTC Card Camera Entity (Not a Frigate camera)", "url": "WebRTC Card Camera URL" @@ -65,7 +68,12 @@ "timeout_seconds": "Reset to default view X seconds after user action (0=never)", "update_cycle_camera": "Cycle through cameras when default view updates", "update_force": "Force card updates (ignore user interaction)", - "update_seconds": "Refresh default view every X seconds (0=never)" + "update_seconds": "Refresh default view every X seconds (0=never)", + "scan": { + "scan_mode": "Scan mode", + "enabled": "Scan mode enabled", + "show_trigger_status": "Show pulsing border when triggered" + } }, "event_gallery": { "controls": { diff --git a/src/scss/card.scss b/src/scss/card.scss index 955b25c0..f3ccbbdd 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -9,48 +9,48 @@ filter: brightness(50%); } -.container { +div.main { position: relative; overflow: auto; width: 100%; height: 100%; margin: auto; display: flex; + align-items: center; justify-content: center; // Need to apply the border radius on the container level, as the ha-card has // overflow visible in order to allow a submenu to extend beyond the card // boundary. border-radius: var(--ha-card-border-radius, 4px); -} -.frigate-card-contents { - width: inherit; - height: inherit; - margin: auto; - overflow: auto; - display: flex; - align-items: center; - justify-content: center; + // Include the borders in the sizing (to keep the triggered warning pulse + // visible in fullscreen). + box-sizing: border-box; // Hide scrollbar: Firefox scrollbar-width: none; // Hide scrollbar: IE and Edge -ms-overflow-style: none; } - -/* Hide scrollbar for Chrome, Safari and Opera */ -.frigate-card-contents::-webkit-scrollbar { - display: none; +div.main.triggered { + @keyframes warning-pulse { + 0% { + border: solid 1px rgba(0,0,0,0); + } + 50% { + border: solid 1px var(--warning-color); + } + 100% { + border: solid 1px rgba(0,0,0,0); + } + } + animation: warning-pulse 5s infinite; } -/* When forcing aspect ratio absolute positioning is required */ -.frigate-card-contents.absolute { - position: absolute; - top: 0px; - right: 0px; - bottom: 0px; - left: 0px; +/* Hide scrollbar for Chrome, Safari and Opera */ +div.main::-webkit-scrollbar { + display: none; } /* The 'hover' menu mode is styling applied outside of the menu itself */ diff --git a/src/scss/editor.scss b/src/scss/editor.scss index c965f9a9..5ddfa307 100644 --- a/src/scss/editor.scss +++ b/src/scss/editor.scss @@ -42,7 +42,7 @@ div.upgrade span { .submenu-header { display: flex; - margin-top: 4px; + margin-top: 10px; cursor: pointer; } diff --git a/src/types.ts b/src/types.ts index 704b0e44..d01a00e1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -364,6 +364,9 @@ const customSchema = z export const cameraConfigDefault = { client_id: 'frigate' as const, live_provider: 'auto' as const, + trigger_by_motion: false, + trigger_by_occupancy: true, + trigger_by_entities: [], }; const webrtcCardCameraConfigSchema = z.object({ entity: z.string().optional(), @@ -394,6 +397,10 @@ const cameraConfigSchema = z // Set of cameras IDs upon which this camera depends. dependent_cameras: z.string().array().optional(), + + trigger_by_motion: z.boolean().default(cameraConfigDefault.trigger_by_motion), + trigger_by_occupancy: z.boolean().default(cameraConfigDefault.trigger_by_occupancy), + trigger_by_entities: z.string().array().default(cameraConfigDefault.trigger_by_entities), }) .default(cameraConfigDefault); export type CameraConfig = z.infer; @@ -498,6 +505,10 @@ const viewConfigDefault = { update_force: false, update_cycle_camera: false, dark_mode: 'off' as const, + scan: { + enabled: false, + show_trigger_status: true, + } }; const viewConfigSchema = z .object({ @@ -514,6 +525,10 @@ const viewConfigSchema = z update_entities: z.string().array().optional(), render_entities: z.string().array().optional(), dark_mode: z.enum(['on', 'off', 'auto']).optional(), + scan: z.object({ + enabled: z.boolean().default(viewConfigDefault.scan.enabled), + show_trigger_status: z.boolean().default(viewConfigDefault.scan.show_trigger_status), + }).default(viewConfigDefault.scan) }) .merge(actionsSchema) .default(viewConfigDefault); @@ -1221,7 +1236,7 @@ export const frigateBrowseMediaSourceSchema: z.ZodSchema = z. event: z.object({ camera: z.string(), end_time: z.number().nullable(), - false_positive: z.boolean(), + false_positive: z.boolean().nullable(), has_clip: z.boolean(), has_snapshot: z.boolean(), id: z.string(), @@ -1249,8 +1264,18 @@ export const signedPathSchema = z.object({ export type SignedPath = z.infer; export const entitySchema = z.object({ + config_entry_id: z.string().nullable(), + disabled_by: z.string().nullable(), entity_id: z.string(), - unique_id: z.string(), platform: z.string(), }); export type Entity = z.infer; + +export const extendedEntitySchema = entitySchema.extend({ + // Extended entity results. + unique_id: z.string().optional(), +}) +export type ExtendedEntity = z.infer; + +export const entityListSchema = entitySchema.array(); +export type EntityList = z.infer; diff --git a/src/utils/ha/entity-registry.ts b/src/utils/ha/entity-registry.ts new file mode 100644 index 00000000..6567e66f --- /dev/null +++ b/src/utils/ha/entity-registry.ts @@ -0,0 +1,109 @@ +import { HomeAssistant } from 'custom-card-helpers'; +import { homeAssistantWSRequest } from '.'; +import { + Entity, + EntityList, + entityListSchema, + ExtendedEntity, + extendedEntitySchema +} from '../../types.js'; + +export class ExtendedEntityCache { + protected _cache: Map = new Map(); + + /** + * Determine if the cache has a given entity_id. + * @param id + * @returns `true` if the id is in the cache, `false` otherwise. + */ + public has(id: string): boolean { + return this._cache.has(id); + } + + /** + * Get the first value that returns true for the given predicate. + * @param func A callback function that returns a boolean. + * @returns The first matching value. + */ + public getMatch(func: (arg: ExtendedEntity) => boolean): ExtendedEntity | null { + return [...this._cache.values()].find(func) ?? null; + } + + /** + * Get entity information given an id. + * @param id The entity id. + * @returns The `ExtendedEntity` for this id. + */ + public get(id: string): ExtendedEntity | undefined { + return this._cache.get(id); + } + + /** + * Add a given ExtendedEntity to the cache. + * @param extendedEntity + */ + public set(extendedEntity: ExtendedEntity): void { + this._cache.set(extendedEntity.entity_id, extendedEntity); + } +} + +/** + * Get the extended entity information for an entity. May throw. + * @param hass The Home Assistant object. + * @param entity The entity id. + * @param cache An optional ExtendedEntityCache. + * @returns The ExtendedEntity information. + */ +export const getExtendedEntity = async ( + hass: HomeAssistant, + entity: string, + cache?: ExtendedEntityCache, +): Promise => { + const cachedValue = cache ? cache.get(entity) : undefined; + if (cachedValue) { + return cachedValue; + } + const result = await homeAssistantWSRequest( + hass, + extendedEntitySchema, + { + type: 'config/entity_registry/get', + entity_id: entity, + }, + ); + if (cache) { + cache.set(result); + } + return result; +}; + +/** + * Get the extended entity information for an array of entities. + * @param hass The Home Assistant object. + * @param entities An array of entity ids. + * @param cache An optional ExtendedEntityCache. + * @returns A map of entity id to ExtendedEntity objects. + */ +export const getExtendedEntities = async ( + hass: HomeAssistant, + entities: string[], + cache?: ExtendedEntityCache, +): Promise> => { + const output: Map = new Map(); + const _storeExtendedEntity = async (entity: string): Promise => { + output.set(entity, await getExtendedEntity(hass, entity, cache)); + }; + await Promise.all(entities.map(_storeExtendedEntity)); + return output; +}; + +/** + * Get a list of all entities from the entity registry. + * @param hass The Home Assistant object. + * @returns An entity list object. + */ +export const getAllEntities = async (hass: HomeAssistant): Promise => { + return await homeAssistantWSRequest(hass, entityListSchema, { + type: 'config/entity_registry/list', + }); +}; diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts index ebc3d5a7..db7b3448 100644 --- a/src/utils/ha/index.ts +++ b/src/utils/ha/index.ts @@ -4,11 +4,10 @@ import { StyleInfo } from 'lit/directives/style-map.js'; import { ZodSchema } from 'zod'; import { localize } from '../../localize/localize.js'; import { - CardHelpers, - ExtendedHomeAssistant, - SignedPath, - signedPathSchema, - StateParameters + CardHelpers, ExtendedHomeAssistant, + SignedPath, + signedPathSchema, + StateParameters } from '../../types.js'; import { stateIcon } from '../icons/state-icon.js'; import { getParseErrorKeys } from '../zod.js'; @@ -75,32 +74,75 @@ export async function homeAssistantSignPath( return hass.hassUrl(response.path); } +interface HassStateDifference { + entity: string; + oldState?: HassEntity; + newState: HassEntity; +} + /** - * Determine whether the card should be updated based on Home Assistant changes. + * Get the difference between two hass objects. * @param newHass The new HA object. * @param oldHass The old HA object. * @param entities The entities to examine for changes. - * @returns A boolean indicating whether or not to allow an update. + * @param options An options object. stateOnly: whether or not to compare state + * strings only, firstOnly: whether or not to get the first difference only. + * @returns An array of HassStateDifference objects. */ -export function shouldUpdateBasedOnHass( +export function getHassDifferences( newHass: HomeAssistant | undefined | null, oldHass: HomeAssistant | undefined | null, entities: string[] | null, -): boolean { + options?: { + firstOnly?: boolean; + stateOnly?: boolean; + }, +): HassStateDifference[] { if (!newHass || !entities || !entities.length) { - return false; - } - if (!oldHass) { - return true; + return []; } - for (let i = 0; i < entities.length; i++) { - const entity = entities[i]; - if (entity && oldHass.states[entity] !== newHass.states[entity]) { - return true; + const differences: HassStateDifference[] = []; + for (const entity of entities) { + const oldState: HassEntity | undefined = oldHass?.states[entity]; + const newState: HassEntity | undefined = newHass.states[entity]; + if ( + (options?.stateOnly && oldState?.state !== newState?.state) || + (!options?.stateOnly && oldState !== newState) + ) { + differences.push({ + entity: entity, + oldState: oldState, + newState: newState, + }); + if (options?.firstOnly) { + break; + } } } - return false; + return differences; +} + +/** + * Determine if two hass objects are different for a list of entities. + * @param newHass The new HA object. + * @param oldHass The old HA object. + * @param entities The entities to examine for changes. + * @param options An options object. stateOnly: whether or not to compare state strings only. + * @returns An array of HassStateDifference objects. + */ +export function isHassDifferent( + newHass: HomeAssistant | undefined | null, + oldHass: HomeAssistant | undefined | null, + entities: string[] | null, + options?: { + stateOnly?: boolean; + }, +): boolean { + return !!getHassDifferences(newHass, oldHass, entities, { + ...options, + firstOnly: true, + }).length; } /** @@ -254,3 +296,29 @@ export const sideLoadHomeAssistantElements = async (): Promise => { } return false; }; + +/** + * Determine if a given state qualifies as 'triggered'. + * @param state The HASSEntity. + * @returns `true` if triggered, `false` otherwise. + */ +export const isTriggeredState = (state?: HassEntity): boolean => { + return !!state && ['on', 'open'].includes(state.state); +}; + +/** + * Get entities from the HASS object. + * @param hass + * @param domain + * @returns + */ +export const getEntitiesFromHASS = (hass: HomeAssistant, domain?: string): string[] => { + if (!hass) { + return []; + } + const entities = Object.keys(hass.states).filter( + (eid) => !domain || eid.substr(0, eid.indexOf('.')) === domain, + ); + entities.sort(); + return entities; +} diff --git a/src/utils/resolved-media.ts b/src/utils/ha/resolved-media.ts similarity index 67% rename from src/utils/resolved-media.ts rename to src/utils/ha/resolved-media.ts index 3ce77436..e816df52 100644 --- a/src/utils/resolved-media.ts +++ b/src/utils/ha/resolved-media.ts @@ -1,11 +1,11 @@ import { HomeAssistant } from 'custom-card-helpers'; import QuickLRU from 'quick-lru'; +import { homeAssistantWSRequest } from '.'; import { - FrigateBrowseMediaSource, - ResolvedMedia, - resolvedMediaSchema -} from '../types.js'; -import { homeAssistantWSRequest } from './ha'; + FrigateBrowseMediaSource, + ResolvedMedia, + resolvedMediaSchema +} from '../../types.js'; // It's important the cache size be at least as large as the largest likely // media query or media items will from a given query will be evicted for other @@ -21,19 +21,42 @@ export class ResolvedMediaCache { this._cache = new QuickLRU({ maxSize: RESOLVED_MEDIA_CACHE_SIZE }); } + /** + * Determine if the cache has a given id. + * @param id + * @returns `true` if the id is in the cache, `false` otherwise. + */ public has(id: string): boolean { return this._cache.has(id); } + + /** + * Get resolved media information given an id. + * @param id The id. + * @returns The `ResolvedMedia` for this id. + */ public get(id: string): ResolvedMedia | undefined { return this._cache.get(id); } + /** + * Add a given ResolvedMedia to the cache. + * @param id The id for the object. + * @param resolvedMedia The `ResolvedMedia` object. + */ public set(id: string, resolvedMedia: ResolvedMedia): void { this._cache.set(id, resolvedMedia); } } +/** + * Resolve a given media source item. + * @param hass The Home Assistant object. + * @param mediaSource The media source object. + * @param cache An optional ResolvedMediaCache object. + * @returns + */ export const resolveMedia = async ( hass: HomeAssistant, mediaSource?: FrigateBrowseMediaSource,