diff --git a/README.md b/README.md index f46766d8..56cd4134 100644 --- a/README.md +++ b/README.md @@ -381,7 +381,8 @@ image: | Option | Default | Overridable | Description | | - | - | - | - | -| `src` | | :white_check_mark: | [embedded image](https://www.flickr.com/photos/dianasch/47543120431) | A static image URL for use with the `image` [view](#views). Note that a `t=[timestsamp]` query parameter will be automatically added to this URL such that the image will not be cached by the browser. | +| `mode` | `url` | :white_check_mark: | Mode of the the `image` [view](#views). Value must be one of `url` (to fetch an arbitrary image URL), `camera` (to show a still of the currently selected camera using either `camera_entity` or `webrtc_card.entity` in that order of precedence), or `screensaver` (to show an [embedded stock Frigate card logo](https://github.com/dermotduffy/frigate-hass-card/blob/main/src/images/frigate-bird-in-sky.jpg)). In either `url` or `camera` mode, the `screensaver` content is used as a fallback if a URL is not specified or cannot be derived. | +| `url` | | :white_check_mark: | A static image URL to be used when the `mode` is set to `url`. Note that a `_t=[timestsamp]` query parameter will be automatically added to all URLs such that the image will not be cached by the browser.| | `refresh_seconds` | 0 | :white_check_mark: | The number of seconds after which to refresh the image. `0` implies no refreshing. | | `actions` | | :white_check_mark: | Actions to use for the `image` view. See [actions](#actions) below.| diff --git a/src/card.ts b/src/card.ts index d310a92f..2f6e3919 100644 --- a/src/card.ts +++ b/src/card.ts @@ -35,6 +35,7 @@ import type { import { CARD_VERSION, REPO_URL } from './const.js'; import { FrigateCardElements } from './components/elements.js'; +import { FrigateCardImage } from './components/image.js'; import { FRIGATE_BUTTON_MENU_ICON, FrigateCardMenu } from './components/menu.js'; import { View } from './view.js'; import { @@ -153,6 +154,9 @@ export class FrigateCard extends LitElement { @query('frigate-card-elements') protected _elements?: FrigateCardElements; + @query('frigate-card-image') + protected _image?: FrigateCardImage; + // user interaction timer ("screensaver" functionality, return to default // view after user interaction). protected _interactionTimerID: number | null = null; @@ -181,8 +185,8 @@ export class FrigateCard extends LitElement { set hass(hass: HomeAssistant & ExtendedHomeAssistant) { this._hass = hass; - // Manually set hass in the menu & elements. This is to allow these to - // update, without necessarily re-rendering the entire card (re-rendering + // Manually set hass in the menu, elements and image. This is to allow these + // to update, without necessarily re-rendering the entire card (re-rendering // interrupts clip playing). if (this._hass) { if (this._menu) { @@ -191,6 +195,9 @@ export class FrigateCard extends LitElement { if (this._elements) { this._elements.hass = this._hass; } + if (this._image) { + this._image.hass = this._hass; + } } } @@ -1210,6 +1217,8 @@ export class FrigateCard extends LitElement { ? html` ` : ``} diff --git a/src/components/image.ts b/src/components/image.ts index 8ca1f816..81d10925 100644 --- a/src/components/image.ts +++ b/src/components/image.ts @@ -7,16 +7,16 @@ import { ReactiveController, ReactiveControllerHost, } from 'lit'; +import { HomeAssistant } from 'custom-card-helpers'; import { customElement, property, state } from 'lit/decorators.js'; -import { ImageViewConfig } from '../types.js'; +import { CameraConfig, ImageViewConfig } from '../types.js'; import { View } from '../view.js'; import { dispatchMediaShowEvent } from '../common.js'; import defaultImage from '../images/frigate-bird-in-sky.jpg'; import imageStyle from '../scss/image.scss'; - export class CachedValueController implements ReactiveController { public value?: T; @@ -59,6 +59,20 @@ export class CachedValueController implements ReactiveController { @customElement('frigate-card-image') export class FrigateCardImage extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + protected view?: Readonly; + + @property({ attribute: false }) + protected cameraConfig?: CameraConfig; + + @state() + protected _imageConfig?: ImageViewConfig; + + protected _cachedValueController?: CachedValueController; + set imageConfig(imageConfig: ImageViewConfig) { this._imageConfig = imageConfig; if (this._cachedValueController) { @@ -70,19 +84,30 @@ export class FrigateCardImage extends LitElement { this._getImageSource.bind(this), ); } - @state() - protected _imageConfig?: ImageViewConfig; - // A new view should trigger an image re-render. - @property({ attribute: false }) - protected view?: Readonly; - protected _cachedValueController?: CachedValueController; + /** + * Build a working absolute image URL that the browser will not cache. + * @param url An input URL (may be relative to document origin) + * @returns A new URL (absolute, will not be browser cached). + */ + protected _buildImageURL(url: string): string { + const urlObj = new URL(url, document.baseURI); + urlObj.searchParams.append('_t', String(Date.now())); + return urlObj.toString(); + } protected _getImageSource(): string { - if (this._imageConfig?.src) { - const url = new URL(this._imageConfig.src); - url.searchParams.append('t', String(Date.now())); - return url.toString(); + if (this._imageConfig?.mode === 'url' && this._imageConfig?.url) { + return this._buildImageURL(this._imageConfig.url); + } else if (this.hass && this._imageConfig?.mode === 'camera') { + const entity = + this.cameraConfig?.camera_entity || this.cameraConfig?.webrtc_card?.entity; + if (entity) { + const state = this.hass.states[entity]; + if (state && state.attributes.entity_picture) { + return this._buildImageURL(state.attributes.entity_picture); + } + } } return defaultImage; } diff --git a/src/config-mgmt.ts b/src/config-mgmt.ts index 5328a22b..e2aec88b 100644 --- a/src/config-mgmt.ts +++ b/src/config-mgmt.ts @@ -11,7 +11,7 @@ import { CONF_EVENT_VIEWER_AUTO_PLAY, CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_SIZE, CONF_EVENT_VIEWER_CONTROLS_NEXT_PREVIOUS_STYLE, - CONF_IMAGE_SRC, + CONF_IMAGE_URL, CONF_LIVE_PRELOAD, CONF_LIVE_WEBRTC_CARD, CONF_MENU, @@ -317,7 +317,7 @@ const upgradeToMultipleCameras = (): ((obj: RawFrigateCardConfig) => boolean) => * @param key A string key. * @returns A safe key. */ -const updateMenuConditionToMenuOverride = (): (( +const upgradeMenuConditionToMenuOverride = (): (( obj: RawFrigateCardConfig, ) => boolean) => { return function (obj: RawFrigateCardConfig): boolean { @@ -363,14 +363,14 @@ const UPGRADES = [ upgradeMoveTo('menu_mode', CONF_MENU_MODE), upgradeMoveTo('menu_buttons', 'menu.buttons'), upgradeMoveTo('menu_button_size', CONF_MENU_BUTTON_SIZE), - upgradeMoveTo('image', CONF_IMAGE_SRC, isNotObject), + upgradeMoveTo('image', 'image.src', isNotObject), // v2.0.0 -> v2.1.0 upgradeMoveTo('update_entities', CONF_VIEW_UPDATE_ENTITIES), // v2.1.0 -> v3.0.0-rc.1 upgradeToMultipleCameras(), - updateMenuConditionToMenuOverride(), + upgradeMenuConditionToMenuOverride(), upgradeMoveTo('view.timeout', CONF_VIEW_TIMEOUT_SECONDS, toNumberOrIgnore), upgradeMoveTo('event_viewer.autoplay_clip', CONF_EVENT_VIEWER_AUTO_PLAY), @@ -383,4 +383,5 @@ const UPGRADES = [ ), upgradeArrayValue(CONF_CAMERAS, upgradeMoveTo('webrtc', 'webrtc_card')), upgradeMoveToWithOverrides('live.webrtc', CONF_LIVE_WEBRTC_CARD), + upgradeMoveToWithOverrides('image.src', CONF_IMAGE_URL), ]; diff --git a/src/const.ts b/src/const.ts index 0da15d51..f5a998ae 100644 --- a/src/const.ts +++ b/src/const.ts @@ -76,8 +76,9 @@ export const CONF_LIVE_TRANSITION_EFFECT = `${CONF_LIVE}.transition_effect` as c export const CONF_LIVE_WEBRTC_CARD = `${CONF_LIVE}.webrtc_card` as const; export const CONF_IMAGE = 'image' as const; +export const CONF_IMAGE_MODE = `${CONF_IMAGE}.mode` as const; export const CONF_IMAGE_REFRESH_SECONDS = `${CONF_IMAGE}.refresh_seconds` as const; -export const CONF_IMAGE_SRC = `${CONF_IMAGE}.src` as const; +export const CONF_IMAGE_URL = `${CONF_IMAGE}.url` as const; export const CONF_MENU = 'menu' as const; export const CONF_MENU_BUTTONS_FRIGATE = `${CONF_MENU}.buttons.frigate` as const; diff --git a/src/editor.ts b/src/editor.ts index 90f0e9d6..3b6b02e6 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -39,8 +39,9 @@ import { CONF_EVENT_VIEWER_DRAGGABLE, CONF_EVENT_VIEWER_LAZY_LOAD, CONF_EVENT_VIEWER_TRANSITION_EFFECT, + CONF_IMAGE_MODE, CONF_IMAGE_REFRESH_SECONDS, - CONF_IMAGE_SRC, + CONF_IMAGE_URL, CONF_LIVE_AUTO_UNMUTE, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_SIZE, CONF_LIVE_CONTROLS_NEXT_PREVIOUS_STYLE, @@ -274,6 +275,13 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ['slide', localize('config.event_viewer.transition_effects.slide')], ]); + protected _imageModes = new Map([ + ['', ''], + ['camera', localize('config.image.modes.camera')], + ['screensaver', localize('config.image.modes.screensaver')], + ['url', localize('config.image.modes.url')], + ]); + public setConfig(config: RawFrigateCardConfig): void { // Note: This does not use Zod to parse the configuration, so it may be // partially or completely invalid. It's more useful to have a partially @@ -882,7 +890,11 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor ${this._renderOptionSetHeader('image')} ${options.image.show ? html`
- ${this._renderStringInput(CONF_IMAGE_SRC)} + ${this._renderDropdown( + CONF_IMAGE_MODE, + this._imageModes, + )} + ${this._renderStringInput(CONF_IMAGE_URL)} ${this._renderStringInput(CONF_IMAGE_REFRESH_SECONDS, 'number')}
` : ''} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index ca94e5c9..36e3b3f0 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -127,7 +127,13 @@ } }, "image": { - "src": "Static image URL/data for image view", + "mode": "Image view mode", + "modes": { + "screensaver": "Embedded Frigate logo", + "url": "Arbitrary image specified by URL", + "camera": "Home Assistant camera snapshot of camera entity" + }, + "url": "Static image URL for image view", "refresh_seconds": "Number of seconds after which to refresh (0=never)" }, "menu": { diff --git a/src/types.ts b/src/types.ts index c051ea79..1aea91e3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -404,12 +404,19 @@ const viewConfigSchema = z * Image view configuration section. */ +export const IMAGE_MODES = [ + 'screensaver', + 'camera', + 'url', +] as const; const imageConfigDefault = { + mode: 'url' as const, refresh_seconds: 0, }; const imageConfigSchema = z .object({ - src: z.string().optional(), + mode: z.enum(IMAGE_MODES).default(imageConfigDefault.mode), + url: z.string().optional(), refresh_seconds: z.number().min(0).default(imageConfigDefault.refresh_seconds), }) .merge(actionsSchema)