diff --git a/docs/configuration/cameras/README.md b/docs/configuration/cameras/README.md index be582f66..3a4a5cf9 100644 --- a/docs/configuration/cameras/README.md +++ b/docs/configuration/cameras/README.md @@ -334,6 +334,7 @@ cameras: position: x: 50 y: 50 + always_error_if_entity_unavailable: false - camera_entity: camera.entrance icon: 'mdi:car' title: 'Front entrance' diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index 615c311c..e0abc817 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -58,7 +58,6 @@ import { RecordingSegmentsQueryResultsMap, } from '../types'; import { getDefaultGo2RTCEndpoint } from '../utils/go2rtc-endpoint'; -import frigateLogo from './assets/frigate.svg'; import { FrigateCamera, isBirdseye } from './camera'; import { FrigateEventWatcher } from './event-watcher'; import { FrigateViewMediaFactory } from './media'; @@ -912,8 +911,8 @@ export class FrigateCameraManagerEngine hass: HomeAssistant, cameraConfig: CameraConfig, ): CameraManagerCameraMetadata { - const metadata = super.getCameraMetadata(hass, cameraConfig); return { + ...super.getCameraMetadata(hass, cameraConfig), title: cameraConfig.title ?? getEntityTitle(hass, cameraConfig.camera_entity) ?? @@ -921,8 +920,7 @@ export class FrigateCameraManagerEngine prettifyTitle(cameraConfig.frigate?.camera_name) ?? cameraConfig.id ?? '', - icon: metadata.icon, - engineLogo: frigateLogo, + engineIcon: 'frigate', }; } diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index f201c852..e756d67c 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -5,7 +5,7 @@ import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/st import { PTZAction } from '../../config/ptz'; import { ActionPhase, CameraConfig } from '../../config/types'; import { ExtendedHomeAssistant } from '../../types'; -import { getEntityIcon, getEntityTitle } from '../../utils/ha'; +import { getEntityTitle } from '../../utils/ha'; import { ViewMedia } from '../../view/media'; import { Camera } from '../camera'; import { Capabilities } from '../capabilities'; @@ -202,9 +202,11 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { getEntityTitle(hass, cameraConfig.webrtc_card?.entity) ?? cameraConfig.id ?? '', - icon: - cameraConfig?.icon ?? - (cameraEntity ? getEntityIcon(hass, cameraEntity, 'mdi:video') : 'mdi:video'), + icon: { + entity: cameraEntity ?? undefined, + icon: cameraConfig.icon, + fallback: 'mdi:video', + }, }; } diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts index 1d3eae28..95f2eca0 100644 --- a/src/camera-manager/motioneye/engine-motioneye.ts +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -43,7 +43,6 @@ import { QueryReturnType, } from '../types'; import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; -import motioneyeLogo from './assets/motioneye.svg'; import { MotionEyeCamera } from './camera'; import { MotionEyeEventQueryResults } from './types'; @@ -420,10 +419,9 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine hass: HomeAssistant, cameraConfig: CameraConfig, ): CameraManagerCameraMetadata { - const metadata = super.getCameraMetadata(hass, cameraConfig); return { - ...metadata, - engineLogo: motioneyeLogo, + ...super.getCameraMetadata(hass, cameraConfig), + engineIcon: 'motioneye', }; } diff --git a/src/camera-manager/reolink/engine-reolink.ts b/src/camera-manager/reolink/engine-reolink.ts index de53d98c..06815717 100644 --- a/src/camera-manager/reolink/engine-reolink.ts +++ b/src/camera-manager/reolink/engine-reolink.ts @@ -38,7 +38,6 @@ import { QueryReturnType, } from '../types'; import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz'; -import reolinkLogo from './assets/reolink.svg'; import { ReolinkCamera } from './camera'; import { BrowseMediaReolinkCameraMetadata, ReolinkEventQueryResults } from './types'; @@ -395,10 +394,9 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { hass: HomeAssistant, cameraConfig: CameraConfig, ): CameraManagerCameraMetadata { - const metadata = super.getCameraMetadata(hass, cameraConfig); return { - ...metadata, - engineLogo: reolinkLogo, + ...super.getCameraMetadata(hass, cameraConfig), + engineIcon: 'reolink', }; } diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index c80def31..e08d1052 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -1,4 +1,4 @@ -import { CapabilityKey } from '../types'; +import { CapabilityKey, Icon } from '../types'; import { FrigateCardView, SSLCiphers } from '../config/types'; import { ViewMedia } from '../view/media'; @@ -106,8 +106,10 @@ export interface CameraManagerMediaCapabilities { export interface CameraManagerCameraMetadata { title: string; - icon: string; - engineLogo?: string; + icon: Icon; + + // Engine icon is just a string since it will never be entity-derived. + engineIcon?: string; } export interface CameraEndpointsContext { diff --git a/src/card-controller/media-player-manager.ts b/src/card-controller/media-player-manager.ts index 023415c7..fd0d065f 100644 --- a/src/card-controller/media-player-manager.ts +++ b/src/card-controller/media-player-manager.ts @@ -7,7 +7,7 @@ import { import { localize } from '../localize/localize'; import { errorToConsole } from '../utils/basic'; import { Entity } from '../utils/ha/registry/entity/types'; -import { supportsFeature } from '../utils/ha/update'; +import { supportsFeature } from '../utils/ha'; import { ViewMedia } from '../view/media'; import { ViewMediaClassifier } from '../view/media-classifier'; import { CardMediaPlayerAPI } from './types'; diff --git a/src/card-controller/status-bar-item-manager.ts b/src/card-controller/status-bar-item-manager.ts index 4168183f..11efce02 100644 --- a/src/card-controller/status-bar-item-manager.ts +++ b/src/card-controller/status-bar-item-manager.ts @@ -45,7 +45,7 @@ export class StatusBarItemManager { const cameraMetadata = options?.view ? options?.cameraManager?.getCameraMetadata(options?.view?.camera) : null; - const engineLogoIcon = cameraMetadata?.engineLogo ?? null; + const engineIcon = cameraMetadata?.engineIcon ?? null; const title = options?.view?.is('live') ? cameraMetadata?.title ?? null : options?.view?.isViewerView() @@ -99,11 +99,11 @@ export class StatusBarItemManager { ] : []), - ...(engineLogoIcon + ...(engineIcon ? [ { - type: 'custom:frigate-card-status-bar-image' as const, - image: engineLogoIcon, + type: 'custom:frigate-card-status-bar-icon' as const, + icon: engineIcon, ...options?.statusConfig?.items.engine, }, ] diff --git a/src/components-lib/icon-controller.ts b/src/components-lib/icon-controller.ts new file mode 100644 index 00000000..4b4b4ab7 --- /dev/null +++ b/src/components-lib/icon-controller.ts @@ -0,0 +1,44 @@ +import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import frigateSVG from '../camera-manager/frigate/assets/frigate.svg'; +import motioneyeSVG from '../camera-manager/motioneye/assets/motioneye.svg'; +import reolinkSVG from '../camera-manager/reolink/assets/reolink.svg'; +import { Icon } from '../types'; +import { HassEntity } from 'home-assistant-js-websocket'; + +export class IconController { + public getCustomIcon(icon?: Icon): string | null { + switch (icon?.icon) { + case 'frigate': + return frigateSVG; + case 'motioneye': + return motioneyeSVG; + case 'reolink': + return reolinkSVG; + default: + return null; + } + } + + public createStateObjectForStateBadge( + hass: HomeAssistant, + entityID: string, + ): HassEntity | null { + if (!hass.states[entityID]) { + return null; + } + return { + ...hass.states[entityID], + attributes: { + ...hass.states[entityID].attributes, + + // State badge is the only available component that will allow the + // Home Assistant frontend to correctly color based on the state, but + // it also will render an image (instead of an icon) if one is present + // in the attributes. By overriding the below attributes, we avoid + // that behavior. + entity_picture: undefined, + entity_picture_local: undefined, + }, + }; + } +} diff --git a/src/components-lib/menu-button-controller.ts b/src/components-lib/menu-button-controller.ts index e76aa22a..b6ff9eec 100644 --- a/src/components-lib/menu-button-controller.ts +++ b/src/components-lib/menu-button-controller.ts @@ -23,7 +23,7 @@ import { } from '../utils/action'; import { isTruthy } from '../utils/basic'; import { isBeingCasted } from '../utils/casting'; -import { getEntityIcon, getEntityTitle } from '../utils/ha'; +import { getEntityTitle } from '../utils/ha'; import { getPTZTarget } from '../utils/ptz'; import { getStreamCameraID, hasSubstream } from '../utils/substream'; import { View } from '../view/view'; @@ -134,30 +134,27 @@ export class MenuButtonController { // current view for a less surprising UX. const menuCameraIDs = cameraManager.getStore().getCameraIDsWithCapability('menu'); if (menuCameraIDs.size > 1) { - const menuItems = Array.from( - cameraManager.getStore().getCameraConfigEntries(menuCameraIDs), - ([cameraID, config]) => { - const action = createCameraAction('camera_select', cameraID); - const metadata = cameraManager.getCameraMetadata(cameraID); + const submenuItems = Array.from(menuCameraIDs, (cameraID) => { + const action = createCameraAction('camera_select', cameraID); + const metadata = cameraManager.getCameraMetadata(cameraID); - return { - enabled: true, - icon: metadata?.icon, - entity: config.camera_entity, - state_color: true, - title: metadata?.title, - selected: view?.camera === cameraID, - ...(action && { tap_action: action }), - }; - }, - ); + return { + enabled: true, + icon: metadata?.icon.icon, + entity: metadata?.icon.entity, + state_color: true, + title: metadata?.title, + selected: view?.camera === cameraID, + ...(action && { tap_action: action }), + }; + }); return { icon: 'mdi:video-switch', ...config.menu.buttons.cameras, type: 'custom:frigate-card-menu-submenu', title: localize('config.menu.buttons.cameras'), - items: menuItems, + items: submenuItems, }; } return null; @@ -201,11 +198,10 @@ export class MenuButtonController { const menuItems = Array.from(streams, (streamID) => { const action = createCameraAction('live_substream_select', streamID); const metadata = cameraManager.getCameraMetadata(streamID) ?? undefined; - const cameraConfig = cameraManager.getStore().getCameraConfig(streamID); return { enabled: true, - icon: metadata?.icon, - entity: cameraConfig?.camera_entity, + icon: metadata?.icon.icon, + entity: metadata?.icon.entity, state_color: true, title: metadata?.title, selected: substreamAwareCameraID === streamID, @@ -465,7 +461,6 @@ export class MenuButtonController { return { enabled: true, selected: false, - icon: getEntityIcon(hass, playerEntityID), entity: playerEntityID, state_color: false, title: title, diff --git a/src/components-lib/menu-controller.ts b/src/components-lib/menu-controller.ts index 9e93d9fa..a5fb4a8f 100644 --- a/src/components-lib/menu-controller.ts +++ b/src/components-lib/menu-controller.ts @@ -1,4 +1,4 @@ -import { HASSDomEvent, HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { HASSDomEvent } from '@dermotduffy/custom-card-helpers'; import { LitElement } from 'lit'; import { orderBy } from 'lodash-es'; import { dispatchActionExecutionRequest } from '../card-controller/actions/utils/execution-request.js'; @@ -9,13 +9,11 @@ import { type MenuConfig, type MenuItem, } from '../config/types.js'; -import { StateParameters } from '../types.js'; import { convertActionToCardCustomAction, getActionConfigGivenAction, } from '../utils/action'; import { arrayify, isTruthy, setOrRemoveAttribute } from '../utils/basic.js'; -import { refreshDynamicStateParameters } from '../utils/ha/index.js'; export class MenuController { protected _host: LitElement; @@ -158,13 +156,6 @@ export class MenuController { } } - public getFreshButtonState(hass: HomeAssistant, button: MenuItem): StateParameters { - const stateParameters = { ...button } as StateParameters; - return hass && button.type === 'custom:frigate-card-menu-state-icon' - ? refreshDynamicStateParameters(hass, stateParameters) - : stateParameters; - } - protected _sortButtons(): void { this._buttons = orderBy( this._buttons, diff --git a/src/components/date-picker.ts b/src/components/date-picker.ts index 24c7456c..6401f719 100644 --- a/src/components/date-picker.ts +++ b/src/components/date-picker.ts @@ -5,6 +5,7 @@ import { localize } from '../localize/localize'; import datePickerStyle from '../scss/date-picker.scss'; import { stopEventFromActivatingCardWideActions } from '../utils/action'; import { dispatchFrigateCardEvent } from '../utils/basic'; +import './icon'; export interface DatePickerEvent { date: Date | null; @@ -44,16 +45,16 @@ export class FrigateCardDatePicker extends LitElement { @input=${() => changed()} @change=${() => changed()} /> - { stopEventFromActivatingCardWideActions(ev); this._refInput.value?.showPicker(); }} > - `; + `; } static get styles(): CSSResultGroup { diff --git a/src/components/drawer.ts b/src/components/drawer.ts index 6ea33139..2913ef69 100644 --- a/src/components/drawer.ts +++ b/src/components/drawer.ts @@ -14,6 +14,7 @@ import drawerInjectStyle from '../scss/drawer-inject.scss'; import drawerStyle from '../scss/drawer.scss'; import { stopEventFromActivatingCardWideActions } from '../utils/action'; import { getChildrenFromElement, isHoverableDevice } from '../utils/basic'; +import './icon'; export interface DrawerIcons { open?: string; @@ -121,11 +122,13 @@ export class FrigateCardDrawer extends LitElement { this.open = !this.open; }} > - { // Only open the drawer on mousenter when the device // supports hover (otherwise iOS may end up passing on @@ -136,7 +139,7 @@ export class FrigateCardDrawer extends LitElement { } }} > - + ` : ''} diff --git a/src/components/icon.ts b/src/components/icon.ts new file mode 100644 index 00000000..e540d3b0 --- /dev/null +++ b/src/components/icon.ts @@ -0,0 +1,54 @@ +import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; +import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; +import { customElement, property } from 'lit/decorators.js'; +import { IconController } from '../components-lib/icon-controller'; +import iconStyle from '../scss/icon.scss'; +import { Icon } from '../types'; + +@customElement('frigate-card-icon') +export class FrigateCardIcon extends LitElement { + @property({ attribute: false }) + public hass?: HomeAssistant; + + @property({ attribute: false }) + public icon?: Icon; + + private _controller = new IconController(); + + protected render(): TemplateResult { + const customIconURL = this._controller.getCustomIcon(this.icon); + if (customIconURL) { + return html``; + } + if (this.icon?.icon) { + return html``; + } + if (this.hass && this.icon?.entity) { + const stateObj = this._controller.createStateObjectForStateBadge( + this.hass, + this.icon.entity, + ); + if (stateObj) { + return html``; + } + } + if (this.icon?.fallback) { + return html``; + } + return html``; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(iconStyle); + } +} + +declare global { + interface HTMLElementTagNameMap { + 'frigate-card-icon': FrigateCardIcon; + } +} diff --git a/src/components/key-assigner.ts b/src/components/key-assigner.ts index 3312dfec..d5af5717 100644 --- a/src/components/key-assigner.ts +++ b/src/components/key-assigner.ts @@ -12,6 +12,7 @@ import { KeyAssignerController } from '../components-lib/key-assigner-controller import { KeyboardShortcut } from '../config/keyboard-shortcuts'; import keyAssignerStyle from '../scss/key-assigner.scss'; import { localize } from '../localize/localize'; +import './icon'; @customElement('frigate-card-key-assigner') export class FrigateCardKeyAssigner extends LitElement { @@ -48,15 +49,11 @@ export class FrigateCardKeyAssigner extends LitElement { this._controller.toggleAssigning(); }} > - + - ${ - this._controller.isAssigning() - ? localize('key_assigner.assigning') - : localize('key_assigner.assign') - } + ${this._controller.isAssigning() ? '' : localize('key_assigner.assign')} ${ @@ -66,7 +63,9 @@ export class FrigateCardKeyAssigner extends LitElement { this._controller.setValue(null); }} > - + ${localize('key_assigner.unassign')} ` : '' diff --git a/src/components/live/provider.ts b/src/components/live/provider.ts index 39b33c62..3d3ac811 100644 --- a/src/components/live/provider.ts +++ b/src/components/live/provider.ts @@ -11,6 +11,7 @@ import { classMap } from 'lit/directives/class-map.js'; import { guard } from 'lit/directives/guard.js'; import { createRef, Ref, ref } from 'lit/directives/ref.js'; import { CameraEndpoints } from '../../camera-manager/types.js'; +import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js'; import { PartialZoomSettings } from '../../components-lib/zoom/types.js'; import { CameraConfig, @@ -26,11 +27,11 @@ import { aspectRatioToString } from '../../utils/basic.js'; import { dispatchMediaUnloadedEvent } from '../../utils/media-info.js'; import { updateElementStyleFromMediaLayoutConfig } from '../../utils/media-layout.js'; import { playMediaMutingIfNecessary } from '../../utils/media.js'; +import '../icon.js'; import { renderMessage } from '../message.js'; import '../next-prev-control.js'; import '../ptz.js'; import '../surround.js'; -import { dispatchLiveErrorEvent } from '../../components-lib/live/utils/dispatch-live-error.js'; @customElement('frigate-card-live-provider') export class FrigateCardLiveProvider @@ -385,10 +386,10 @@ export class FrigateCardLiveProvider : html``} `)} ${showImageDuringLoading && !this._isVideoMediaLoaded - ? html`` + .icon=${{ icon: 'mdi:progress-helper' }} + >` : ''} `; } diff --git a/src/components/menu.ts b/src/components/menu.ts index dc20faf8..df47f1d9 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -1,15 +1,15 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import { ifDefined } from 'lit/directives/if-defined.js'; import { styleMap } from 'lit/directives/style-map.js'; import { actionHandler } from '../action-handler-directive.js'; import { MenuController } from '../components-lib/menu-controller.js'; import type { MenuConfig, MenuItem } from '../config/types.js'; import menuStyle from '../scss/menu.scss'; import { frigateCardHasAction } from '../utils/action.js'; -import { getCustomIconURL } from '../utils/custom-icons.js'; +import { getEntityTitle } from '../utils/ha/index.js'; import { EntityRegistryManager } from '../utils/ha/registry/entity/index.js'; +import './icon.js'; import './submenu.js'; @customElement('frigate-card-menu') @@ -60,38 +60,30 @@ export class FrigateCardMenu extends LitElement { `; } - // ===================================================================================== - // For `data-domain` and `data-state`, see: See - // https://github.com/home-assistant/frontend/blob/dev/src/components/entity/state-badge.ts#L54 - // ===================================================================================== - // Buttons are styled in a few ways (in order of precedence): - // - // - User provided style - // - Color/Brightness styling for the `light` domain (calculated in - // `refreshDynamicStateParameters`) - // - Static styling based on domain (`data-domain`) and state - // (`data-state`). This looks up a CSS style in `menu.scss`. - - const buttonState = this._controller.getFreshButtonState(this.hass, button); - const customImageURL = getCustomIconURL(buttonState.icon); + const title = + this.hass && button.type === 'custom:frigate-card-menu-state-icon' && !button.title + ? getEntityTitle(this.hass, button.entity) + : button.title; return html` this._controller.actionHandler(ev, button)} > - ${customImageURL - ? html`` - : html``} + `; } diff --git a/src/components/message.ts b/src/components/message.ts index 20fd56b6..41b344cd 100644 --- a/src/components/message.ts +++ b/src/components/message.ts @@ -9,6 +9,7 @@ import { localize } from '../localize/localize.js'; import messageStyle from '../scss/message.scss'; import { FrigateCardError, Message, MessageType } from '../types.js'; import { dispatchFrigateCardEvent } from '../utils/basic.js'; +import './icon.js'; @customElement('frigate-card-message') export class FrigateCardMessage extends LitElement { @@ -38,7 +39,7 @@ export class FrigateCardMessage extends LitElement { return html`
- +
@@ -103,9 +104,11 @@ export class FrigateCardProgressIndicator extends LitElement { protected render(): TemplateResult { return html`
${this.animated - ? html` + ? html` ` - : html``} + : html``} ${this.message ? html`${this.message}` : html``}
`; } diff --git a/src/components/next-prev-control.ts b/src/components/next-prev-control.ts index 83b5695b..7b1c4a6d 100644 --- a/src/components/next-prev-control.ts +++ b/src/components/next-prev-control.ts @@ -4,6 +4,7 @@ import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; import { NextPreviousControlConfig } from '../config/types.js'; import controlStyle from '../scss/next-previous-control.scss'; +import { Icon } from '../types.js'; import { renderTask } from '../utils/task.js'; import { createFetchThumbnailTask } from '../utils/thumbnail.js'; @@ -29,7 +30,7 @@ export class FrigateCardNextPreviousControl extends LitElement { public thumbnail?: string; @property({ attribute: false }) - public icon?: string; + public icon?: Icon; @property({ attribute: true, type: Boolean }) public disabled = false; @@ -67,17 +68,14 @@ export class FrigateCardNextPreviousControl extends LitElement { if (renderIcon) { const icon = - !this.thumbnail || - !this.icon || - this._controlConfig.style === 'chevrons' || - this._thumbnailError - ? this.side === 'left' - ? 'mdi:chevron-left' - : 'mdi:chevron-right' - : this.icon; + this.icon && !this._thumbnailError && this._controlConfig.style !== 'chevrons' + ? this.icon + : this.side === 'left' + ? { icon: 'mdi:chevron-left' } + : { icon: 'mdi:chevron-right' }; return html` - + `; } diff --git a/src/components/ptz.ts b/src/components/ptz.ts index 45355010..d9639a50 100644 --- a/src/components/ptz.ts +++ b/src/components/ptz.ts @@ -17,6 +17,7 @@ import { Actions, PTZControlsConfig } from '../config/types.js'; import { localize } from '../localize/localize.js'; import ptzStyle from '../scss/ptz.scss'; import { frigateCardHasAction } from '../utils/action.js'; +import './icon.js'; @customElement('frigate-card-ptz') export class FrigateCardPTZ extends LitElement { @@ -67,9 +68,9 @@ export class FrigateCardPTZ extends LitElement { }; return actions - ? html`) => this._controller.handleAction(ev, actions)} - >` + >` : html``; }; diff --git a/src/components/status-bar.ts b/src/components/status-bar.ts index 3948c1be..33750278 100644 --- a/src/components/status-bar.ts +++ b/src/components/status-bar.ts @@ -13,7 +13,7 @@ import { StatusBarController } from '../components-lib/status-bar-controller'; import { StatusBarConfig, StatusBarItem } from '../config/types'; import statusStyle from '../scss/status.scss'; import { frigateCardHasAction } from '../utils/action'; -import { getCustomIconURL } from '../utils/custom-icons.js'; +import './icon.js'; @customElement('frigate-card-status-bar') export class FrigateCardStatusBar extends LitElement { @@ -68,20 +68,12 @@ export class FrigateCardStatusBar extends LitElement { ${item.string}
`; } else if (item.type === 'custom:frigate-card-status-bar-icon') { - const customIconURL = getCustomIconURL(item.icon); - return customIconURL - ? html` this._controller.actionHandler(ev, item.actions)} - />` - : html` this._controller.actionHandler(ev, item.actions)} - >`; + return html` this._controller.actionHandler(ev, item.actions)} + >`; } else if (item.type === 'custom:frigate-card-status-bar-image') { return html` { - if (stateParameters.icon) { - const url = getCustomIconURL(stateParameters.icon); - return url - ? html`` - : html` - `; - } - return html``; - }; + const title = item.title ?? getEntityTitle(this.hass, item.entity); return html` { // Attach the action config so ascendants have access to it. ev.detail.config = item; @@ -80,9 +61,17 @@ export class FrigateCardSubmenu extends LitElement { hasDoubleClick: frigateCardHasAction(item.double_tap_action), })} > - ${stateParameters.title || ''} + ${title ?? ''} ${item.subtitle ? html`${item.subtitle}` : ''} - ${getIcon(stateParameters)} + `; } @@ -118,7 +107,14 @@ export class FrigateCardSubmenu extends LitElement { hasDoubleClick: frigateCardHasAction(this.submenu.double_tap_action), })} > - + ${items.map(this._renderItem.bind(this))} @@ -207,18 +203,19 @@ export class FrigateCardSubmenuSelect extends LitElement { return; } + const title = getEntityTitle(this.hass, entityID); const submenu: MenuSubmenu = { - // Default icon. It should be impossible for this to be used, since - // this.submenuSelect will always have an entity, which means - // refreshDynamicStateParameters will always return an icon. - icon: domainIcon('select'), - - // Pull out the dynamic properties (like icon, and title) from the state. - ...refreshDynamicStateParameters(this.hass, this.submenuSelect as StateParameters), + ...(title && { title }), // Override it with anything explicitly set in the submenuSelect. ...this.submenuSelect, + icon: { + icon: this.submenuSelect.icon, + entity: entityID, + fallback: 'mdi:format-list-bulleted', + }, + type: 'custom:frigate-card-menu-submenu', items: [], }; diff --git a/src/components/thumbnail.ts b/src/components/thumbnail.ts index 6070487b..056c692f 100644 --- a/src/components/thumbnail.ts +++ b/src/components/thumbnail.ts @@ -105,10 +105,10 @@ export class FrigateCardThumbnailFeatureThumbnail extends LitElement { } protected render(): TemplateResult | void { - const imageOff = html` `; + > `; if (!this._embedThumbnailTask || this._thumbnailError) { return imageOff; @@ -150,8 +150,11 @@ export class FrigateCardThumbnailFeatureText extends LitElement { return; } return html` - ${this.cameraMetadata?.engineLogo - ? html`` + ${this.cameraMetadata?.engineIcon + ? html`` : ''}
${format(this.date, 'HH:mm')}
@@ -211,18 +214,18 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
${startTime ? html`
- + .icon=${{ icon: 'mdi:calendar-clock-outline' }} + > ${startTime}
${duration || inProgress ? html`
- + .icon=${{ icon: 'mdi:clock-outline' }} + > ${duration ? html`${duration}` : ''} ${inProgress ? html`${inProgress}` @@ -232,31 +235,37 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement { : ''} ${this.cameraTitle ? html`
- + ${this.cameraTitle}
` : ''} ${where ? html`
- + .icon=${{ icon: 'mdi:map-marker-outline' }} + > ${where}
` : html``} ${tags ? html`
- + ${tags}
` : html``} ${seek ? html`
- + .icon=${{ icon: 'mdi:clock-fast' }} + > ${seek}
` : html``} @@ -306,18 +315,18 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
${startTime ? html`
- + .icon=${{ icon: 'mdi:calendar-clock-outline' }} + > ${startTime}
${duration || inProgress ? html`
- + .icon=${{ icon: 'mdi:clock-outline' }} + > ${duration ? html`${duration}` : ''} ${inProgress ? html`${inProgress}` @@ -327,19 +336,19 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement { : ''} ${seek ? html`
- + .icon=${{ icon: 'mdi:clock-fast' }} + > ${seek}
` : html``} ${eventCount !== null ? html`
- + .icon=${{ icon: 'mdi:shield-alert' }} + > ${eventCount}
` : ``} @@ -448,10 +457,10 @@ export class FrigateCardThumbnail extends LitElement { >` : html``} ${shouldShowFavoriteControl - ? html` { stopEventFromActivatingCardWideActions(ev); if (this.hass && this.media) { @@ -467,7 +476,7 @@ export class FrigateCardThumbnail extends LitElement { this.requestUpdate(); } }} - />` + />` : ``} ${this.details && ViewMediaClassifier.isEvent(this.media) ? html`` : html``} ${shouldShowTimelineControl - ? html` { stopEventFromActivatingCardWideActions(ev); @@ -503,12 +512,12 @@ export class FrigateCardThumbnail extends LitElement { modifiers: [new RemoveContextViewModifier(['timeline'])], }); }} - >` + >` : ''} ${shouldShowDownloadControl - ? html` { stopEventFromActivatingCardWideActions(ev); @@ -520,7 +529,7 @@ export class FrigateCardThumbnail extends LitElement { } } }} - >` + >` : ``} `; } diff --git a/src/components/timeline-core.ts b/src/components/timeline-core.ts index d204c58d..d0525222 100644 --- a/src/components/timeline-core.ts +++ b/src/components/timeline-core.ts @@ -69,6 +69,7 @@ import { MediaQueriesResults } from '../view/media-queries-results'; import { mergeViewContext } from '../view/view'; import './date-picker.js'; import { DatePickerEvent, FrigateCardDatePicker } from './date-picker.js'; +import './icon'; import './thumbnail.js'; interface FrigateCardGroupData { @@ -304,8 +305,8 @@ export class FrigateCardTimelineCore extends LitElement { >
${this._shouldSupportSeeking() - ? html` { this._panMode = panMode === 'pan' @@ -319,7 +320,7 @@ export class FrigateCardTimelineCore extends LitElement { aria-label="${panTitle}" title="${panTitle}" > - ` + ` : ''} ` : ''}
- - + +
`; } diff --git a/src/config/types.ts b/src/config/types.ts index dc916256..e7b379e0 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1721,7 +1721,7 @@ const statusBarItemDefault = { }; const statusBarConfigDefault = { - height: 46, + height: 30, items: { engine: statusBarItemDefault, resolution: statusBarItemDefault, diff --git a/src/editor.ts b/src/editor.ts index 4605c7c8..fc882a6d 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -6,6 +6,7 @@ import { import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; +import './components/icon.js'; import './components/key-assigner.js'; import { KeyboardShortcut } from './config/keyboard-shortcuts.js'; import { @@ -32,9 +33,10 @@ import { THUMBNAIL_WIDTH_MIN, } from './config/types.js'; import { + CONF_CAMERAS, CONF_CAMERAS_ARRAY_CAMERA_ENTITY, - CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT, CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE, + CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT, CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH, CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH, CONF_CAMERAS_ARRAY_CAST_METHOD, @@ -60,8 +62,8 @@ import { CONF_CAMERAS_ARRAY_GO2RTC_STREAM, CONF_CAMERAS_ARRAY_ICON, CONF_CAMERAS_ARRAY_ID, - CONF_CAMERAS_ARRAY_IMAGE_ENTITY_PARAMETERS, CONF_CAMERAS_ARRAY_IMAGE_ENTITY, + CONF_CAMERAS_ARRAY_IMAGE_ENTITY_PARAMETERS, CONF_CAMERAS_ARRAY_IMAGE_MODE, CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS, CONF_CAMERAS_ARRAY_IMAGE_URL, @@ -84,12 +86,11 @@ import { CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY, CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY, CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, - CONF_CAMERAS, - CONF_DIMENSIONS_ASPECT_RATIO_MODE, CONF_DIMENSIONS_ASPECT_RATIO, + CONF_DIMENSIONS_ASPECT_RATIO_MODE, CONF_DIMENSIONS_HEIGHT, - CONF_IMAGE_ENTITY_PARAMETERS, CONF_IMAGE_ENTITY, + CONF_IMAGE_ENTITY_PARAMETERS, CONF_IMAGE_MODE, CONF_IMAGE_REFRESH_SECONDS, CONF_IMAGE_URL, @@ -200,14 +201,15 @@ import { CONF_TIMELINE_WINDOW_SECONDS, CONF_VIEW_CAMERA_SELECT, CONF_VIEW_DARK_MODE, + CONF_VIEW_DEFAULT, CONF_VIEW_DEFAULT_CYCLE_CAMERA, + CONF_VIEW_DEFAULT_RESET, CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION, CONF_VIEW_DEFAULT_RESET_ENTITIES, CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS, CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE, - CONF_VIEW_DEFAULT_RESET, - CONF_VIEW_DEFAULT, CONF_VIEW_INTERACTION_SECONDS, + CONF_VIEW_KEYBOARD_SHORTCUTS, CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED, CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_DOWN, CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_HOME, @@ -216,22 +218,20 @@ import { CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP, CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN, CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT, - CONF_VIEW_KEYBOARD_SHORTCUTS, + CONF_VIEW_TRIGGERS, + CONF_VIEW_TRIGGERS_ACTIONS, CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE, CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER, - CONF_VIEW_TRIGGERS_ACTIONS, CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA, CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS, CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS, - CONF_VIEW_TRIGGERS, MEDIA_CHUNK_SIZE_MAX, } from './const.js'; import { localize } from './localize/localize.js'; import frigate_card_editor_style from './scss/editor.scss'; import { arrayMove, prettifyTitle } from './utils/basic.js'; import { getCameraID } from './utils/camera.js'; -import { getCustomIconURL } from './utils/custom-icons.js'; import { getEntitiesFromHASS, getEntityTitle, @@ -960,7 +960,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor .key=${optionSetName} >
- +
${optionSet.name}
${optionSet.secondary}
@@ -1406,7 +1408,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor selected: selected, }; - const customIconURL = getCustomIconURL(icon); return html`
${selected ? html`
${template}
` : ''} @@ -1909,7 +1908,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor .domain=${MENU_CAMERAS} .key=${cameraIndex} > - + ${addNewCamera ? html` @@ -1944,7 +1945,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor return false; })} > - + - + - +
${this._renderEntitySelector( diff --git a/src/localize/languages/ca.json b/src/localize/languages/ca.json index 5dce88f6..229a9026 100644 --- a/src/localize/languages/ca.json +++ b/src/localize/languages/ca.json @@ -660,7 +660,6 @@ }, "key_assigner": { "assign": "", - "assigning": "", "modifiers": { "alt": "", "ctrl": "", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 0c52e71f..41f67155 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -660,7 +660,6 @@ }, "key_assigner": { "assign": "Assign", - "assigning": "Assigning", "modifiers": { "alt": "Alt", "ctrl": "Ctrl", diff --git a/src/localize/languages/fr.json b/src/localize/languages/fr.json index 0efcab15..bdf3d02e 100644 --- a/src/localize/languages/fr.json +++ b/src/localize/languages/fr.json @@ -660,7 +660,6 @@ }, "key_assigner": { "assign": "Assigner", - "assigning": "Assignation", "modifiers": { "alt": "", "ctrl": "", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index a455579a..528f09f4 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -660,7 +660,6 @@ }, "key_assigner": { "assign": "", - "assigning": "", "modifiers": { "alt": "", "ctrl": "", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 7bb4b6b3..c15db734 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -660,7 +660,6 @@ }, "key_assigner": { "assign": "", - "assigning": "", "modifiers": { "alt": "", "ctrl": "", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index f11e7e5e..d9a96161 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -660,7 +660,6 @@ }, "key_assigner": { "assign": "", - "assigning": "", "modifiers": { "alt": "", "ctrl": "", diff --git a/src/scss/button.scss b/src/scss/button.scss index 9ff1fa85..ea92c2cc 100644 --- a/src/scss/button.scss +++ b/src/scss/button.scss @@ -5,93 +5,8 @@ ha-icon-button.button { padding: 0px; margin: 3px; --ha-icon-display: block; + /* Buttons can always be clicked */ pointer-events: auto; opacity: 0.9; } - -// ===================================================================================== -// From: https://github.com/home-assistant/frontend/blob/dev/src/common/style/icon_color_css.ts -// Modified version for ha-icon and ha-icon-button. -// ===================================================================================== -@keyframes pulse { - 0% { - opacity: 1; - } - 50% { - opacity: 0.6; - } - 100% { - opacity: 1; - } -} - -@mixin state-icon($element) { - #{$element}[data-domain='alert'][data-state='on'], - #{$element}[data-domain='automation'][data-state='on'], - #{$element}[data-domain='binary_sensor'][data-state='on'], - #{$element}[data-domain='calendar'][data-state='on'], - #{$element}[data-domain='camera'][data-state='streaming'], - #{$element}[data-domain='cover'][data-state='open'], - #{$element}[data-domain='fan'][data-state='on'], - #{$element}[data-domain='humidifier'][data-state='on'], - #{$element}[data-domain='light'][data-state='on'], - #{$element}[data-domain='input_boolean'][data-state='on'], - #{$element}[data-domain='lock'][data-state='unlocked'], - #{$element}[data-domain='media_player'][data-state='on'], - #{$element}[data-domain='media_player'][data-state='paused'], - #{$element}[data-domain='media_player'][data-state='playing'], - #{$element}[data-domain='script'][data-state='on'], - #{$element}[data-domain='sun'][data-state='above_horizon'], - #{$element}[data-domain='switch'][data-state='on'], - #{$element}[data-domain='timer'][data-state='active'], - #{$element}[data-domain='vacuum'][data-state='cleaning'], - #{$element}[data-domain='group'][data-state='on'], - #{$element}[data-domain='group'][data-state='home'], - #{$element}[data-domain='group'][data-state='open'], - #{$element}[data-domain='group'][data-state='locked'], - #{$element}[data-domain='group'][data-state='problem'] { - color: var(--paper-item-icon-active-color, #fdd835); - } - - #{$element}[data-domain='climate'][data-state='cooling'] { - color: var(--cool-color, var(--state-climate-cool-color)); - } - - #{$element}[data-domain='climate'][data-state='heating'] { - color: var(--heat-color, var(--state-climate-heat-color)); - } - - #{$element}[data-domain='climate'][data-state='drying'] { - color: var(--dry-color, var(--state-climate-dry-color)); - } - - #{$element}[data-domain='alarm_control_panel'] { - color: var(--alarm-color-armed, var(--label-badge-red)); - } - #{$element}[data-domain='alarm_control_panel'][data-state='disarmed'] { - color: var(--alarm-color-disarmed, var(--label-badge-green)); - } - #{$element}[data-domain='alarm_control_panel'][data-state='pending'], - #{$element}[data-domain='alarm_control_panel'][data-state='arming'] { - color: var(--alarm-color-pending, var(--label-badge-yellow)); - animation: pulse 1s infinite; - } - #{$element}[data-domain='alarm_control_panel'][data-state='triggered'] { - color: var(--alarm-color-triggered, var(--label-badge-red)); - animation: pulse 1s infinite; - } - - #{$element}[data-domain='plant'][data-state='problem'], - #{$element}[data-domain='zwave'][data-state='dead'] { - color: var(--state-icon-error-color); - } - - /* Color the icon if unavailable */ - #{$element}[data-state='unavailable'] { - color: var(--state-unavailable-color); - } -} - -@include state-icon('ha-icon'); -@include state-icon('ha-icon-button'); diff --git a/src/scss/date-picker.scss b/src/scss/date-picker.scss index ae82974d..92fc9d45 100644 --- a/src/scss/date-picker.scss +++ b/src/scss/date-picker.scss @@ -42,7 +42,7 @@ input { } } -ha-icon { +frigate-card-icon { display: block; height: 100%; width: 100%; diff --git a/src/scss/drawer.scss b/src/scss/drawer.scss index 7c100e9a..466f2183 100644 --- a/src/scss/drawer.scss +++ b/src/scss/drawer.scss @@ -36,7 +36,7 @@ div.control-surround { visibility: visible; } -ha-icon.control { +frigate-card-icon.control { color: var(--secondary-color, white); background-color: rgba(0, 0, 0, 0.7); opacity: 0.5; @@ -49,20 +49,20 @@ ha-icon.control { transition: opacity 0.5s ease; } -:host([open]) ha-icon.control, -ha-icon.control:hover { +:host([open]) frigate-card-icon.control, +frigate-card-icon.control:hover { // When the drawer is open or hovered make the button to close it more // prominent. opacity: 1; background-color: black; } -:host([location='left']) ha-icon.control { +:host([location='left']) frigate-card-icon.control { border-top-right-radius: $drawer-icon-size; border-bottom-right-radius: $drawer-icon-size; } -:host([location='right']) ha-icon.control { +:host([location='right']) frigate-card-icon.control { border-top-left-radius: $drawer-icon-size; border-bottom-left-radius: $drawer-icon-size; } diff --git a/src/scss/editor.scss b/src/scss/editor.scss index a7139ec9..a9982070 100644 --- a/src/scss/editor.scss +++ b/src/scss/editor.scss @@ -70,9 +70,8 @@ div.upgrade span { color: var(--secondary-text-color, 'black'); } -.submenu-header ha-icon, -.submenu-header img { - padding-right: 15px; +.submenu-header frigate-card-icon { + margin-right: 15px; } .submenu.selected { diff --git a/src/scss/icon.scss b/src/scss/icon.scss new file mode 100644 index 00000000..3826c6b3 --- /dev/null +++ b/src/scss/icon.scss @@ -0,0 +1,14 @@ +:host { + display: inline-block; + width: var(--mdc-icon-size, 24px); + height: var(--mdc-icon-size, 24px); + + --ha-icon-display: block; +} + +* { + display: block; + height: 100%; + width: 100%; + box-sizing: border-box; +} diff --git a/src/scss/key-assigner.scss b/src/scss/key-assigner.scss index e2fa20dc..dfb85002 100644 --- a/src/scss/key-assigner.scss +++ b/src/scss/key-assigner.scss @@ -12,11 +12,11 @@ } :host([assigning]) ha-button.assign span, -:host([assigning]) ha-button.assign ha-icon { +:host([assigning]) ha-button.assign frigate-card-icon { color: var(--warning-color); } -ha-icon { +frigate-card-icon { padding: 10px; } diff --git a/src/scss/live-provider.scss b/src/scss/live-provider.scss index 104506a1..24d385bb 100644 --- a/src/scss/live-provider.scss +++ b/src/scss/live-provider.scss @@ -11,7 +11,7 @@ display: none; } -ha-icon { +frigate-card-icon { position: absolute; top: 10px; right: 10px; diff --git a/src/scss/ptz.scss b/src/scss/ptz.scss index b21e62bb..c3ad73f9 100644 --- a/src/scss/ptz.scss +++ b/src/scss/ptz.scss @@ -84,11 +84,11 @@ /*********** * PTZ Icons ***********/ -ha-icon { +frigate-card-icon { position: absolute; --mdc-icon-size: var(--frigate-card-ptz-icon-size); } -ha-icon:not(.disabled) { +frigate-card-icon:not(.disabled) { cursor: pointer; } .disabled { diff --git a/src/scss/status.scss b/src/scss/status.scss index d68e5de4..fef91a30 100644 --- a/src/scss/status.scss +++ b/src/scss/status.scss @@ -1,8 +1,7 @@ @use './button.scss'; :host { - --mdc-icon-button-size: calc(var(--frigate-card-status-bar-height) - 6px); - --mdc-icon-size: calc(var(--mdc-icon-button-size) / 2); + --mdc-icon-size: calc(var(--frigate-card-status-bar-height) - 6px); display: block; width: 100%; @@ -61,8 +60,7 @@ .item { display: inline-block; - margin: 3px; - padding: 3px; + margin: 3px 5px; align-content: center; @@ -84,6 +82,10 @@ } img.item { - width: var(--mdc-icon-size, 24px); + display: block; + + // To ensure images render somewhat reasonably looking their height is kept to + // the same height as icons. height: var(--mdc-icon-size, 24px); + width: auto; } diff --git a/src/scss/thumbnail-details.scss b/src/scss/thumbnail-details.scss index e6265dc2..77da5f4f 100644 --- a/src/scss/thumbnail-details.scss +++ b/src/scss/thumbnail-details.scss @@ -27,3 +27,12 @@ div.details { // Details panel can shrink as well as grow. min-height: 0px; } + +div.details div { + display: flex; + align-items: center; +} + +div.details div * { + margin: 0px 3px; +} diff --git a/src/scss/thumbnail-feature-text.scss b/src/scss/thumbnail-feature-text.scss index e0c71ce4..e4cec7b4 100644 --- a/src/scss/thumbnail-feature-text.scss +++ b/src/scss/thumbnail-feature-text.scss @@ -21,7 +21,7 @@ position: relative; } -img.background { +frigate-card-icon.background { display: block; width: 100%; height: 100%; diff --git a/src/scss/thumbnail-feature-thumbnail.scss b/src/scss/thumbnail-feature-thumbnail.scss index 1ed44506..cc75a31a 100644 --- a/src/scss/thumbnail-feature-thumbnail.scss +++ b/src/scss/thumbnail-feature-thumbnail.scss @@ -14,7 +14,7 @@ img { } img, -ha-icon { +frigate-card-icon { // Safari will occasionally not load thumbnails correctly with display block. display: inline-block; @@ -37,7 +37,7 @@ ha-icon { object-fit: cover; } -ha-icon { +frigate-card-icon { --mdc-icon-size: 50%; color: var(--primary-text-color); display: flex; diff --git a/src/scss/thumbnail.scss b/src/scss/thumbnail.scss index a585b793..c0cc6638 100644 --- a/src/scss/thumbnail.scss +++ b/src/scss/thumbnail.scss @@ -31,7 +31,7 @@ transform: scale(1.04); } -ha-icon { +frigate-card-icon { position: absolute; border-radius: 50%; opacity: 0.5; @@ -43,24 +43,24 @@ ha-icon { opacity 0.2s ease-in-out, color 0.2s ease-in-out; } -ha-icon:hover { +frigate-card-icon:hover { opacity: 1; } -ha-icon.star { +frigate-card-icon.star { top: 3px; left: 3px; } -ha-icon.star.starred { +frigate-card-icon.star.starred { color: gold; } -ha-icon.timeline { +frigate-card-icon.timeline { top: 3px; right: 3px; } -ha-icon.download { +frigate-card-icon.download { right: 3px; bottom: 3px; } diff --git a/src/scss/timeline-core.scss b/src/scss/timeline-core.scss index 1221a39c..0de57ac7 100644 --- a/src/scss/timeline-core.scss +++ b/src/scss/timeline-core.scss @@ -174,12 +174,15 @@ div.vis-tooltip { .timeline-tools { display: inline-flex; position: absolute; - right: 2px; - bottom: 2px; + right: 0px; + bottom: 0px; color: var(--primary-color); z-index: 10; } - -.timeline-tools ha-icon { +.timeline-tools * { + margin: 2px 5px; cursor: pointer; } +.timeline-tools *:last-child { + margin-right: 10px; +} diff --git a/src/types.ts b/src/types.ts index 447447e9..5ded0c3b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,7 +5,6 @@ import { LovelaceCardEditor, Themes, } from '@dermotduffy/custom-card-helpers'; -import { StyleInfo } from 'lit/directives/style-map.js'; import { z } from 'zod'; export type ClipsOrSnapshots = 'clips' | 'snapshots'; @@ -65,16 +64,6 @@ export interface Message { dotdotdot?: boolean; } -export interface StateParameters { - entity?: string; - icon?: string; - title?: string | null; - state_color?: boolean; - style?: StyleInfo; - data_domain?: string; - data_state?: string; -} - export interface FrigateCardMediaPlayer { play(): Promise; pause(): Promise; @@ -143,6 +132,21 @@ export const capabilityKeys: readonly [CapabilityKey, ...CapabilityKey[]] = [ 'substream', ] as const; +export interface Icon { + // If set, this icon will be used. + icon?: string; + + // If icon is not set, this entity's icon will be used (and HA will be asked + // to render it). + entity?: string; + + // Whether or not to change the icon color depending on entity state. + stateColor?: boolean; + + // If an icon is not otherwise resolved / available, this will be used instead. + fallback?: string; +} + // ************************************************************************* // Home Assistant API types. // ************************************************************************* diff --git a/src/utils/custom-icons.ts b/src/utils/custom-icons.ts deleted file mode 100644 index 2054dc89..00000000 --- a/src/utils/custom-icons.ts +++ /dev/null @@ -1,16 +0,0 @@ -import frigateSVG from '../camera-manager/frigate/assets/frigate.svg'; -import motioneyeSVG from '../camera-manager/motioneye/assets/motioneye.svg'; -import reolinkSVG from '../camera-manager/reolink/assets/reolink.svg'; - -export const getCustomIconURL = (icon?: string): string | null => { - switch (icon) { - case 'frigate': - return frigateSVG; - case 'motioneye': - return motioneyeSVG; - case 'reolink': - return reolinkSVG; - default: - return null; - } -}; diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts index 7079f585..46b4a35e 100644 --- a/src/utils/ha/index.ts +++ b/src/utils/ha/index.ts @@ -1,19 +1,12 @@ -import { - computeDomain, - computeStateDomain, - HomeAssistant, -} from '@dermotduffy/custom-card-helpers'; +import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; import { HassEntity } from 'home-assistant-js-websocket'; -import { StyleInfo } from 'lit/directives/style-map.js'; import { CardHelpers, ExtendedHomeAssistant, LovelaceCardWithEditor, SignedPath, signedPathSchema, - StateParameters, } from '../../types.js'; -import { domainIcon } from '../icons/domain-icon.js'; import { homeAssistantWSRequest } from './ws-request.js'; /** @@ -116,128 +109,14 @@ export function isHassDifferent( }).length; } -/** - * Calculate a style brightness from a hass state. - * Inspired by https://github.com/home-assistant/frontend/blob/7d5b5663123bb16d1da0c5bac3f2fc26d5f69ae8/src/panels/lovelace/cards/hui-button-card.ts#L296 - * @param state The hass state object. - * @returns A CSS brightness string. - */ -function computeBrightnessFromState(state: HassEntity): string { - if (state.state === 'off' || !state.attributes.brightness) { - return ''; - } - const brightness = state.attributes.brightness; - return `brightness(${(brightness + 245) / 5}%)`; -} - -/** - * Calculate a style color from a hass state. - * Inspired by https://github.com/home-assistant/frontend/blob/7d5b5663123bb16d1da0c5bac3f2fc26d5f69ae8/src/panels/lovelace/cards/hui-button-card.ts#L304 - * @param state The hass state object. - * @returns A CSS color string. - */ -function computeColorFromState(state: HassEntity): string { - if (state.state === 'off') { - return ''; - } - return state.attributes.rgb_color - ? `rgb(${state.attributes.rgb_color.join(',')})` - : ''; -} - -/** - * Get the style of emphasized menu items. - * @returns A StyleInfo. - */ -function computeStyle(state: HassEntity): StyleInfo { - return { - color: computeColorFromState(state), - filter: computeBrightnessFromState(state), - }; -} - -/** - * Determine the string state of a given stateObj. - * From: https://github.com/home-assistant/frontend/blob/dev/src/common/entity/compute_active_state.ts - * @param stateObj The HassEntity object from `hass.states`. - * @returns A string state, e.g. 'on'. - */ -const computeActiveState = (stateObj: HassEntity): string => { - const domain = stateObj.entity_id.split('.')[0]; - let state = stateObj.state; - - if (domain === 'climate') { - state = stateObj.attributes.hvac_action; - } - - return state; -}; - -/** - * Use Home Assistant state to refresh state parameters for an item to be rendered. - * @param hass Home Assistant object. - * @param params A StateParameters object to modify in place. - * @returns A StateParameters object updated based on HASS state. - */ -export function refreshDynamicStateParameters( - hass: HomeAssistant, - params: StateParameters, -): StateParameters { - if (!params.entity) { - return params; - } - const state = hass.states[params.entity]; - if (!!state && !!params.state_color) { - params.style = { ...computeStyle(state), ...params.style }; - } - params.title = params.title ?? (state?.attributes?.friendly_name || params.entity); - params.icon = params.icon ?? getEntityIcon(hass, params.entity); - - const domain = state ? computeStateDomain(state) : undefined; - params.data_domain = - params.state_color || (domain === 'light' && params.state_color !== false) - ? domain - : undefined; - if (state) { - params.data_state = computeActiveState(state); - } - return params; -} - /** * Get the title of an entity. * @param entity The entity id. * @param hass The Home Assistant object. * @returns The title or undefined. */ -export function getEntityTitle( - hass?: HomeAssistant, - entity?: string, -): string | undefined { - return entity ? hass?.states[entity]?.attributes?.friendly_name : undefined; -} - -/** - * Get the icon of an entity. - * @param entityID The entity id. - * @param hass The Home Assistant object. - * @returns The icon or undefined. - */ -export function getEntityIcon( - hass: HomeAssistant, - entityID: string, - defaultIcon?: string, -): string { - const entityState = hass.states[entityID]; - if (entityState && entityState.attributes.icon) { - return entityState.attributes.icon; - } - return domainIcon( - computeDomain(entityID), - entityState, - entityState?.state, - defaultIcon, - ); +export function getEntityTitle(hass?: HomeAssistant, entity?: string): string | null { + return entity ? hass?.states[entity]?.attributes?.friendly_name ?? null : null; } /** @@ -253,12 +132,17 @@ export const sideLoadHomeAssistantElements = async (): Promise => { 'ha-camera-stream', 'ha-card', 'ha-circular-progress', + 'ha-combo-box', 'ha-hls-player', 'ha-icon-button', 'ha-icon', 'ha-menu-button', 'ha-selector', + 'ha-state-icon', 'ha-web-rtc-player', + 'mwc-button', + 'mwc-list-item', + 'state-badge', ]; if (neededElements.every((element) => customElements.get(element))) { @@ -367,3 +251,12 @@ export const hasHAConnectionStateChanged = ( ): boolean => { return oldHass?.connected !== newHass?.connected; }; + +/** + * Determine if a state object supports a given feature. + * @param stateObj The state object. + * @param feature The feature to check. + * @returns `true` if the feature is supported, `false` otherwise. + */ +export const supportsFeature = (stateObj: HassEntity, feature: number): boolean => + ((stateObj.attributes.supported_features ?? 0) & feature) !== 0; diff --git a/src/utils/ha/update.ts b/src/utils/ha/update.ts deleted file mode 100644 index 5ede0871..00000000 --- a/src/utils/ha/update.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - HassEntity, - HassEntityAttributeBase, - HassEntityBase, -} from 'home-assistant-js-websocket'; - -const UPDATE_SUPPORT_PROGRESS = 4; - -interface UpdateEntityAttributes extends HassEntityAttributeBase { - auto_update: boolean | null; - installed_version: string | null; - in_progress: boolean | number; - latest_version: string | null; - release_summary: string | null; - release_url: string | null; - skipped_version: string | null; - title: string | null; -} - -export interface UpdateEntity extends HassEntityBase { - attributes: UpdateEntityAttributes; -} - -export const supportsFeature = (stateObj: HassEntity, feature: number): boolean => - ((stateObj.attributes.supported_features ?? 0) & feature) !== 0; - -const updateUsesProgress = (entity: UpdateEntity): boolean => - supportsFeature(entity, UPDATE_SUPPORT_PROGRESS) && - typeof entity.attributes.in_progress === 'number'; - -export const updateIsInstalling = (entity: UpdateEntity): boolean => - updateUsesProgress(entity) || !!entity.attributes.in_progress; diff --git a/src/utils/icons/LICENSE b/src/utils/icons/LICENSE deleted file mode 100644 index 261eeb9e..00000000 --- a/src/utils/icons/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright [yyyy] [name of copyright owner] - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/src/utils/icons/README.md b/src/utils/icons/README.md deleted file mode 100644 index e1501184..00000000 --- a/src/utils/icons/README.md +++ /dev/null @@ -1,7 +0,0 @@ -## Icons - -These entity icon mappings are copied (with slight tweaks to work with this -card), from the [Lovelace Mushroom -Card](https://github.com/piitaya/lovelace-mushroom/tree/main/src/utils/icons), which are in turn copied from [the Home Assistant frontend](https://github.com/home-assistant/frontend/tree/dev/src/common/entity) in order to -replace the out of date entity icons in -[custom-card-helpers](https://github.com/custom-cards/custom-card-helpers). diff --git a/src/utils/icons/alarm-panel-icon.ts b/src/utils/icons/alarm-panel-icon.ts deleted file mode 100644 index b0bb5da9..00000000 --- a/src/utils/icons/alarm-panel-icon.ts +++ /dev/null @@ -1,23 +0,0 @@ -export const alarmPanelIcon = (state?: string) => { - switch (state) { - case 'armed_away': - return 'mdi:shield-lock'; - case 'armed_vacation': - return 'mdi:shield-airplane'; - case 'armed_home': - return 'mdi:shield-home'; - case 'armed_night': - return 'mdi:shield-moon'; - case 'armed_custom_bypass': - return 'mdi:security'; - case 'pending': - case 'arming': - return 'mdi:shield-sync'; - case 'triggered': - return 'mdi:bell-ring'; - case 'disarmed': - return 'mdi:shield-off'; - default: - return 'mdi:shield'; - } -}; diff --git a/src/utils/icons/binary-sensor-icon.ts b/src/utils/icons/binary-sensor-icon.ts deleted file mode 100644 index ec6a74b2..00000000 --- a/src/utils/icons/binary-sensor-icon.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { HassEntity } from 'home-assistant-js-websocket'; - -export const binarySensorIcon = (state?: string, entity?: HassEntity) => { - const isOff = state === 'off'; - switch (entity?.attributes.device_class) { - case 'battery': - return isOff ? 'mdi:battery' : 'mdi:battery-outline'; - case 'battery_charging': - return isOff ? 'mdi:battery' : 'mdi:battery-charging'; - case 'cold': - return isOff ? 'mdi:thermometer' : 'mdi:snowflake'; - case 'connectivity': - return isOff ? 'mdi:close-network-outline' : 'mdi:check-network-outline'; - case 'door': - return isOff ? 'mdi:door-closed' : 'mdi:door-open'; - case 'garage_door': - return isOff ? 'mdi:garage' : 'mdi:garage-open'; - case 'power': - return isOff ? 'mdi:power-plug-off' : 'mdi:power-plug'; - case 'gas': - case 'problem': - case 'safety': - case 'tamper': - return isOff ? 'mdi:check-circle' : 'mdi:alert-circle'; - case 'smoke': - return isOff ? 'mdi:check-circle' : 'mdi:smoke'; - case 'heat': - return isOff ? 'mdi:thermometer' : 'mdi:fire'; - case 'light': - return isOff ? 'mdi:brightness5' : 'mdi:brightness-7'; - case 'lock': - return isOff ? 'mdi:lock' : 'mdi:lock-open'; - case 'moisture': - return isOff ? 'mdi:water-off' : 'mdi:water'; - case 'motion': - return isOff ? 'mdi:motion-sensor-off' : 'mdi:motion-sensor'; - case 'occupancy': - return isOff ? 'mdi:home-outline' : 'mdi:home'; - case 'opening': - return isOff ? 'mdi:square' : 'mdi:square-outline'; - case 'plug': - return isOff ? 'mdi:power-plug-off' : 'mdi:power-plug'; - case 'presence': - return isOff ? 'mdi:home-outline' : 'mdi:home'; - case 'running': - return isOff ? 'mdi:stop' : 'mdi:play'; - case 'sound': - return isOff ? 'mdi:music-note-off' : 'mdi:music-note'; - case 'update': - return isOff ? 'mdi:package' : 'mdi:package-up'; - case 'vibration': - return isOff ? 'mdi:crop-portrait' : 'mdi:vibrate'; - case 'window': - return isOff ? 'mdi:window-closed' : 'mdi:window-open'; - default: - return isOff ? 'mdi:radiobox-blank' : 'mdi:checkbox-marked-circle'; - } -}; diff --git a/src/utils/icons/cover-icon.ts b/src/utils/icons/cover-icon.ts deleted file mode 100644 index 44b5985b..00000000 --- a/src/utils/icons/cover-icon.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { HassEntity } from 'home-assistant-js-websocket'; - -export const coverIcon = (state?: string, entity?: HassEntity): string => { - const open = state !== 'closed'; - - switch (entity?.attributes.device_class) { - case 'garage': - switch (state) { - case 'opening': - return 'mdi:arrow-up-box'; - case 'closing': - return 'mdi:arrow-down-box'; - case 'closed': - return 'mdi:garage'; - default: - return 'mdi:garage-open'; - } - case 'gate': - switch (state) { - case 'opening': - case 'closing': - return 'mdi:gate-arrow-right'; - case 'closed': - return 'mdi:gate'; - default: - return 'mdi:gate-open'; - } - case 'door': - return open ? 'mdi:door-open' : 'mdi:door-closed'; - case 'damper': - return open ? 'md:circle' : 'mdi:circle-slice-8'; - case 'shutter': - switch (state) { - case 'opening': - return 'mdi:arrow-up-box'; - case 'closing': - return 'mdi:arrow-down-box'; - case 'closed': - return 'mdi:window-shutter'; - default: - return 'mdi:window-shutter-open'; - } - case 'curtain': - switch (state) { - case 'opening': - return 'mdi:arrow-split-vertical'; - case 'closing': - return 'mdi:arrow-collapse-horizontal'; - case 'closed': - return 'mdi:curtains-closed'; - default: - return 'mdi:curtains'; - } - case 'blind': - case 'shade': - switch (state) { - case 'opening': - return 'mdi:arrow-up-box'; - case 'closing': - return 'mdi:arrow-down-box'; - case 'closed': - return 'mdi:blinds'; - default: - return 'mdi:blinds-open'; - } - case 'window': - switch (state) { - case 'opening': - return 'mdi:arrow-up-box'; - case 'closing': - return 'mdi:arrow-down-box'; - case 'closed': - return 'mdi:window-closed'; - default: - return 'mdi:window-open'; - } - } - - switch (state) { - case 'opening': - return 'mdi:arrow-up-box'; - case 'closing': - return 'mdi:arrow-down-box'; - case 'closed': - return 'mdi:window-closed'; - default: - return 'mdi:window-open'; - } -}; diff --git a/src/utils/icons/domain-icon.ts b/src/utils/icons/domain-icon.ts deleted file mode 100644 index 64dfab9a..00000000 --- a/src/utils/icons/domain-icon.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { HassEntity } from 'home-assistant-js-websocket'; -import { UpdateEntity, updateIsInstalling } from '../ha/update.js'; -import { alarmPanelIcon } from './alarm-panel-icon'; -import { binarySensorIcon } from './binary-sensor-icon'; -import { coverIcon } from './cover-icon'; -import { sensorIcon } from './sensor-icon'; - -const DEFAULT_DOMAIN_ICON = 'mdi:bookmark'; - -const FIXED_DOMAIN_ICONS = { - alert: 'mdi:alert', - air_quality: 'mdi:air-filter', - automation: 'mdi:robot', - calendar: 'mdi:calendar', - camera: 'mdi:video', - climate: 'mdi:thermostat', - configurator: 'mdi:cog', - conversation: 'mdi:text-to-speech', - counter: 'mdi:counter', - fan: 'mdi:fan', - google_assistant: 'mdi:google-assistant', - group: 'mdi:google-circles-communities', - homeassistant: 'mdi:home-assistant', - homekit: 'mdi:home-automation', - image_processing: 'mdi:image-filter-frames', - input_button: 'mdi:gesture-tap-button', - input_datetime: 'mdi:calendar-clock', - input_number: 'mdi:ray-vertex', - input_select: 'mdi:format-list-bulleted', - input_text: 'mdi:form-textbox', - light: 'mdi:lightbulb', - mailbox: 'mdi:mailbox', - notify: 'mdi:comment-alert', - number: 'mdi:ray-vertex', - persistent_notification: 'mdi:bell', - person: 'mdi:account', - plant: 'mdi:flower', - proximity: 'mdi:apple-safari', - remote: 'mdi:remote', - scene: 'mdi:palette', - script: 'mdi:script-text', - select: 'mdi:format-list-bulleted', - sensor: 'mdi:eye', - siren: 'mdi:bullhorn', - simple_alarm: 'mdi:bell', - sun: 'mdi:white-balance-sunny', - timer: 'mdi:timer-outline', - updater: 'mdi:cloud-upload', - vacuum: 'mdi:robot-vacuum', - water_heater: 'mdi:thermometer', - weather: 'mdi:weather-cloudy', - zone: 'mdi:map-marker-radius', -}; - -export function domainIcon( - domain: string, - entity?: HassEntity, - state?: string, - defaultIcon?: string, -): string { - switch (domain) { - case 'alarm_control_panel': - return alarmPanelIcon(state); - - case 'binary_sensor': - return binarySensorIcon(state, entity); - - case 'button': - switch (entity?.attributes.device_class) { - case 'restart': - return 'mdi:restart'; - case 'update': - return 'mdi:package-up'; - default: - return 'mdi:gesture-tap-button'; - } - - case 'cover': - return coverIcon(state, entity); - - case 'device_tracker': - if (entity?.attributes.source_type === 'router') { - return state === 'home' ? 'mdi:lan-connect' : 'mdi:lan-disconnect'; - } - if (['bluetooth', 'bluetooth_le'].includes(entity?.attributes.source_type)) { - return state === 'home' ? 'mdi:bluetooth-connect' : 'mdi:bluetooth'; - } - return state === 'not_home' ? 'mdi:account-arrow-right' : 'mdi:account'; - - case 'humidifier': - return state && state === 'off' ? 'mdi:air-humidifier-off' : 'mdi:air-humidifier'; - - case 'input_boolean': - return state === 'on' ? 'mdi:check-circle-outline' : 'mdi:close-circle-outline'; - - case 'lock': - switch (state) { - case 'unlocked': - return 'mdi:lock-open'; - case 'jammed': - return 'mdi:lock-alert'; - case 'locking': - case 'unlocking': - return 'mdi:lock-clock'; - default: - return 'mdi:lock'; - } - - // Taken from https://github.com/home-assistant/frontend/blob/45646eaf0bfc86aa0d0d6b73809ef0b32abb5729/src/common/entity/domain_icon.ts#L138-L172 - case 'media_player': - switch (entity?.attributes.device_class) { - case 'speaker': - switch (state) { - case 'playing': - return 'mdi:speaker-play'; - case 'paused': - return 'mdi:speaker-pause'; - case 'off': - return 'mdi:speaker-off'; - default: - return 'mdi:speaker'; - } - case 'tv': - switch (state) { - case 'playing': - return 'mdi:television-play'; - case 'paused': - return 'mdi:television-pause'; - case 'off': - return 'mdi:television-off'; - default: - return 'mdi:television'; - } - default: - switch (state) { - case 'playing': - case 'paused': - return 'mdi:cast-connected'; - case 'off': - return 'mdi:cast-off'; - default: - return 'mdi:cast'; - } - } - - case 'switch': - switch (entity?.attributes.device_class) { - case 'outlet': - return state === 'on' ? 'mdi:power-plug' : 'mdi:power-plug-off'; - case 'switch': - return state === 'on' ? 'mdi:toggle-switch' : 'mdi:toggle-switch-off'; - default: - return 'mdi:flash'; - } - - case 'zwave': - switch (state) { - case 'dead': - return 'mdi:emoticon-dead'; - case 'sleeping': - return 'mdi:sleep'; - case 'initializing': - return 'mdi:timer-sand'; - default: - return 'mdi:z-wave'; - } - - case 'sensor': { - const icon = sensorIcon(entity); - if (icon) { - return icon; - } - - break; - } - - case 'input_datetime': - if (!entity?.attributes.has_date) { - return 'mdi:clock'; - } - if (!entity.attributes.has_time) { - return 'mdi:calendar'; - } - break; - - case 'sun': - return entity?.state === 'above_horizon' - ? FIXED_DOMAIN_ICONS[domain] - : 'mdi:weather-night'; - - case 'update': - return entity?.state === 'on' - ? updateIsInstalling(entity as UpdateEntity) - ? 'mdi:package-down' - : 'mdi:package-up' - : 'mdi:package'; - } - - if (domain in FIXED_DOMAIN_ICONS) { - return FIXED_DOMAIN_ICONS[domain]; - } - - return defaultIcon ?? DEFAULT_DOMAIN_ICON; -} diff --git a/src/utils/icons/sensor-icon.ts b/src/utils/icons/sensor-icon.ts deleted file mode 100644 index efb42df6..00000000 --- a/src/utils/icons/sensor-icon.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { UNIT_C, UNIT_F } from '@dermotduffy/custom-card-helpers'; -import { HassEntity } from 'home-assistant-js-websocket'; - -const FIXED_DEVICE_CLASS_ICONS = { - apparent_power: 'mdi:flash', - aqi: 'mdi:air-filter', - carbon_dioxide: 'mdi:molecule-co2', - carbon_monoxide: 'mdi:molecule-co', - current: 'mdi:current-ac', - date: 'mdi:calendar', - energy: 'mdi:lightning-bolt', - frequency: 'mdi:sine-wave', - gas: 'mdi:gas-cylinder', - humidity: 'mdi:water-percent', - illuminance: 'mdi:brightness-5', - monetary: 'mdi:cash', - nitrogen_dioxide: 'mdi:molecule', - nitrogen_monoxide: 'mdi:molecule', - nitrous_oxide: 'mdi:molecule', - ozone: 'mdi:molecule', - pm1: 'mdi:molecule', - pm10: 'mdi:molecule', - pm25: 'mdi:molecule', - power: 'mdi:flash', - power_factor: 'mdi:angle-acute', - pressure: 'mdi:gauge', - reactive_power: 'mdi:flash', - signal_strength: 'mdi:wifi', - sulphur_dioxide: 'mdi:molecule', - temperature: 'mdi:thermometer', - timestamp: 'mdi:clock', - volatile_organic_compounds: 'mdi:molecule', - voltage: 'mdi:sine-wave', -}; - -const SENSOR_DEVICE_CLASS_BATTERY = 'battery'; - -const BATTERY_ICONS = { - 10: 'mdi:battery-10', - 20: 'mdi:battery-20', - 30: 'mdi:battery-30', - 40: 'mdi:battery-40', - 50: 'mdi:battery-50', - 60: 'mdi:battery-60', - 70: 'mdi:battery-70', - 80: 'mdi:battery-80', - 90: 'mdi:battery-90', - 100: 'mdi:battery', -}; -const BATTERY_CHARGING_ICONS = { - 10: 'mdi:battery-charging-10', - 20: 'mdi:battery-charging-20', - 30: 'mdi:battery-charging-30', - 40: 'mdi:battery-charging-40', - 50: 'mdi:battery-charging-50', - 60: 'mdi:battery-charging-60', - 70: 'mdi:battery-charging-70', - 80: 'mdi:battery-charging-80', - 90: 'mdi:battery-charging-90', - 100: 'mdi:battery-charging', -}; - -const batteryStateIcon = ( - batteryEntity: HassEntity, - batteryChargingEntity?: HassEntity, -) => { - const battery = batteryEntity.state; - const batteryCharging = batteryChargingEntity?.state === 'on'; - - return batteryIcon(battery, batteryCharging); -}; - -const batteryIcon = (batteryState: number | string, batteryCharging?: boolean) => { - const batteryValue = Number(batteryState); - if (isNaN(batteryValue)) { - if (batteryState === 'off') { - return 'mdi:battery'; - } - if (batteryState === 'on') { - return 'mdi:battery-alert'; - } - return 'mdi:battery-unknown'; - } - - const batteryRound = Math.round(batteryValue / 10) * 10; - if (batteryCharging && batteryValue >= 10) { - return BATTERY_CHARGING_ICONS[batteryRound]; - } - if (batteryCharging) { - return 'mdi:battery-charging-outline'; - } - if (batteryValue <= 5) { - return 'mdi:battery-alert-variant-outline'; - } - return BATTERY_ICONS[batteryRound]; -}; - -export const sensorIcon = (entity?: HassEntity): string | undefined => { - const dclass = entity?.attributes.device_class; - - if (dclass && dclass in FIXED_DEVICE_CLASS_ICONS) { - return FIXED_DEVICE_CLASS_ICONS[dclass]; - } - - if (dclass === SENSOR_DEVICE_CLASS_BATTERY) { - return entity ? batteryStateIcon(entity) : 'mdi:battery'; - } - - const unit = entity?.attributes.unit_of_measurement; - if (unit === UNIT_C || unit === UNIT_F) { - return 'mdi:thermometer'; - } - - return undefined; -}; diff --git a/tests/camera-manager/generic/engine-generic.test.ts b/tests/camera-manager/generic/engine-generic.test.ts index da678641..8b642153 100644 --- a/tests/camera-manager/generic/engine-generic.test.ts +++ b/tests/camera-manager/generic/engine-generic.test.ts @@ -206,7 +206,11 @@ describe('GenericCameraManagerEngine', () => { expect( createEngine().getCameraMetadata(createHASS(), createGenericCameraConfig()), ).toEqual({ - icon: 'mdi:video', + icon: { + entity: undefined, + icon: undefined, + fallback: 'mdi:video', + }, title: '', }); }); @@ -217,10 +221,11 @@ describe('GenericCameraManagerEngine', () => { createHASS(), createGenericCameraConfig({ id: 'https://go2rtc#stream' }), ), - ).toEqual({ - icon: 'mdi:video', - title: 'https://go2rtc#stream', - }); + ).toEqual( + expect.objectContaining({ + title: 'https://go2rtc#stream', + }), + ); }); it('with configured title', async () => { @@ -231,10 +236,11 @@ describe('GenericCameraManagerEngine', () => { title: 'My Camera', }), ), - ).toEqual({ - icon: 'mdi:video', - title: 'My Camera', - }); + ).toEqual( + expect.objectContaining({ + title: 'My Camera', + }), + ); }); describe('with entity title', () => { @@ -250,10 +256,11 @@ describe('GenericCameraManagerEngine', () => { camera_entity: 'camera.test', }), ), - ).toEqual({ - icon: 'mdi:video', - title: 'My Entity Camera', - }); + ).toEqual( + expect.objectContaining({ + title: 'My Entity Camera', + }), + ); }); it('webrtc_card.entity', async () => { @@ -270,10 +277,11 @@ describe('GenericCameraManagerEngine', () => { }, }), ), - ).toEqual({ - icon: 'mdi:video', - title: 'My Entity Camera', - }); + ).toEqual( + expect.objectContaining({ + title: 'My Entity Camera', + }), + ); }); }); }); diff --git a/tests/camera-manager/reolink/engine-reolink.test.ts b/tests/camera-manager/reolink/engine-reolink.test.ts index 1dc8219c..d03a1669 100644 --- a/tests/camera-manager/reolink/engine-reolink.test.ts +++ b/tests/camera-manager/reolink/engine-reolink.test.ts @@ -259,8 +259,12 @@ describe('ReolinkCameraManagerEngine', () => { }); const engine = createEngine(); expect(engine.getCameraMetadata(createHASS(), cameraConfig)).toEqual({ - engineLogo: '/src/camera-manager/reolink/assets/reolink.svg', - icon: 'mdi:camera', + engineIcon: 'reolink', + icon: { + icon: 'mdi:camera', + entity: 'camera.office', + fallback: 'mdi:video', + }, title: 'Office', }); }); diff --git a/tests/card-controller/status-bar-item-manager.test.ts b/tests/card-controller/status-bar-item-manager.test.ts index 3dc3538f..58f582f8 100644 --- a/tests/card-controller/status-bar-item-manager.test.ts +++ b/tests/card-controller/status-bar-item-manager.test.ts @@ -59,7 +59,7 @@ describe('StatusBarItemManager', () => { const cameraManager = createCameraManager(store); vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({ title: 'Camera Title', - icon: 'mdi:camera', + icon: { icon: 'mdi:camera' }, }); expect( @@ -225,8 +225,10 @@ describe('StatusBarItemManager', () => { const cameraManager = createCameraManager(store); vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({ title: 'Camera Title', - icon: 'mdi:camera', - engineLogo: 'IMAGE_LOGO', + icon: { + icon: 'mdi:camera', + }, + engineIcon: 'ENGINE_ICON', }); expect( @@ -235,8 +237,8 @@ describe('StatusBarItemManager', () => { view: createView({ view: 'live', camera: 'camera-1' }), }), ).toContainEqual({ - type: 'custom:frigate-card-status-bar-image' as const, - image: 'IMAGE_LOGO', + type: 'custom:frigate-card-status-bar-icon' as const, + icon: 'ENGINE_ICON', }); }); }); diff --git a/tests/components-lib/icon-controller.test.ts b/tests/components-lib/icon-controller.test.ts new file mode 100644 index 00000000..62117fc5 --- /dev/null +++ b/tests/components-lib/icon-controller.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import { IconController } from '../../src/components-lib/icon-controller'; +import { createHASS, createStateEntity } from '../test-utils'; + +describe('IconController', () => { + describe('should get custom icon', () => { + it('should return frigate SVG for frigate icon', () => { + expect(new IconController().getCustomIcon({ icon: 'frigate' })).toMatch( + /frigate.svg$/, + ); + }); + + it('should return motioneye SVG for motioneye icon', () => { + expect(new IconController().getCustomIcon({ icon: 'motioneye' })).toMatch( + /motioneye.svg$/, + ); + }); + + it('should return reolink SVG for reolink icon', () => { + expect(new IconController().getCustomIcon({ icon: 'reolink' })).toMatch( + /reolink.svg$/, + ); + }); + + it('should return null for mdi icon', () => { + expect(new IconController().getCustomIcon({ icon: 'mdi:car' })).toBeNull(); + }); + + it('should return null for undefined icon', () => { + expect(new IconController().getCustomIcon()).toBeNull(); + }); + }); + + describe('should create state object for state badge', () => { + it('should return null for non-existent entity', () => { + expect( + new IconController().createStateObjectForStateBadge( + createHASS(), + 'sensor.DOES_NOT_EXIST', + ), + ).toBeNull(); + }); + + it('should return modified state object for existing entity', () => { + expect( + new IconController().createStateObjectForStateBadge( + createHASS({ + 'sensor.existing': createStateEntity({ + entity_id: 'sensor.existing', + attributes: { + friendly_name: 'Existing', + icon: 'mdi:car', + entity_picture: 'http://example.com/image.jpg', + entity_picture_local: 'local.jpg', + }, + }), + }), + 'sensor.existing', + ), + ).toEqual( + expect.objectContaining({ + entity_id: 'sensor.existing', + attributes: expect.objectContaining({ + friendly_name: 'Existing', + icon: 'mdi:car', + entity_picture: undefined, + entity_picture_local: undefined, + }), + }), + ); + }); + }); +}); diff --git a/tests/components-lib/menu-button-controller.test.ts b/tests/components-lib/menu-button-controller.test.ts index 4f4bf50d..ef3d6b99 100644 --- a/tests/components-lib/menu-button-controller.test.ts +++ b/tests/components-lib/menu-button-controller.test.ts @@ -127,7 +127,9 @@ describe('MenuButtonController', () => { ); vi.mocked(cameraManager).getCameraMetadata.mockReturnValue({ title: 'title', - icon: 'icon', + icon: { + icon: 'icon', + }, }); const buttons = calculateButtons(controller, { cameraManager: cameraManager }); @@ -178,7 +180,9 @@ describe('MenuButtonController', () => { ); vi.mocked(cameraManager).getCameraMetadata.mockReturnValue({ title: 'title', - icon: 'icon', + icon: { + icon: 'icon', + }, }); const buttons = calculateButtons(controller, { cameraManager: cameraManager }); @@ -332,7 +336,10 @@ describe('MenuButtonController', () => { return cameraID === 'camera-1' ? { title: 'title', - icon: 'icon', + icon: { + icon: 'icon', + entity: 'entity', + }, } : null; }, @@ -351,7 +358,7 @@ describe('MenuButtonController', () => { { enabled: true, icon: 'icon', - entity: 'camera.1', + entity: 'entity', state_color: true, title: 'title', selected: true, @@ -364,7 +371,7 @@ describe('MenuButtonController', () => { { enabled: true, icon: undefined, - entity: 'camera.2', + entity: undefined, state_color: true, title: undefined, selected: false, @@ -377,7 +384,7 @@ describe('MenuButtonController', () => { { enabled: true, icon: undefined, - entity: 'camera.3', + entity: undefined, state_color: true, title: undefined, selected: false, @@ -443,7 +450,7 @@ describe('MenuButtonController', () => { { enabled: true, icon: undefined, - entity: 'camera.1', + entity: undefined, state_color: true, title: undefined, selected: false, @@ -456,7 +463,7 @@ describe('MenuButtonController', () => { { enabled: true, icon: undefined, - entity: 'camera.2', + entity: undefined, state_color: true, title: undefined, // camera-2 is selected in this test scenario because of the view @@ -471,7 +478,7 @@ describe('MenuButtonController', () => { { enabled: true, icon: undefined, - entity: 'camera.3', + entity: undefined, state_color: true, title: undefined, selected: false, @@ -1212,7 +1219,6 @@ describe('MenuButtonController', () => { { enabled: true, selected: false, - icon: 'mdi:cast', entity: 'media_player.tv', state_color: false, title: 'media_player.tv', @@ -1266,7 +1272,6 @@ describe('MenuButtonController', () => { { enabled: true, selected: false, - icon: 'mdi:bookmark', entity: 'not_a_real_player', state_color: false, title: 'not_a_real_player', diff --git a/tests/components-lib/menu-controller.test.ts b/tests/components-lib/menu-controller.test.ts index 01fd06e2..f22ad20f 100644 --- a/tests/components-lib/menu-controller.test.ts +++ b/tests/components-lib/menu-controller.test.ts @@ -2,9 +2,7 @@ import { handleActionConfig } from '@dermotduffy/custom-card-helpers'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MenuController } from '../../src/components-lib/menu-controller'; import { MenuConfig, menuConfigSchema } from '../../src/config/types'; -import { StateParameters } from '../../src/types'; -import { refreshDynamicStateParameters } from '../../src/utils/ha'; -import { createInteractionEvent, createHASS, createLitElement } from '../test-utils'; +import { createInteractionEvent, createLitElement } from '../test-utils'; vi.mock('@dermotduffy/custom-card-helpers'); vi.mock('../../src/utils/ha'); @@ -362,38 +360,6 @@ describe('MenuController', () => { }); }); - describe('should get fresh button state', () => { - it('on state icon', () => { - const controller = new MenuController(createLitElement()); - const stateButton = { - type: 'custom:frigate-card-menu-state-icon' as const, - icon: 'mdi:sheep', - entity: 'switch.foo', - state_color: true, - }; - - const stateParameters: StateParameters = {}; - vi.mocked(refreshDynamicStateParameters).mockReturnValue(stateParameters); - - expect(controller.getFreshButtonState(createHASS(), stateButton)).toBe( - stateParameters, - ); - - expect(vi.mocked(refreshDynamicStateParameters)).toBeCalled(); - }); - - it('on non state icon', () => { - const controller = new MenuController(createLitElement()); - const button = { - type: 'custom:frigate-card-menu-icon' as const, - icon: 'mdi:sheep', - }; - - expect(controller.getFreshButtonState(createHASS(), button)).toEqual(button); - expect(vi.mocked(refreshDynamicStateParameters)).not.toBeCalled(); - }); - }); - describe('should handle actions', () => { it('should bail without config', () => { const controller = new MenuController(createLitElement()); diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 7edf1e21..010e876c 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -285,7 +285,7 @@ describe('config defaults', () => { }, }, status_bar: { - height: 46, + height: 30, items: { engine: { enabled: true, diff --git a/tests/utils/custom-icons.test.ts b/tests/utils/custom-icons.test.ts deleted file mode 100644 index 124761e5..00000000 --- a/tests/utils/custom-icons.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import frigateSVG from '../../src/camera-manager/frigate/assets/frigate.svg'; -import motioneyeSVG from '../../src/camera-manager/motioneye/assets/motioneye.svg'; -import reolinkSVG from '../../src/camera-manager/reolink/assets/reolink.svg'; -import { getCustomIconURL } from '../../src/utils/custom-icons'; - -describe('getCustomIconURL', () => { - it('should return frigate SVG for frigate icon', () => { - expect(getCustomIconURL('frigate')).toBe(frigateSVG); - }); - - it('should return motioneye SVG for motioneye icon', () => { - expect(getCustomIconURL('motioneye')).toBe(motioneyeSVG); - }); - - it('should return reolink SVG for reolink icon', () => { - expect(getCustomIconURL('reolink')).toBe(reolinkSVG); - }); - - it('should return null for mdi icon', () => { - expect(getCustomIconURL('mdi:car')).toBeNull(); - }); - - it('should return null for undefined icon', () => { - expect(getCustomIconURL()).toBeNull(); - }); -}); diff --git a/tests/utils/ha/index.test.ts b/tests/utils/ha/index.test.ts index 3bc08ff2..52ca319d 100644 --- a/tests/utils/ha/index.test.ts +++ b/tests/utils/ha/index.test.ts @@ -2,11 +2,10 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers'; import { describe, expect, it, vi } from 'vitest'; import { canonicalizeHAURL, - getEntityIcon, hasHAConnectionStateChanged, isHARelativeURL, } from '../../../src/utils/ha/index.js'; -import { createHASS, createStateEntity } from '../../test-utils.js'; +import { createHASS } from '../../test-utils.js'; const createConnected = (connected: boolean): HomeAssistant => { const hass = createHASS(); @@ -49,27 +48,6 @@ describe('hasHAConnectionStateChanged', () => { }); }); -describe('getEntityIcon', () => { - it('should get icon from attributes', () => { - expect( - getEntityIcon( - createHASS({ - 'camera.test': createStateEntity({ - attributes: { - icon: 'mdi:cow', - }, - }), - }), - 'camera.test', - ), - ).toBe('mdi:cow'); - }); - - it('should get icon from domain', () => { - expect(getEntityIcon(createHASS(), 'camera.test')).toBe('mdi:video'); - }); -}); - describe('isHARelativeURL', () => { it('should return true when URL is HA relative', () => { expect(isHARelativeURL('/api/foo')).toBeTruthy(); diff --git a/tsconfig.json b/tsconfig.json index c8c9094e..46ea7f03 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,14 +25,20 @@ "globalTags": [ "ha-button-menu", "ha-button", + "ha-camera-stream", "ha-card", "ha-circular-progress", "ha-combo-box", + "ha-hls-player", "ha-icon-button", "ha-icon", + "ha-menu-button", "ha-selector", + "ha-state-icon", + "ha-web-rtc-player", "mwc-button", - "mwc-list-item" + "mwc-list-item", + "state-badge" ], "rules": { "no-unknown-tag-name": "error",