diff --git a/README.md b/README.md index 482dd2fe..3f9f3e44 100644 --- a/README.md +++ b/README.md @@ -134,12 +134,15 @@ dimensions: aspect_ratio: '4:3' ``` + + ### Advanced Options | Option | Default | Description | | ------------- | - | --------------------------------------------- | | `label` | | A label used to filter events (clips & snapshots), e.g. 'person'.| | `zone` | | A zone used to filter events (clips & snapshots), e.g. 'front_door'.| +| `update_entities` | | A list of entity ids that should cause the whole card to re-render, this can be useful in the `clip` or `snapshot` mode to (for example) cause a motion sensor to trigger a card refresh. Configurable in YAML only. Entities used in picture elements / included in the menu do not need to be explicitly included here to be kept updated. | @@ -189,20 +192,6 @@ webrtc: See [WebRTC configuration](https://github.com/AlexxIT/WebRTC#configuration) for full configuration options. - - -### Entities - -Additional entities may be configured to trigger updates to the card, and -optionally to appear in the menu. An `entities` section may be added to the card -configuration containing a list with entries of the following format: - -| Option | Default | Description | -| ------------- | - | -------------------------------------------- | -| `entity` | | Entity ID to use to trigger updates, and optionally appear in the menu. | -| `icon` | [default entity icon] | An optional manual override of the icon to use in the menu, e.g. `mdi:car`. | -| `show`| `true` | Whether or not to show the entity in the menu. When `false` the entity ID will trigger card updates only, but not appear in the menu. | - #### Example This example allows access to the detection, recordings and snapshots switches @@ -218,6 +207,202 @@ entities: show: false ``` +## Picture Elements / Menu customizations + +This card supports the [Picture Elements configuration +syntax](https://www.home-assistant.io/lovelace/picture-elements/) to seamlessly +allow the user to add custom elements to the card, which may be configured to +perform a variety of actions on `tap`, `double_tap` and `hold`. + +In the card YAML configuration, elements may be manually added under an +`elements` key. + +See the [action +documentation](https://www.home-assistant.io/lovelace/actions/#hold-action) for +more information on the action options available. + +### Special Elements + +This card supports all [Picture Elements](https://www.home-assistant.io/lovelace/picture-elements/#icon-element) using the same syntax. The card also supports a handful of custom special elements to add special Frigate card functionality. + +| Element name | Description | +| ------------- | --------------------------------------------- | +| `custom:frigate-card-menu-icon` | Add an arbitrary icon to the Frigate Card menu. Configuration is ~identical to that of the [Picture Elements Icon](https://www.home-assistant.io/lovelace/picture-elements/#icon-element) except with a type name of `custom:frigate-card-menu-icon`.| +| `custom:frigate-card-menu-state-icon` | Add a state icon to the Frigate Card menu that represents the state of a Home Assistant entity. Configuration is ~identical to that of the [Picture Elements State Icon](https://www.home-assistant.io/lovelace/picture-elements/#state-icon) except with a type name of `custom:frigate-card-menu-state-icon`.| +| `custom:frigate-card-conditional` | Restrict a set of elements to only render when the card is showing particular a particular [view](#views). See [configuration below](#frigate-card-conditional).| + + + +### `custom:frigate-card-conditional` + +Parameters for the `custom:frigate-card-conditional` element: + +| Parameter | Description | +| ------------- | --------------------------------------------- | +| `type` | Must be `custom:frigate-card-conditional`. | +| `conditions` | A set of conditions that must evaluate to true in order for the elements to be rendered. | +| `conditions.view` | A list of [views](#views) in which these elements should be rendered. | +| `elements` | The elements to render. Can be any supported element, include additional condition or custom elements. | + +See the [PTZ example below](#frigate-card-conditional-example) for a real-world example. +### Elements Examples + +#### Menu icons + +You can add custom icons to the menu with arbitrary actions. + +
+ Expand: Custom menu icon + +This example adds an icon that navigates the brower to the releases page for this +card: + +```yaml + - type: custom:frigate-card-menu-icon + icon: mdi:book + tap_action: + action: url + url_path: https://github.com/dermotduffy/frigate-hass-card/releases +``` +
+ +#### Menu state icons + +You can add custom state icons to the menu to show the state of an entity and complete arbitrary actions. + +
+ Expand: Custom menu state icon + +This example adds an icon that represents the state of the +`light.office_main_lights` entity, that toggles the light on double click. + +```yaml +elements: + - type: custom:frigate-card-menu-state-icon + entity: light.office_main_lights + tap_action: + action: toggle +``` +
+ +#### State badges + +You can adds a state badge to the card showing arbitrary entity states. + +
+ Expand: State badge + +This example adds a state badge showing the temperature and hides the label text: + +```yaml + - type: state-badge + entity: sensor.kitchen_temperature + style: + right: '-20px' + top: 100px + color: rgba(0,0,0,0) + opacity: 0.5 +``` + +Picture elements temperature example +
+ +#### Conditional menu icons + +You can have icons conditionally added to the menu based on entity state. + +
+ Expand: Conditional menu icons + +This example only adds the light entity to the menu if a light is on. + +```yaml + - type: conditional + conditions: + - entity: light.kitchen + state: 'on' + elements: + - type: custom:frigate-card-menu-state-icon + entity: light.kitchen + tap_action: + action: toggle +``` +
+ + + +#### Restricting icons to certain views + +You can restrict icons to only show for certain [views](#views) using a +`custom:frigate-card-conditional` element (e.g. PTZ controls) + +
+ Expand: View-based conditions (e.g. PTZ controls) + +This example shows PTZ icons that call a PTZ service, but only in the `live` view. + +```yaml +elements: + - type: custom:frigate-card-conditional + conditions: + view: + - live + elements: + - type: icon + icon: mdi:arrow-up + style: + background: rgba(255, 255, 255, 0.25) + border-radius: 5px + right: 25px + bottom: 50px + tap_action: + action: call-service + service: amcrest.ptz_control + service_data: + entity_id: camera.kitchen + movement: up + - type: icon + icon: mdi:arrow-down + style: + background: rgba(255, 255, 255, 0.25) + border-radius: 5px + right: 25px + bottom: 0px + tap_action: + action: call-service + service: amcrest.ptz_control + service_data: + entity_id: camera.kitchen + movement: down + - type: icon + icon: mdi:arrow-left + style: + background: rgba(255, 255, 255, 0.25) + border-radius: 5px + right: 50px + bottom: 25px + tap_action: + action: call-service + service: amcrest.ptz_control + service_data: + entity_id: camera.kitchen + movement: left + - type: icon + icon: mdi:arrow-right + style: + background: rgba(255, 255, 255, 0.25) + border-radius: 5px + right: 0px + bottom: 25px + tap_action: + action: call-service + service: amcrest.ptz_control + service_data: + entity_id: camera.kitchen + movement: right +``` +
+ ## Views @@ -235,18 +420,18 @@ This card supports several different views. ### Automatic updates in the `clip` or `snapshot` view Updates will occur whenever on every change of the state of the `camera_entity` -or any entity configured under `entities`. In particular, if the desire is +or any entity configured under `update_entities`. In particular, if the desire is to have an auto-refreshing view of the most recent event, the `camera_entity` will not be sufficient alone since the Home Assistant state for Frigate camera entities does not change often. Instead, use the Frigate binary_sensor for that camera (or any other entity at your discretion) to trigger the update: ```yaml -entities: - - entity: binary_sensor.office_person_motion +update_entities: + - binary_sensor.office_person_motion ``` -See [entities](#entities) above. +See the [advanced options](#advanced-options) above. ### Getting from a snapshot to a clip diff --git a/images/picture_elements_temperature.png b/images/picture_elements_temperature.png new file mode 100644 index 00000000..8b4f3370 Binary files /dev/null and b/images/picture_elements_temperature.png differ diff --git a/package.json b/package.json index 94bd4a2a..437e41a7 100644 --- a/package.json +++ b/package.json @@ -16,28 +16,28 @@ "dependencies": { "@cycjimmy/jsmpeg-player": "^5.0.1", "@material/image-list": "^12.0.0", - "custom-card-helpers": "^1.7.2", - "dayjs": "^1.10.6", + "custom-card-helpers": "^1.8.0", + "dayjs": "^1.10.7", "home-assistant-js-websocket": "^5.11.1", - "lit": "^2.0.0-rc.2", + "lit": "^2.0.0", "screenfull": "^5.1.0", - "zod": "^3.8.2" + "zod": "^3.9.5" }, "devDependencies": { "@babel/core": "^7.15.5", "@babel/plugin-proposal-class-properties": "^7.14.5", "@babel/plugin-proposal-decorators": "^7.15.4", "@rollup/plugin-json": "^4.1.0", - "@typescript-eslint/eslint-plugin": "^4.30.0", - "@typescript-eslint/parser": "^4.30.0", + "@typescript-eslint/eslint-plugin": "^4.32.0", + "@typescript-eslint/parser": "^4.32.0", "eslint": "^7.32.0", "eslint-config-airbnb-base": "^14.2.1", "eslint-config-prettier": "^8.3.0", "eslint-plugin-import": "^2.24.2", "eslint-plugin-prettier": "^3.4.1", "npm-check-updates": "^11.8.5", - "prettier": "^2.3.2", - "rollup": "^2.56.3", + "prettier": "^2.4.1", + "rollup": "^2.58.0", "rollup-plugin-babel": "^4.4.0", "rollup-plugin-commonjs": "^10.1.0", "rollup-plugin-node-resolve": "^5.2.0", @@ -45,8 +45,8 @@ "rollup-plugin-styles": "^3.14.1", "rollup-plugin-terser": "^7.0.2", "rollup-plugin-typescript2": "^0.30.0", - "sass": "^1.39.0", - "typescript": "^4.4.2" + "sass": "^1.42.1", + "typescript": "^4.4.3" }, "scripts": { "start": "rollup -c rollup.config.dev.js --watch", diff --git a/src/action-handler-directive.ts b/src/action-handler-directive.ts index 220b36bd..822a6a31 100644 --- a/src/action-handler-directive.ts +++ b/src/action-handler-directive.ts @@ -7,10 +7,8 @@ import { } from 'custom-card-helpers/dist/types'; import { fireEvent } from 'custom-card-helpers'; -const isTouch = - 'ontouchstart' in window || - navigator.maxTouchPoints > 0 || - navigator.msMaxTouchPoints > 0; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const isTouch = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || (navigator as any).msMaxTouchPoints > 0; interface ActionHandler extends HTMLElement { holdTime: number; diff --git a/src/card.ts b/src/card.ts index 932171d3..392a2c98 100644 --- a/src/card.ts +++ b/src/card.ts @@ -14,13 +14,12 @@ import { until } from 'lit/directives/until'; import { HomeAssistant, LovelaceCardEditor, - fireEvent, getLovelace, - stateIcon, + handleAction, } from 'custom-card-helpers'; import screenfull from 'screenfull'; -import { entitySchema, frigateCardConfigSchema } from './types'; +import { entitySchema, frigateCardConfigSchema, Message } from './types'; import type { BrowseMediaQueryParameters, Entity, @@ -33,11 +32,16 @@ import type { import { CARD_VERSION } from './const'; import { FrigateCardMenu, MENU_HEIGHT } from './components/menu'; import { View } from './view'; -import { getParseErrorKeys, homeAssistantWSRequest } from './common'; +import { + getParseErrorKeys, + homeAssistantWSRequest, + shouldUpdateBasedOnHass, +} from './common'; import { localize } from './localize/localize'; -import { renderErrorMessage, renderProgressIndicator } from './components/message'; +import { renderMessage, renderProgressIndicator } from './components/message'; import './editor'; +import './components/elements'; import './components/gallery'; import './components/live'; import './components/menu'; @@ -47,6 +51,7 @@ import './patches/ha-camera-stream'; import './patches/ha-hls-player'; import cardStyle from './scss/card.scss'; +import { FrigateCardElements } from './components/elements'; const MEDIA_HEIGHT_CUTOFF = 50; const MEDIA_WIDTH_CUTOFF = MEDIA_HEIGHT_CUTOFF; @@ -83,34 +88,6 @@ console.info( description: localize('common.frigate_card_description'), }); -// Determine whether the card should be updated based on Home Assistant changes. -function shouldUpdateBasedOnHass( - newHass: HomeAssistant | null, - oldHass: HomeAssistant | undefined, - entities: string[] | null, -): boolean { - if (!newHass || !entities) { - return false; - } - if (!entities.length) { - return false; - } - - if (oldHass) { - for (let i = 0; i < entities.length; i++) { - const entity = entities[i]; - if (!entity) { - continue; - } - if (oldHass.states[entity] !== newHass.states[entity]) { - return true; - } - } - return false; - } - return false; -} - // Main FrigateCard class. @customElement('frigate-card') export class FrigateCard extends LitElement { @@ -128,6 +105,9 @@ export class FrigateCard extends LitElement { @query('frigate-card-menu') _menu!: FrigateCardMenu; + @query('frigate-card-elements') + _elements!: FrigateCardElements; + // Whether or not media is actively playing (live or clip). protected _mediaPlaying = false; @@ -142,9 +122,23 @@ export class FrigateCard extends LitElement { // derived). protected _frigateCameraName: string | null = null; + // Error/info message to render. + protected _message: Message | null = null; + set hass(hass: HomeAssistant & ExtendedHomeAssistant) { this._hass = hass; - this._updateMenu(); + + // Manually set hass in the menu & elements. 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) { + this._menu.hass = this._hass; + } + if (this._elements) { + this._elements.hass = this._hass; + } + } } // Get the configuration element. @@ -157,67 +151,57 @@ export class FrigateCard extends LitElement { return {}; } - protected _updateMenu(): void { - // Manually set hass in the menu. This is to allow the menu to update, - // without necessarily re-rendering the entire card (re-rendering interrupts - // clip playing). - if (!this._menu || !this._hass) { - return; - } - this._menu.buttons = this._getMenuButtons(); - } - - protected _getMenuButtons(): Map { - const buttons: Map = new Map(); + protected _getMenuButtons(): MenuButton[] { + const buttons: MenuButton[] = []; if (this.config.menu_buttons?.frigate ?? true) { - buttons.set('frigate', { description: localize('menu.frigate') }); + buttons.push({ + type: 'internal-menu-icon', + card_action: 'frigate', + title: localize('menu.frigate'), + }); } if (this.config.menu_buttons?.live ?? true) { - buttons.set('live', { + buttons.push({ + type: 'internal-menu-icon', + card_action: 'live', + title: localize('menu.live'), icon: 'mdi:cctv', - description: localize('menu.live'), emphasize: this._view.is('live'), }); } if (this.config.menu_buttons?.clips ?? true) { - buttons.set('clips', { + buttons.push({ + type: 'internal-menu-icon', + card_action: 'clips', + title: localize('menu.clips'), icon: 'mdi:filmstrip', - description: localize('menu.clips'), emphasize: this._view.is('clips'), }); } if (this.config.menu_buttons?.snapshots ?? true) { - buttons.set('snapshots', { + buttons.push({ + type: 'internal-menu-icon', + card_action: 'snapshots', + title: localize('menu.snapshots'), icon: 'mdi:camera', - description: localize('menu.snapshots'), emphasize: this._view.is('snapshots'), }); } if ((this.config.menu_buttons?.frigate_ui ?? true) && this.config.frigate_url) { - buttons.set('frigate_ui', { + buttons.push({ + type: 'internal-menu-icon', + card_action: 'frigate_ui', + title: localize('menu.frigate_ui'), icon: 'mdi:web', - description: localize('menu.frigate_ui'), }); } if ((this.config.menu_buttons?.fullscreen ?? true) && screenfull.isEnabled) { - buttons.set('fullscreen', { + buttons.push({ + type: 'internal-menu-icon', + card_action: 'fullscreen', + title: localize('menu.fullscreen'), icon: screenfull.isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen', - description: localize('menu.fullscreen'), - }); - } - - const entities = this.config.entities || []; - for (let i = 0; this._hass && i < entities.length; i++) { - if (!entities[i].show) { - continue; - } - const entity = entities[i].entity; - const state = this._hass.states[entity]; - buttons.set(entity, { - description: state.attributes.friendly_name || entity, - emphasize: ['on', 'active', 'home'].includes(state.state), - icon: entities[i].icon || stateIcon(state), }); } return buttons; @@ -285,13 +269,15 @@ export class FrigateCard extends LitElement { this.config = config; this._entitiesToMonitor = [ - ...(this.config.entities || []).map((entity) => entity.entity), + ...(this.config.update_entities || []), this.config.camera_entity, ]; this._changeView(); } protected _changeView(view?: View | undefined): void { + this._message = null; + if (view === undefined) { this._view = new View({ view: this.config.view_default }); } else { @@ -328,15 +314,20 @@ export class FrigateCard extends LitElement { return true; } - protected _menuActionHandler(name: string): void { - switch (name) { + protected _menuActionHandler(action: string, button: MenuButton): void { + if (button.type != 'internal-menu-icon') { + handleAction(this, this._hass as HomeAssistant, button, action); + return; + } + + switch (button.card_action) { case 'frigate': this._changeView(); break; case 'live': case 'clips': case 'snapshots': - this._changeView(new View({ view: name })); + this._changeView(new View({ view: button.card_action })); break; case 'frigate_ui': const frigate_url = this._getFrigateURLFromContext(); @@ -350,8 +341,7 @@ export class FrigateCard extends LitElement { } break; default: - // If it's unknown, it's assumed to be an entity_id. - fireEvent(this, 'hass-more-info', { entityId: name }); + console.warn(`Frigate card received unknown menu action: ${button.card_action}`); } } @@ -389,6 +379,7 @@ export class FrigateCard extends LitElement { return html` ): void { + return this._setMessageAndUpdate(e.detail); + } + protected _mediaLoadHandler(e: CustomEvent): void { const mediaInfo = e.detail; // In Safari, with WebRTC, 0x0 is occasionally returned during loading, @@ -524,7 +527,9 @@ export class FrigateCard extends LitElement { window.innerWidth / window.innerHeight ) { // If the menu is outside the media (i.e. above/below) allow space for it. - const allowance = ["above", "below"].includes(this.config.menu_mode) ? MENU_HEIGHT : 0; + const allowance = ['above', 'below'].includes(this.config.menu_mode) + ? MENU_HEIGHT + : 0; innerStyle['max-width'] = `calc(${ (100 * this._mediaInfo.width) / this._mediaInfo.height }vh - ${allowance}px )`; @@ -539,57 +544,89 @@ export class FrigateCard extends LitElement { ${this.config.menu_mode == 'above' ? this._renderMenu() : ''}
- ${until(this._render(), renderProgressIndicator())} + ${this._message + ? renderMessage(this._message) + : until(this._render(), renderProgressIndicator())}
${this.config.menu_mode != 'above' ? this._renderMenu() : ''} `; } - protected async _render(): Promise { + protected async _render(): Promise { if (!this._frigateCameraName) { this._frigateCameraName = await this._getFrigateCameraName(); } const mediaQueryParameters = this._getBrowseMediaQueryParameters(); - if (!this._frigateCameraName || !mediaQueryParameters) { - return renderErrorMessage(localize('error.no_frigate_camera_name')); + if (!this._hass || !this._frigateCameraName || !mediaQueryParameters) { + return this._setMessageAndUpdate({ + message: localize('error.no_frigate_camera_name'), + type: 'error', + }); } + const pictureElementsClasses = { + 'picture-elements': true, + gallery: this._view.isGalleryView(), + }; + return html` - ${this._view.is('clips') || this._view.is('snapshots') - ? html` - ` - : ``} - ${this._view.is('clip') || this._view.is('snapshot') - ? html` - ` - : ``} - ${this._view.is('live') - ? html` - ` - : ``} +
+ ${this._view.is('clips') || this._view.is('snapshots') + ? html` + ` + : ``} + ${this._view.is('clip') || this._view.is('snapshot') + ? html` + ` + : ``} + ${this._view.is('live') + ? html` + + + ` + : ``} + { + this._menu.addButton(e.detail); + }} + @frigate-card:menu-remove=${(e) => { + this._menu.removeButton(e.detail); + }} + @frigate-card:state-request=${(e) => { + e.view = this._view; + }} + > + +
`; } diff --git a/src/common.ts b/src/common.ts index 34ff07b1..1fc8fc08 100644 --- a/src/common.ts +++ b/src/common.ts @@ -7,6 +7,7 @@ import type { BrowseMediaSource, ExtendedHomeAssistant, MediaLoadInfo, + Message, } from './types'; import { browseMediaSourceSchema } from './types'; @@ -108,14 +109,17 @@ export function dispatchEvent(element: HTMLElement, name: string, detail?: T) } export function dispatchPlayEvent(element: HTMLElement): void { - dispatchEvent(element, 'play') + dispatchEvent(element, 'play'); } export function dispatchPauseEvent(element: HTMLElement): void { - dispatchEvent(element, 'pause') + dispatchEvent(element, 'pause'); } -export function dispatchMediaLoadEvent(element: HTMLElement, source: Event | HTMLElement): void { +export function dispatchMediaLoadEvent( + element: HTMLElement, + source: Event | HTMLElement, +): void { let target: HTMLElement | EventTarget; if (source instanceof Event) { target = source.composedPath()[0]; @@ -139,4 +143,54 @@ export function dispatchMediaLoadEvent(element: HTMLElement, source: Event | HTM height: (target as HTMLCanvasElement).height, }); } -} \ No newline at end of file +} + +export function dispatchMessageEvent( + element: HTMLElement, + message: string, + icon?: string, +): void { + dispatchEvent(element, 'message', { + message: message, + type: 'info', + icon: icon, + }); +} + +export function dispatchErrorMessageEvent( + element: HTMLElement, + message: string, +): void { + dispatchEvent(element, 'message', { + message: message, + type: 'error', + }); +} + +// Determine whether the card should be updated based on Home Assistant changes. +export function shouldUpdateBasedOnHass( + newHass: HomeAssistant | null, + oldHass: HomeAssistant | undefined, + entities: string[] | null, +): boolean { + if (!newHass || !entities) { + return false; + } + if (!entities.length) { + return false; + } + + if (oldHass) { + for (let i = 0; i < entities.length; i++) { + const entity = entities[i]; + if (!entity) { + continue; + } + if (oldHass.states[entity] !== newHass.states[entity]) { + return true; + } + } + return false; + } + return false; +} diff --git a/src/components/elements.ts b/src/components/elements.ts new file mode 100644 index 00000000..a0da7511 --- /dev/null +++ b/src/components/elements.ts @@ -0,0 +1,286 @@ +import { LitElement, TemplateResult, html, CSSResultGroup, unsafeCSS } from 'lit'; +import { HomeAssistant } from 'custom-card-helpers'; +import { customElement, property, query } from 'lit/decorators'; + +import { + ExtendedHomeAssistant, + FrigateConditional, + MenuButton, + MenuIcon, + MenuStateIcon, + PictureElements, +} from '../types'; +import { dispatchErrorMessageEvent, dispatchEvent } from '../common'; + +import elementsStyle from '../scss/elements.scss'; +import { localize } from '../localize/localize'; +import { View } from '../view'; + +/* A note on picture element rendering: + * + * To avoid needing to deal with the rendering of all the picture elements + * ourselves, instead the card relies on a stock conditional element (with no + * conditions) to render elements (this._root). This has a few advantages: + * + * - Does not depend on (much of!) an internal API -- conditional picture + * elements are unlikely to go away or change. + * - Forces usage of elements that HA understands. If the rendering is done + * directly, it is (ask me how I know!) very tempting to render things in such + * a way that a nested conditional element would not be able to render, i.e. + * the custom rendering logic would only apply at the first level. + */ + +/* A note on custom elements: + * + * The native HA support for custom elements is used for the menu-icon and + * menu-state-icon elements. This ensures multi-nested conditionals will work + * correctly. These custom elements 'render' by firing events that are caught by + * the card to call for inclusion/exclusion of the menu icon in question. + * + * One major complexity here is that the top element + * will not necessarily know when a menu icon is no longer rendered because of a + * conditional that no-longer evaluates to true. As such, it cannot know when to + * signal for the menu icon removal. Furthermore, the menu icon element itself + * will only know it's been removed _after_ it's been disconnected from the DOM, + * so normal event propagation at that point will not work. Instead, we must + * catch the menu icon _addition_ and register the eventhandler for the removal + * directly on the child (which will have no parent at time of calling). That + * then triggers to re-dispatch a removal event for + * upper layers to handle correctly. + */ + +// A small wrapper around a HA conditional element used to render a set of +// picture elements. +@customElement('frigate-card-elements-core') +class FrigateCardElementsCore extends LitElement { + @property({ attribute: false }) + protected elements: PictureElements; + + protected _root: HTMLElement | null = null; + protected _hass!: HomeAssistant & ExtendedHomeAssistant; + + set hass(hass: HomeAssistant & ExtendedHomeAssistant) { + if (this._root) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this._root as any).hass = hass; + } + this._hass = hass; + } + + // Transparent to elements. + createRenderRoot(): LitElement { + return this; + } + + protected _createRoot(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const elementConstructor = customElements.get('hui-conditional-element') as any; + if (!elementConstructor) { + throw new Error(localize('error.could_not_render_elements')); + } + + const element = new elementConstructor(); + element.hass = this._hass; + const config = { + type: 'conditional', + conditions: [], + elements: this.elements, + }; + try { + element.setConfig(config); + } catch (e) { + console.error(e, (e as Error).stack); + throw new Error(localize('error.invalid_elements_config')); + } + this._root = element; + } + + protected render(): TemplateResult | void { + if (!this._root) { + try { + this._createRoot(); + } catch (e) { + return dispatchErrorMessageEvent(this, (e as Error).message); + } + } + return html`${this._root || ''}`; + } +} + +// THe master class, handles event listeners and styles. +@customElement('frigate-card-elements') +export class FrigateCardElements extends LitElement { + @property({ attribute: false }) + protected elements: PictureElements; + + protected _hass!: HomeAssistant & ExtendedHomeAssistant; + + @query('frigate-card-elements-core') + _core!: FrigateCardElementsCore; + + set hass(hass: HomeAssistant & ExtendedHomeAssistant) { + if (this._core) { + this._core.hass = hass; + } + this._hass = hass; + } + + protected _menuRemoveHandler(ev: Event): void { + // Re-dispatch event from this element (instead of the disconnected one, as + // there is no parent of the disconnected element). + dispatchEvent(this, 'menu-remove', (ev as CustomEvent).detail); + } + + protected _menuAddHandler(ev: Event): void { + ev = ev as CustomEvent; + const path = ev.composedPath(); + if (!path.length) { + return; + } + + // See 'A note on custom elements' above to explain what's going on here. + + // Ensure listener is only attached 1 time by removing it first. + path[0].removeEventListener( + 'frigate-card:menu-remove', + this._menuRemoveHandler.bind(this), + ); + + path[0].addEventListener( + 'frigate-card:menu-remove', + this._menuRemoveHandler.bind(this), + ); + } + + connectedCallback(): void { + super.connectedCallback(); + + // Catch icons being added to the menu (so their removal can be subsequently + // handled). + this.addEventListener('frigate-card:menu-add', this._menuAddHandler); + } + + disconnectedCallback(): void { + this.removeEventListener('frigate-card:menu-add', this._menuAddHandler); + super.disconnectedCallback(); + } + + protected render(): TemplateResult { + return html` + `; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(elementsStyle); + } +} + +class StateRequestEvent extends Event { + public view: View | undefined; +} + +// An element that can render others based on Frigate state (e.g. only show +// overlays in particular views). This is the Frigate Card equivalent to the HA +// conditional card. +@customElement('frigate-card-conditional') +export class FrigateCardElementsConditional extends LitElement { + protected _config: FrigateConditional | null = null; + protected _hass!: HomeAssistant & ExtendedHomeAssistant; + + @query('frigate-card-elements-core') + _core!: FrigateCardElementsCore; + + set hass(hass: HomeAssistant & ExtendedHomeAssistant) { + if (this._core) { + this._core.hass = hass; + } + this._hass = hass; + } + + public setConfig(config: FrigateConditional): void { + this._config = config; + } + + // Transparent to elements. + createRenderRoot(): LitElement { + return this; + } + + protected evaluate(stateEvent: StateRequestEvent): boolean { + if (stateEvent.view && this._config.conditions.view) { + return this._config.conditions.view.includes(stateEvent.view.view); + } + return true; + } + + connectedCallback(): void { + super.connectedCallback(); + + // HA will automatically attach the 'element' class to picture elements. As + // this is a transparent 'conditional' element (just like the stock HA + // 'conditional' element), it should not have positioning. + this.className = ''; + } + + protected render(): TemplateResult | void { + const stateEvent = new StateRequestEvent(`frigate-card:state-request`, { + bubbles: true, + composed: true, + }); + + /* Special note on what's going on here: + * + * Picture elements all are descendents of , but + * there may be arbitrary complexity and layers (that this card doesn't + * control) between that master element and this custom conditional element. + * This element needs Frigate card state to function (e.g. view), but + * there's no clean way to pass state from the rest of card down through + * these layers. Instead, we dispatch a "request for state" + * (StateRequestEvent) event upwards which is caught by the outer card and + * state added to the event object. Because event propagation is handled + * synchronously, the state will be added to the event before the flow + * proceeds. + */ + this.dispatchEvent(stateEvent); + if (this.evaluate(stateEvent)) { + return html` + `; + } + } +} + +// A base class for rendering menu icons / menu state icons. +export class FrigateCardElementsBaseMenuIcon extends LitElement { + @property({ attribute: false }) + protected _config: T | null = null; + + public setConfig(config: T): void { + this._config = config; + } + + connectedCallback(): void { + super.connectedCallback(); + if (this._config) { + dispatchEvent(this, 'menu-add', this._config); + } + } + + disconnectedCallback(): void { + if (this._config) { + dispatchEvent(this, 'menu-remove', this._config); + } + super.disconnectedCallback(); + } +} + +@customElement('frigate-card-menu-icon') +export class FrigateCardElementsMenuIcon extends FrigateCardElementsBaseMenuIcon {} + +@customElement('frigate-card-menu-state-icon') +export class FrigateCardElementsMenuStateIcon extends FrigateCardElementsBaseMenuIcon {} diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 01254b44..85faf32b 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -11,9 +11,15 @@ import type { } from '../types'; import { View } from '../view'; -import { browseMedia, browseMediaQuery, getFirstTrueMediaChildIndex } from '../common'; +import { + browseMedia, + browseMediaQuery, + dispatchErrorMessageEvent, + dispatchMessageEvent, + getFirstTrueMediaChildIndex, +} from '../common'; import { localize } from '../localize/localize'; -import { renderMessage, renderErrorMessage, renderProgressIndicator } from './message'; +import { renderProgressIndicator } from './message'; import galleryStyle from '../scss/gallery.scss'; import { styleMap } from 'lit/directives/style-map.js'; @@ -65,7 +71,7 @@ export class FrigateCardGallery extends LitElement { return html`${until(this._render(), renderProgressIndicator())}`; } - protected async _render(): Promise { + protected async _render(): Promise { let parent: BrowseMediaSource | null; try { if (this.view.target) { @@ -74,11 +80,12 @@ export class FrigateCardGallery extends LitElement { parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters); } } catch (e: any) { - return renderErrorMessage(e.message); + return dispatchErrorMessageEvent(this, e.message); } if (!parent || !parent.children || getFirstTrueMediaChildIndex(parent) == null) { - return renderMessage( + return dispatchMessageEvent( + this, this._getMediaType() == 'clips' ? localize('common.no_clips') : localize('common.no_snapshots'), diff --git a/src/components/live.ts b/src/components/live.ts index 964e540e..6c6fdd0f 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -8,16 +8,14 @@ import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types'; import { localize } from '../localize/localize'; import { + dispatchErrorMessageEvent, dispatchMediaLoadEvent, + dispatchMessageEvent, dispatchPauseEvent, dispatchPlayEvent, homeAssistantWSRequest, } from '../common'; -import { - renderMessage, - renderErrorMessage, - renderProgressIndicator, -} from '../components/message'; +import { renderProgressIndicator } from '../components/message'; import JSMpeg from '@cycjimmy/jsmpeg-player'; @@ -74,7 +72,11 @@ export class FrigateCardViewerFrigate extends LitElement { protected render(): TemplateResult | void { if (!(this.cameraEntity in this.hass.states)) { - return renderMessage(localize('error.no_live_camera'), 'mdi:camera-off'); + return dispatchMessageEvent( + this, + localize('error.no_live_camera'), + 'mdi:camera-off', + ); } return html` { + protected async _render(): Promise { if (!this._jsmpegCanvasElement) { this._jsmpegCanvasElement = document.createElement('canvas'); this._jsmpegCanvasElement.className = 'media'; @@ -223,7 +225,10 @@ export class FrigateCardViewerJSMPEG extends LitElement { const jsmpeg_url = await this._getURL(); if (!jsmpeg_url) { - return renderErrorMessage('Could not retrieve or sign JSMPEG websocket path'); + return dispatchErrorMessageEvent( + this, + 'Could not retrieve or sign JSMPEG websocket path', + ); } let videoDecoded = false; diff --git a/src/components/menu.ts b/src/components/menu.ts index 37d9b45a..e9a764d9 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -1,19 +1,26 @@ -import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; +import { HomeAssistant, hasAction, stateIcon } from 'custom-card-helpers'; +import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS, PropertyValues } from 'lit'; +import { actionHandler } from '../action-handler-directive'; import { customElement, property } from 'lit/decorators'; import { classMap } from 'lit/directives/class-map.js'; - +import { styleMap } from 'lit/directives/style-map.js'; import { MenuButton } from '../types'; -import type { FrigateMenuMode } from '../types'; +import type { ExtendedHomeAssistant, FrigateMenuMode } from '../types'; import menuStyle from '../scss/menu.scss'; +import { HassEntity } from 'home-assistant-js-websocket'; +import { shouldUpdateBasedOnHass } from '../common'; -type FrigateCardMenuCallback = (name: string) => void; +type FrigateCardMenuCallback = (name: string, button: MenuButton) => void; export const MENU_HEIGHT = 46; // A menu for the Frigate card. @customElement('frigate-card-menu') export class FrigateCardMenu extends LitElement { + @property({ attribute: false }) + public hass!: HomeAssistant & ExtendedHomeAssistant; + @property({ attribute: false }) protected menuMode: FrigateMenuMode = 'hidden-top'; @@ -24,12 +31,22 @@ export class FrigateCardMenu extends LitElement { protected actionCallback: FrigateCardMenuCallback | null = null; @property({ attribute: false }) - public buttons: Map = new Map(); + public buttons: MenuButton[] = []; + + public addButton(button: MenuButton): void { + if (!this.buttons.includes(button)) { + this.buttons = [...this.buttons, button]; + } + } + + public removeButton(target: MenuButton): void { + this.buttons = this.buttons.filter(button => button != target); + } // Call the callback. - protected _callAction(name: string): void { + protected _callAction(ev: CustomEvent, button: MenuButton): void { if (this.menuMode.startsWith('hidden-')) { - if (name == 'frigate') { + if (button.type == 'internal-menu-icon' && button.card_action === 'frigate') { this.expand = !this.expand; return; } @@ -38,42 +55,94 @@ export class FrigateCardMenu extends LitElement { } if (this.actionCallback) { - this.actionCallback(name); + this.actionCallback(ev.detail.action, button); } } + // Determine whether the menu should be updated. + protected shouldUpdate(changedProps: PropertyValues): boolean { + const oldHass = changedProps.get('hass') as HomeAssistant | undefined; + + if (changedProps.size > 1 || !oldHass) { + return true; + } + + // Extract the entities the menu rendering depends on (if any). + const entities: string[] = [] + for (let i = 0; i < this.buttons.length; i++) { + const button = this.buttons[i]; + if (button.type == 'custom:frigate-card-menu-state-icon') { + entities.push(button.entity); + } + } + return shouldUpdateBasedOnHass(this.hass, oldHass, entities); + } + // Render a menu button. - protected _renderButton(name: string, button: MenuButton): TemplateResult { + protected _renderButton(button: MenuButton): TemplateResult { + let state: HassEntity | null = null; + let emphasize = false; + let title = button.title; + let icon = button.icon; + const style = ('style' in button ? button.style : {}) || {}; + + if (button.type === 'custom:frigate-card-menu-state-icon') { + state = this.hass.states[button.entity]; + emphasize = + !!state && button.state_color && ['on', 'active', 'home'].includes(state.state); + title = title ?? (state.attributes.friendly_name || button.entity); + icon = icon ?? stateIcon(state); + } else if (button.type === 'internal-menu-icon') { + emphasize = button.emphasize ?? false; + } + + let hasHold = false; + let hasDoubleClick = false; + + if (button.type != 'internal-menu-icon') { + hasHold = hasAction(button.hold_action); + hasDoubleClick = hasAction(button.double_tap_action); + } + const classes = { button: true, - emphasize: button.emphasize ?? false, + emphasize: emphasize, }; return html` this._callAction(name)} + style="${styleMap(style)}" + icon=${icon || 'mdi:gesture-tap-button'} + title=${title || ''} + @action=${(ev) => this._callAction(ev, button)} + .actionHandler=${actionHandler({ + hasHold: hasHold, + hasDoubleClick: hasDoubleClick, + })} >`; } // Render the Frigate menu button. - protected _renderFrigateButton(name: string, button: MenuButton): TemplateResult { + protected _renderFrigateButton(button: MenuButton): TemplateResult { const icon = this.menuMode.startsWith('hidden-') && !this.expand ? 'mdi:alpha-f-box-outline' : 'mdi:alpha-f-box'; - return this._renderButton(name, Object.assign({}, button, { icon: icon })); + return this._renderButton(Object.assign({}, button, { icon: icon })); } // Render the menu. protected render(): TemplateResult { + const isFrigateButton = function (button: MenuButton): boolean { + return button.type === 'internal-menu-icon' && button.card_action === 'frigate'; + }; + // If the menu is off, or if it's in hidden mode but there's no button to // unhide it, just show nothing. if ( this.menuMode == 'none' || - (this.menuMode.startsWith('hidden-') && !this.buttons.get('frigate')) + (this.menuMode.startsWith('hidden-') && !this.buttons.find(isFrigateButton)) ) { return html``; } @@ -103,14 +172,10 @@ export class FrigateCardMenu extends LitElement { return html`
- ${Array.from(this.buttons.keys()).map((name) => { - const button = this.buttons.get(name); - if (button) { - return name === 'frigate' - ? this._renderFrigateButton(name, button) - : this._renderButton(name, button); - } - return html``; + ${Array.from(this.buttons).map((button) => { + return isFrigateButton(button) + ? this._renderFrigateButton(button) + : this._renderButton(button); })}
`; diff --git a/src/components/message.ts b/src/components/message.ts index 939bb0ae..c0fc5b90 100644 --- a/src/components/message.ts +++ b/src/components/message.ts @@ -3,6 +3,8 @@ import { customElement, property } from 'lit/decorators'; import { localize } from '../localize/localize'; +import { Message } from '../types'; + import messageStyle from '../scss/message.scss'; const URL_TROUBLESHOOTING = @@ -14,14 +16,17 @@ export class FrigateCardMessage extends LitElement { protected message = ''; @property({ attribute: false }) - protected icon = 'mdi:information-outline'; + protected icon?; // Render the menu. protected render(): TemplateResult { + const icon = this.icon ? this.icon : 'mdi:information-outline'; return html`
- - ${this.message ? html` ${this.message}` : ''} + + + + ${this.message ? html`${this.message}` : ''}
`; } @@ -39,7 +44,7 @@ export class FrigateCardErrorMessage extends LitElement { protected render(): TemplateResult { return html` ${localize('error.troubleshooting')} .`} + ${localize('error.troubleshooting')}.`} .icon=${'mdi:alert-circle'} > `; @@ -59,16 +64,18 @@ export class FrigateCardProgressIndicator extends LitElement { } } -export function renderErrorMessage(error: string): TemplateResult { - return html` - - `; -} - -export function renderMessage(message: string, icon: string): TemplateResult { - return html` - - `; +export function renderMessage(message: Message): TemplateResult { + if (message.type == 'error') { + return html` `; + } else if (message.type == 'info') { + return html` `; + } + return html``; } export function renderProgressIndicator(): TemplateResult { diff --git a/src/components/viewer.ts b/src/components/viewer.ts index cafecd34..53c1557a 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -18,7 +18,9 @@ import type { import { localize } from '../localize/localize'; import { browseMediaQuery, + dispatchErrorMessageEvent, dispatchMediaLoadEvent, + dispatchMessageEvent, dispatchPauseEvent, dispatchPlayEvent, getFirstTrueMediaChildIndex, @@ -27,8 +29,6 @@ import { import { View } from '../view'; import { - renderMessage, - renderErrorMessage, renderProgressIndicator, } from '../components/message'; @@ -158,7 +158,7 @@ export class FrigateCardViewer extends LitElement { return html`${until(this._render(), renderProgressIndicator())}`; } - protected async _render(): Promise { + protected async _render(): Promise { let autoplay = true; let parent: BrowseMediaSource | null = null; @@ -173,11 +173,12 @@ export class FrigateCardViewer extends LitElement { try { parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters); } catch (e) { - return renderErrorMessage((e as Error).message); + return dispatchErrorMessageEvent(this, (e as Error).message); } childIndex = getFirstTrueMediaChildIndex(parent); if (!parent || !parent.children || childIndex == null) { - return renderMessage( + return dispatchMessageEvent( + this, this.view.is('clip') ? localize('common.no_clip') : localize('common.no_snapshot'), @@ -196,7 +197,7 @@ export class FrigateCardViewer extends LitElement { const resolvedMedia = await this._resolveMedia(mediaToRender); if (!mediaToRender || !resolvedMedia) { // Home Assistant could not resolve media item. - return renderErrorMessage(localize('error.could_not_resolve')); + return dispatchErrorMessageEvent(this, localize('error.could_not_resolve')); } const neighbors = this._getMediaNeighbors(parent, childIndex); diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 0a943bdc..7bdb8f9e 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -95,6 +95,8 @@ "no_live_camera": "No live camera", "invalid_configuration": "Invalid configuration", "missing_webrtc": "WebRTC component not found", - "no_frigate_camera_name": "Cannot derive frigate_camera_name, you may need to set it manually" + "no_frigate_camera_name": "Cannot derive frigate_camera_name, you may need to set it manually", + "could_not_render_elements": "Could not render picture elements", + "invalid_elements_config": "Invalid picture elements configuration" } } diff --git a/src/scss/card.scss b/src/scss/card.scss index 4c851ee0..f1d9006d 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -1,15 +1,16 @@ .container { position: relative; overflow: auto; - height: 100%; width: 100%; + height: 100%; margin: auto; display: flex; justify-content: center; } .frigate-card-contents { - width: 100%; + width: inherit; + height: inherit; margin: auto; overflow: auto; -ms-overflow-style: none; /* Hide scrollbar: IE and Edge */ @@ -43,6 +44,17 @@ .outer:hover + .hover-menu, .hover-menu:hover { opacity: 1.0; } +/* A relative div to place absolute picture elements onto */ +.picture-elements { + position: relative; + width: inherit; +} + +/* Enforce picture elements to only be the size of the card/fullscreen (and not +larger) when in gallery mode so that the picture elements do not scroll. */ +.picture-elements.gallery { + height: 100%; +} ha-card { display: flex; @@ -56,7 +68,7 @@ ha-card { background-color: var(--secondary-background-color, black); } -frigate-card-gallery, frigate-card-viewer, frigate-card-live { +frigate-card-gallery, frigate-card-viewer, frigate-card-live, frigate-card-message, frigate-card-error-message { width: 100%; display: block; } diff --git a/src/scss/elements.scss b/src/scss/elements.scss new file mode 100644 index 00000000..239c854c --- /dev/null +++ b/src/scss/elements.scss @@ -0,0 +1,12 @@ +.element { + position: absolute; + transform: translate(-50%, -50%); +} + +// Errors encountered by HA (not the card) during parsing of the configuration +// (e.g. custom picture element that does not exist). +hui-error-card.element { + inset: 0px; + background-color: var(--secondary-background-color, black); + transform: unset; +} \ No newline at end of file diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss index 7de07add..5e285c92 100644 --- a/src/scss/gallery.scss +++ b/src/scss/gallery.scss @@ -1,6 +1,17 @@ @use "@material/image-list/mdc-image-list"; @use "@material/image-list"; +:host { + overflow: auto; + -ms-overflow-style: none; /* Hide scrollbar: IE and Edge */ + scrollbar-width: none; /* Hide scrollbar: Firefox */ +} + +/* Hide scrollbar for Chrome, Safari and Opera */ +:host::-webkit-scrollbar { + display: none; +} + .frigate-card-gallery { // Note: In fullscreen, number of columns is overwritten in Javascript based // on dimensions. diff --git a/src/scss/message.scss b/src/scss/message.scss index aa789cb0..6becbe0c 100644 --- a/src/scss/message.scss +++ b/src/scss/message.scss @@ -10,3 +10,7 @@ .message a { color: var(--primary-text-color, white); } + +span { + padding: 10px; +} \ No newline at end of file diff --git a/src/types.ts b/src/types.ts index 704be24e..2fe0ea71 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,12 @@ -import { LovelaceCard, LovelaceCardEditor } from 'custom-card-helpers'; +import { + CallServiceActionConfig, + LovelaceCard, + LovelaceCardEditor, + MoreInfoActionConfig, + NavigateActionConfig, + ToggleActionConfig, + UrlActionConfig, +} from 'custom-card-helpers'; import { z } from 'zod'; declare global { @@ -46,6 +54,195 @@ export type NextPreviousControlStyle = typeof NEXT_PREVIOUS_CONTROL_STYLES[numbe export const LIVE_PROVIDERS = ['frigate', 'frigate-jsmpeg', 'webrtc'] as const; export type LiveProvider = typeof LIVE_PROVIDERS[number]; +/** + * Action Types (for "Picture Elements" / Menu) + */ + +// Declare schemas to existing types: +// - https://github.com/colinhacks/zod/issues/372#issuecomment-826380330 +const schemaForType = + () => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + >(arg: S) => { + return arg; + }; +const toggleActionSchema = schemaForType()( + z.object({ + action: z.literal('toggle'), + }), +); +const callServiceActionSchema = schemaForType()( + z.object({ + action: z.literal('call-service'), + service: z.string(), + service_data: z.object({}).passthrough().optional(), + }), +); +const navigateActionSchema = schemaForType()( + z.object({ + action: z.literal('navigate'), + navigation_path: z.string(), + }), +); +const urlActionSchema = schemaForType()( + z.object({ + action: z.literal('url'), + url_path: z.string(), + }), +); +const moreInfoActionSchema = schemaForType()( + z.object({ + action: z.literal('more-info'), + }), +); +const elementsActionSchema = z.union([ + toggleActionSchema, + callServiceActionSchema, + navigateActionSchema, + urlActionSchema, + moreInfoActionSchema, +]); +export type ElementsActionType = z.infer; + +const elementsBaseSchema = z.object({ + style: z.object({}).passthrough().optional(), + title: z.string().nullable().optional(), + tap_action: elementsActionSchema.optional(), + hold_action: elementsActionSchema.optional(), + double_tap_action: elementsActionSchema.optional(), +}); + +/** + * Picture Element Types + * + * All picture element types are validated (not just the Frigate card custom + * ones) as a convenience to present the user with a consistent error display + * up-front regardless of where they made their error. + */ + +// https://www.home-assistant.io/lovelace/picture-elements/#state-badge +const stateBadgeIconSchema = elementsBaseSchema.merge( + z.object({ + type: z.literal('state-badge'), + entity: z.string(), + })); + +// https://www.home-assistant.io/lovelace/picture-elements/#state-icon +const stateIconSchema = elementsBaseSchema.merge( + z.object({ + type: z.literal('state-icon'), + entity: z.string(), + icon: z.string().optional(), + state_color: z.boolean().default(true), + })); + +// https://www.home-assistant.io/lovelace/picture-elements/#state-label +const stateLabelSchema = elementsBaseSchema.merge( + z.object({ + type: z.literal('state-label'), + entity: z.string(), + attribute: z.string().optional(), + prefix: z.string().optional(), + suffix: z.string().optional(), + })); + +// https://www.home-assistant.io/lovelace/picture-elements/#service-call-button +const serviceCallButtonSchema = + elementsBaseSchema.merge(z + .object({ + type: z.literal('service-button'), + // Title is required for service button. + title: z.string(), + service: z.string(), + service_data: z.object({}).passthrough().optional(), + }) + ) + +// https://www.home-assistant.io/lovelace/picture-elements/#icon +const iconSchema = elementsBaseSchema.merge( + z.object({ + type: z.literal('icon'), + icon: z.string(), + entity: z.string().optional(), + })); + +// https://www.home-assistant.io/lovelace/picture-elements/#image-element +const imageSchema = elementsBaseSchema.merge( + z.object({ + type: z.literal('image'), + entity: z.string().optional(), + image: z.string().optional(), + camera_image: z.string().optional(), + camera_view: z.string().optional(), + state_image: z.object({}).passthrough().optional(), + filter: z.string().optional(), + state_filter: z.object({}).passthrough().optional(), + aspect_ratio: z.string().optional(), +})); + +// https://www.home-assistant.io/lovelace/picture-elements/#image-element +const conditionalSchema = z.object({ + type: z.literal('conditional'), + conditions: z.object({ + entity: z.string(), + state: z.string().optional(), + state_not: z.string().optional(), + }).array(), + elements: z.lazy(() => pictureElementsSchema), + }); + +// https://www.home-assistant.io/lovelace/picture-elements/#custom-elements +const customSchema = z.object({ + // Insist that Frigate card custom elements are handled by other schemas. + type: z.string().regex(/^custom:(?!frigate-card).+/), + }).passthrough(); + +/** + * Custom Element Types + */ + +export const menuIconSchema = iconSchema.merge( + z.object({ + type: z.literal('custom:frigate-card-menu-icon'), + })); +export type MenuIcon = z.infer; + +export const menuStateIconSchema = stateIconSchema.merge( + z.object({ + type: z.literal('custom:frigate-card-menu-state-icon'), + })); +export type MenuStateIcon = z.infer; + +const frigateConditionalSchema = z.object({ + type: z.literal('custom:frigate-card-conditional'), + conditions: z.object({ + view: z.string().array().optional(), + }), + elements: z.lazy(() => pictureElementsSchema), +}); +export type FrigateConditional = z.infer; + + +// 'internalMenuIconSchema' is excluded to disallow the user from manually +// changing the internal menu buttons. +const pictureElementSchema = z.union([ + menuStateIconSchema, + menuIconSchema, + frigateConditionalSchema, + stateBadgeIconSchema, + stateIconSchema, + stateLabelSchema, + serviceCallButtonSchema, + iconSchema, + imageSchema, + conditionalSchema, + customSchema, +]); +export type PictureElement = z.infer; + +const pictureElementsSchema = pictureElementSchema.array().optional(); +export type PictureElements = z.infer; + export const frigateCardConfigSchema = z.object({ camera_entity: z.string(), // No URL validation to allow relative URLs within HA (e.g. addons). @@ -85,14 +282,8 @@ export const frigateCardConfigSchema = z.object({ fullscreen: z.boolean().default(true), }) .optional(), - entities: z - .object({ - entity: z.string(), - show: z.boolean().default(true), - icon: z.string().optional(), - }) - .array() - .optional(), + update_entities: z.string().array().optional(), + elements: pictureElementsSchema, controls: z .object({ nextprev: z.enum(NEXT_PREVIOUS_CONTROL_STYLES).default('thumbnails'), @@ -125,12 +316,22 @@ export const frigateCardConfigSchema = z.object({ }); export type FrigateCardConfig = z.infer; -export interface MenuButton { - icon?: string; - description: string; - emphasize?: boolean; -} +// Schema for card (non-user configured) menu icons. +const internalMenuIconSchema = z + .object({ + type: z.literal('internal-menu-icon'), + title: z.string(), + icon: z.string().optional(), + emphasize: z.boolean().default(false).optional(), + card_action: z.string(), + }); +const menuButtonSchema = z.union([ + menuIconSchema, + menuStateIconSchema, + internalMenuIconSchema, +]); +export type MenuButton = z.infer; export interface ExtendedHomeAssistant { hassUrl(path?): string; } @@ -158,6 +359,12 @@ export interface MediaLoadInfo { height: number; } +export interface Message { + message: string; + type: 'error' | 'info'; + icon?: string; +} + /** * Home Assistant API types. */