fix: Use HA style and coloring for all icons (#1790)

This commit is contained in:
Dermot Duffy
2024-12-23 15:22:27 -08:00
committed by GitHub
parent a9e5889ca7
commit 9d812af9f4
68 changed files with 557 additions and 1366 deletions
+1
View File
@@ -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'
+2 -4
View File
@@ -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',
};
}
+6 -4
View File
@@ -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',
},
};
}
@@ -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',
};
}
+2 -4
View File
@@ -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',
};
}
+5 -3
View File
@@ -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 {
+1 -1
View File
@@ -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';
@@ -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,
},
]
+44
View File
@@ -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,
},
};
}
}
+17 -22
View File
@@ -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,
+1 -10
View File
@@ -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,
+4 -3
View File
@@ -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()}
/>
<ha-icon
<frigate-card-icon
aria-label="${localize('timeline.select_date')}"
title="${localize('timeline.select_date')}"
.icon=${this.icon ?? `mdi:calendar-search`}
.icon=${{ icon: this.icon ?? `mdi:calendar-search` }}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
this._refInput.value?.showPicker();
}}
>
</ha-icon>`;
</frigate-card-icon>`;
}
static get styles(): CSSResultGroup {
+8 -5
View File
@@ -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;
}}
>
<ha-icon
<frigate-card-icon
class="control"
icon="${this.open
? this.icons?.open ?? 'mdi:menu-open'
: this.icons?.closed ?? 'mdi:menu'}"
.icon="${{
icon: this.open
? this.icons?.open ?? 'mdi:menu-open'
: this.icons?.closed ?? 'mdi:menu',
}}"
@mouseenter=${() => {
// 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 {
}
}}
>
</ha-icon>
</frigate-card-icon>
</div>
`
: ''}
+54
View File
@@ -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`<img src="${customIconURL}" />`;
}
if (this.icon?.icon) {
return html`<ha-icon icon="${this.icon.icon}"></ha-icon>`;
}
if (this.hass && this.icon?.entity) {
const stateObj = this._controller.createStateObjectForStateBadge(
this.hass,
this.icon.entity,
);
if (stateObj) {
return html`<state-badge
.stateColor=${this.icon.stateColor ?? true}
.hass=${this.hass}
.stateObj=${stateObj}
></state-badge>`;
}
}
if (this.icon?.fallback) {
return html`<ha-icon icon="${this.icon.fallback}"></ha-icon>`;
}
return html``;
}
static get styles(): CSSResultGroup {
return unsafeCSS(iconStyle);
}
}
declare global {
interface HTMLElementTagNameMap {
'frigate-card-icon': FrigateCardIcon;
}
}
+6 -7
View File
@@ -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();
}}
>
<ha-icon icon="mdi:keyboard-settings"></ha-icon>
<frigate-card-icon .icon=${{ icon: 'mdi:keyboard-settings' }}></frigate-card-icon>
<span class="${classMap({
dotdotdot: this._controller.isAssigning(),
})}">
${
this._controller.isAssigning()
? localize('key_assigner.assigning')
: localize('key_assigner.assign')
}
${this._controller.isAssigning() ? '' : localize('key_assigner.assign')}
</span>
</ha-button>
${
@@ -66,7 +63,9 @@ export class FrigateCardKeyAssigner extends LitElement {
this._controller.setValue(null);
}}
>
<ha-icon icon="mdi:keyboard-off"></ha-icon>
<frigate-card-icon
.icon=${{ icon: 'mdi:keyboard-off' }}
></frigate-card-icon>
<span> ${localize('key_assigner.unassign')} </span>
</ha-button>`
: ''
+5 -4
View File
@@ -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`<ha-icon
? html`<frigate-card-icon
title=${localize('error.awaiting_live')}
icon="mdi:progress-helper"
></ha-icon>`
.icon=${{ icon: 'mdi:progress-helper' }}
></frigate-card-icon>`
: ''} `;
}
+17 -25
View File
@@ -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 {
</frigate-card-submenu-select>`;
}
// =====================================================================================
// 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` <ha-icon-button
data-domain=${ifDefined(buttonState.data_domain)}
data-state=${ifDefined(buttonState.data_state)}
class="button"
style="${styleMap(buttonState.style || {})}"
style="${styleMap(button.style || {})}"
.actionHandler=${actionHandler({
hasHold: frigateCardHasAction(button.hold_action),
hasDoubleClick: frigateCardHasAction(button.double_tap_action),
})}
.label=${buttonState.title || ''}
.label=${title ?? ''}
@action=${(ev) => this._controller.actionHandler(ev, button)}
>
${customImageURL
? html`<img src="${customImageURL}" />`
: html`<ha-icon
icon="${buttonState.icon || 'mdi:gesture-tap-button'}"
></ha-icon>`}
<frigate-card-icon
.hass=${this.hass}
.icon=${{
icon: button.icon,
entity: button.entity,
stateColor: button.state_color,
fallback: 'mdi:gesture-tap-button',
}}
></frigate-card-icon>
</ha-icon-button>`;
}
+6 -3
View File
@@ -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` <div class="wrapper">
<div class="message padded">
<div class="icon">
<ha-icon icon="${icon}"> </ha-icon>
<frigate-card-icon .icon="${{ icon: icon }}"></frigate-card-icon>
</div>
<div class="contents">
<span class="${classMap(classes)}">
@@ -103,9 +104,11 @@ export class FrigateCardProgressIndicator extends LitElement {
protected render(): TemplateResult {
return html` <div class="message vertical">
${this.animated
? html`<ha-circular-progress active="true" size="${this.size}">
? html`<ha-circular-progress indeterminate size="${this.size}">
</ha-circular-progress>`
: html`<ha-icon icon="mdi:timer-sand"></ha-icon>`}
: html`<frigate-card-icon
.icon=${{ icon: 'mdi:timer-sand' }}
></frigate-card-icon>`}
${this.message ? html`<span>${this.message}</span>` : html``}
</div>`;
}
+8 -10
View File
@@ -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` <ha-icon-button class="${classMap(classes)}" .label=${this.label}>
<ha-icon icon=${icon}></ha-icon>
<frigate-card-icon .hass=${this.hass} .icon=${icon}></frigate-card-icon>
</ha-icon-button>`;
}
+4 -3
View File
@@ -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`<ha-icon
? html`<frigate-card-icon
class=${classMap(classes)}
icon=${icon}
.icon=${{ icon: icon }}
.actionHandler=${actionHandler({
hasHold: frigateCardHasAction(actions?.hold_action),
hasDoubleClick: frigateCardHasAction(actions?.double_tap_action),
@@ -77,7 +78,7 @@ export class FrigateCardPTZ extends LitElement {
.title=${localize(`elements.ptz.${name}`)}
@action=${(ev: HASSDomEvent<{ action: string }>) =>
this._controller.handleAction(ev, actions)}
></ha-icon>`
></frigate-card-icon>`
: html``;
};
+7 -15
View File
@@ -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}
</div>`;
} else if (item.type === 'custom:frigate-card-status-bar-icon') {
const customIconURL = getCustomIconURL(item.icon);
return customIconURL
? html`<img
.actionHandler=${handler}
class="${classes}"
src="${customIconURL}"
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
/>`
: html`<ha-icon
.actionHandler=${handler}
class="${classes}"
icon="${item.icon}"
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
></ha-icon>`;
return html`<frigate-card-icon
.actionHandler=${handler}
.icon=${{ icon: item.icon }}
class="${classes}"
@action=${(ev) => this._controller.actionHandler(ev, item.actions)}
></frigate-card-icon>`;
} else if (item.type === 'custom:frigate-card-status-bar-image') {
return html`<img
.actionHandler=${handler}
+40 -43
View File
@@ -13,16 +13,21 @@ import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { actionHandler } from '../action-handler-directive.js';
import { MenuSubmenu, MenuSubmenuItem, MenuSubmenuSelect } from '../config/types.js';
import submenuStyle from '../scss/submenu.scss';
import { StateParameters } from '../types.js';
import {
frigateCardHasAction,
stopEventFromActivatingCardWideActions,
} from '../utils/action.js';
import { getCustomIconURL } from '../utils/custom-icons.js';
import { isHassDifferent, refreshDynamicStateParameters } from '../utils/ha';
import { getEntityTitle, isHassDifferent } from '../utils/ha';
import { getEntityStateTranslation } from '../utils/ha/entity-state-translation.js';
import { EntityRegistryManager } from '../utils/ha/registry/entity/index.js';
import { domainIcon } from '../utils/icons/domain-icon.js';
import './icon.js';
import { Icon } from '../types.js';
interface ExtendedMenuSubmenu extends MenuSubmenu {
// An internal version of a submenu that allows entity-based submenus (for
// FrigateCardSubmenuSelect).
icon: string | Icon;
}
@customElement('frigate-card-submenu')
export class FrigateCardSubmenu extends LitElement {
@@ -30,47 +35,23 @@ export class FrigateCardSubmenu extends LitElement {
public hass?: HomeAssistant;
@property({ attribute: false })
public submenu?: MenuSubmenu;
public submenu?: ExtendedMenuSubmenu;
protected _renderItem(item: MenuSubmenuItem): TemplateResult | void {
if (!this.hass) {
return;
}
const stateParameters = refreshDynamicStateParameters(this.hass, {
...item,
} as StateParameters);
const getIcon = (stateParameters: StateParameters): TemplateResult => {
if (stateParameters.icon) {
const url = getCustomIconURL(stateParameters.icon);
return url
? html`<img
style="${styleMap(stateParameters.style || {})}"
data-domain=${ifDefined(stateParameters.data_domain)}
data-state=${ifDefined(stateParameters.data_state)}
slot="graphic"
src=${url}
/>`
: html` <ha-icon
style="${styleMap(stateParameters.style || {})}"
data-domain=${ifDefined(stateParameters.data_domain)}
data-state=${ifDefined(stateParameters.data_state)}
slot="graphic"
icon="${stateParameters.icon || ''}"
>
</ha-icon>`;
}
return html``;
};
const title = item.title ?? getEntityTitle(this.hass, item.entity);
return html`
<mwc-list-item
style="${styleMap(stateParameters.style || {})}"
graphic=${ifDefined(stateParameters.icon ? 'icon' : undefined)}
style="${styleMap(item.style || {})}"
graphic=${ifDefined(item.icon || item.entity ? 'icon' : undefined)}
?twoline=${!!item.subtitle}
?selected=${item.selected}
?activated=${item.selected}
?disabled=${item.enabled === false}
aria-label="${stateParameters.title || ''}"
aria-label="${title ?? ''}"
@action=${(ev) => {
// 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),
})}
>
<span>${stateParameters.title || ''}</span>
<span>${title ?? ''}</span>
${item.subtitle ? html`<span slot="secondary">${item.subtitle}</span>` : ''}
${getIcon(stateParameters)}
<frigate-card-icon
slot="graphic"
.hass=${this.hass}
.icon=${{
icon: item.icon,
entity: item.entity,
}}
style="${styleMap(item.style || {})}"
></frigate-card-icon>
</mwc-list-item>
`;
}
@@ -118,7 +107,14 @@ export class FrigateCardSubmenu extends LitElement {
hasDoubleClick: frigateCardHasAction(this.submenu.double_tap_action),
})}
>
<ha-icon icon="${this.submenu.icon}"></ha-icon>
<frigate-card-icon
.hass=${this.hass}
.icon=${typeof this.submenu.icon === 'string'
? {
icon: this.submenu.icon,
}
: this.submenu.icon}
></frigate-card-icon>
</ha-icon-button>
${items.map(this._renderItem.bind(this))}
</ha-button-menu>
@@ -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: [],
};
+49 -40
View File
@@ -105,10 +105,10 @@ export class FrigateCardThumbnailFeatureThumbnail extends LitElement {
}
protected render(): TemplateResult | void {
const imageOff = html`<ha-icon
icon="mdi:image-off"
const imageOff = html`<frigate-card-icon
.icon=${{ icon: 'mdi:image-off' }}
title=${localize('thumbnail.no_thumbnail')}
></ha-icon> `;
></frigate-card-icon> `;
if (!this._embedThumbnailTask || this._thumbnailError) {
return imageOff;
@@ -150,8 +150,11 @@ export class FrigateCardThumbnailFeatureText extends LitElement {
return;
}
return html`
${this.cameraMetadata?.engineLogo
? html`<img class="background" src="${this.cameraMetadata.engineLogo}" />`
${this.cameraMetadata?.engineIcon
? html`<frigate-card-icon
class="background"
.icon=${{ icon: this.cameraMetadata.engineIcon }}
></frigate-card-icon>`
: ''}
<div class="content">
<div class="title">${format(this.date, 'HH:mm')}</div>
@@ -211,18 +214,18 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
<div class="details">
${startTime
? html` <div>
<ha-icon
<frigate-card-icon
title=${localize('event.start')}
.icon=${'mdi:calendar-clock-outline'}
></ha-icon>
.icon=${{ icon: 'mdi:calendar-clock-outline' }}
></frigate-card-icon>
<span title="${startTime}">${startTime}</span>
</div>
${duration || inProgress
? html` <div>
<ha-icon
<frigate-card-icon
title=${localize('event.duration')}
.icon=${'mdi:clock-outline'}
></ha-icon>
.icon=${{ icon: 'mdi:clock-outline' }}
></frigate-card-icon>
${duration ? html`<span title="${duration}">${duration}</span>` : ''}
${inProgress
? html`<span title="${inProgress}">${inProgress}</span>`
@@ -232,31 +235,37 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
: ''}
${this.cameraTitle
? html` <div>
<ha-icon title=${localize('event.camera')} .icon=${'mdi:cctv'}></ha-icon>
<frigate-card-icon
title=${localize('event.camera')}
.icon=${{ icon: 'mdi:cctv' }}
></frigate-card-icon>
<span title="${this.cameraTitle}">${this.cameraTitle}</span>
</div>`
: ''}
${where
? html` <div>
<ha-icon
<frigate-card-icon
title=${localize('event.where')}
.icon=${'mdi:map-marker-outline'}
></ha-icon>
.icon=${{ icon: 'mdi:map-marker-outline' }}
></frigate-card-icon>
<span title="${where}">${where}</span>
</div>`
: html``}
${tags
? html` <div>
<ha-icon title=${localize('event.tag')} .icon=${'mdi:tag'}></ha-icon>
<frigate-card-icon
title=${localize('event.tag')}
.icon=${{ icon: 'mdi:tag' }}
></frigate-card-icon>
<span title="${tags}">${tags}</span>
</div>`
: html``}
${seek
? html` <div>
<ha-icon
<frigate-card-icon
title=${localize('event.seek')}
.icon=${'mdi:clock-fast'}
></ha-icon>
.icon=${{ icon: 'mdi:clock-fast' }}
></frigate-card-icon>
<span title="${seek}">${seek}</span>
</div>`
: html``}
@@ -306,18 +315,18 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
<div class="details">
${startTime
? html` <div>
<ha-icon
<frigate-card-icon
title=${localize('recording.start')}
.icon=${'mdi:calendar-clock-outline'}
></ha-icon>
.icon=${{ icon: 'mdi:calendar-clock-outline' }}
></frigate-card-icon>
<span title="${startTime}">${startTime}</span>
</div>
${duration || inProgress
? html` <div>
<ha-icon
<frigate-card-icon
title=${localize('recording.duration')}
.icon=${'mdi:clock-outline'}
></ha-icon>
.icon=${{ icon: 'mdi:clock-outline' }}
></frigate-card-icon>
${duration ? html`<span title="${duration}">${duration}</span>` : ''}
${inProgress
? html`<span title="${inProgress}">${inProgress}</span>`
@@ -327,19 +336,19 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
: ''}
${seek
? html` <div>
<ha-icon
<frigate-card-icon
title=${localize('event.seek')}
.icon=${'mdi:clock-fast'}
></ha-icon>
.icon=${{ icon: 'mdi:clock-fast' }}
></frigate-card-icon>
<span title="${seek}">${seek}</span>
</div>`
: html``}
${eventCount !== null
? html`<div>
<ha-icon
<frigate-card-icon
title=${localize('recording.events')}
.icon=${'mdi:shield-alert'}
></ha-icon>
.icon=${{ icon: 'mdi:shield-alert' }}
></frigate-card-icon>
<span title="${eventCount}">${eventCount}</span>
</div>`
: ``}
@@ -448,10 +457,10 @@ export class FrigateCardThumbnail extends LitElement {
></frigate-card-thumbnail-feature-text>`
: html``}
${shouldShowFavoriteControl
? html` <ha-icon
? html` <frigate-card-icon
class="${classMap(starClasses)}"
icon=${this.media.isFavorite() ? 'mdi:star' : 'mdi:star-outline'}
title=${localize('thumbnail.retain_indefinitely')}
.icon=${{ icon: this.media.isFavorite() ? 'mdi:star' : 'mdi:star-outline' }}
@click=${async (ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
if (this.hass && this.media) {
@@ -467,7 +476,7 @@ export class FrigateCardThumbnail extends LitElement {
this.requestUpdate();
}
}}
/></ha-icon>`
/></frigate-card-icon>`
: ``}
${this.details && ViewMediaClassifier.isEvent(this.media)
? html`<frigate-card-thumbnail-details-event
@@ -483,9 +492,9 @@ export class FrigateCardThumbnail extends LitElement {
></frigate-card-thumbnail-details-recording>`
: html``}
${shouldShowTimelineControl
? html`<ha-icon
? html`<frigate-card-icon
class="timeline"
icon="mdi:target"
.icon=${{ icon: 'mdi:target' }}
title=${localize('thumbnail.timeline')}
@click=${(ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
@@ -503,12 +512,12 @@ export class FrigateCardThumbnail extends LitElement {
modifiers: [new RemoveContextViewModifier(['timeline'])],
});
}}
></ha-icon>`
></frigate-card-icon>`
: ''}
${shouldShowDownloadControl
? html` <ha-icon
? html` <frigate-card-icon
class="download"
icon=${'mdi:download'}
.icon=${{ icon: 'mdi:download' }}
title=${localize('thumbnail.download')}
@click=${async (ev: Event) => {
stopEventFromActivatingCardWideActions(ev);
@@ -520,7 +529,7 @@ export class FrigateCardThumbnail extends LitElement {
}
}
}}
></ha-icon>`
></frigate-card-icon>`
: ``}
`;
}
+4 -3
View File
@@ -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 {
>
<div class="timeline-tools">
${this._shouldSupportSeeking()
? html` <ha-icon
.icon=${panIcon}
? html` <frigate-card-icon
.icon=${{ icon: panIcon }}
@click=${() => {
this._panMode =
panMode === 'pan'
@@ -319,7 +320,7 @@ export class FrigateCardTimelineCore extends LitElement {
aria-label="${panTitle}"
title="${panTitle}"
>
</ha-icon>`
</frigate-card-icon>`
: ''}
<frigate-card-date-picker
${ref(this._refDatePicker)}
+5 -2
View File
@@ -402,8 +402,11 @@ export class FrigateCardViewerCarousel extends LitElement {
</frigate-card-ptz>`
: ''}
<div class="seek-warning">
<ha-icon title="${localize('media_viewer.unseekable')}" icon="mdi:clock-remove">
</ha-icon>
<frigate-card-icon
title="${localize('media_viewer.unseekable')}"
.icon=${{ icon: 'mdi:clock-remove' }}
>
</frigate-card-icon>
</div>
`;
}
+1 -1
View File
@@ -1721,7 +1721,7 @@ const statusBarItemDefault = {
};
const statusBarConfigDefault = {
height: 46,
height: 30,
items: {
engine: statusBarItemDefault,
resolution: statusBarItemDefault,
+25 -20
View File
@@ -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}
>
<div class="row">
<ha-icon .icon=${`mdi:${optionSet.icon}`}></ha-icon>
<frigate-card-icon
.icon=${{ icon: `mdi:${optionSet.icon}` }}
></frigate-card-icon>
<div class="title ${titleClass ?? ''}">${optionSet.name}</div>
</div>
<div class="secondary">${optionSet.secondary}</div>
@@ -1406,7 +1408,6 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
selected: selected,
};
const customIconURL = getCustomIconURL(icon);
return html` <div class="${classMap(submenuClasses)}">
<div
class="submenu-header"
@@ -1414,9 +1415,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
.domain=${domain}
.key=${key}
>
${customIconURL
? html` <img src=${customIconURL} /> `
: html` <ha-icon .icon=${icon}></ha-icon> `}
<frigate-card-icon .icon=${{ icon: icon }}></frigate-card-icon>
<span>${localize(labelPath)}</span>
</div>
${selected ? html`<div class="values">${template}</div>` : ''}
@@ -1909,7 +1908,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
.domain=${MENU_CAMERAS}
.key=${cameraIndex}
>
<ha-icon .icon=${addNewCamera ? 'mdi:video-plus' : 'mdi:video'}></ha-icon>
<frigate-card-icon
.icon=${{ icon: addNewCamera ? 'mdi:video-plus' : 'mdi:video' }}
></frigate-card-icon>
<span>
${addNewCamera
? html` <span class="new-camera">
@@ -1944,7 +1945,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
return false;
})}
>
<ha-icon icon="mdi:arrow-up"></ha-icon>
<frigate-card-icon
.icon=${{ icon: 'mdi:arrow-up' }}
></frigate-card-icon>
</ha-icon-button>
<ha-icon-button
class="button"
@@ -1967,7 +1970,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
return false;
})}
>
<ha-icon icon="mdi:arrow-down"></ha-icon>
<frigate-card-icon
.icon=${{ icon: 'mdi:arrow-down' }}
></frigate-card-icon>
</ha-icon-button>
<ha-icon-button
class="button"
@@ -1984,7 +1989,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
});
}}
>
<ha-icon icon="mdi:delete"></ha-icon>
<frigate-card-icon .icon=${{ icon: 'mdi:delete' }}></frigate-card-icon>
</ha-icon-button>
</div>
${this._renderEntitySelector(
-1
View File
@@ -660,7 +660,6 @@
},
"key_assigner": {
"assign": "",
"assigning": "",
"modifiers": {
"alt": "",
"ctrl": "",
-1
View File
@@ -660,7 +660,6 @@
},
"key_assigner": {
"assign": "Assign",
"assigning": "Assigning",
"modifiers": {
"alt": "Alt",
"ctrl": "Ctrl",
-1
View File
@@ -660,7 +660,6 @@
},
"key_assigner": {
"assign": "Assigner",
"assigning": "Assignation",
"modifiers": {
"alt": "",
"ctrl": "",
-1
View File
@@ -660,7 +660,6 @@
},
"key_assigner": {
"assign": "",
"assigning": "",
"modifiers": {
"alt": "",
"ctrl": "",
-1
View File
@@ -660,7 +660,6 @@
},
"key_assigner": {
"assign": "",
"assigning": "",
"modifiers": {
"alt": "",
"ctrl": "",
-1
View File
@@ -660,7 +660,6 @@
},
"key_assigner": {
"assign": "",
"assigning": "",
"modifiers": {
"alt": "",
"ctrl": "",
+1 -86
View File
@@ -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');
+1 -1
View File
@@ -42,7 +42,7 @@ input {
}
}
ha-icon {
frigate-card-icon {
display: block;
height: 100%;
width: 100%;
+5 -5
View File
@@ -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;
}
+2 -3
View File
@@ -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 {
+14
View File
@@ -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;
}
+2 -2
View File
@@ -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;
}
+1 -1
View File
@@ -11,7 +11,7 @@
display: none;
}
ha-icon {
frigate-card-icon {
position: absolute;
top: 10px;
right: 10px;
+2 -2
View File
@@ -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 {
+7 -5
View File
@@ -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;
}
+9
View File
@@ -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;
}
+1 -1
View File
@@ -21,7 +21,7 @@
position: relative;
}
img.background {
frigate-card-icon.background {
display: block;
width: 100%;
height: 100%;
+2 -2
View File
@@ -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;
+6 -6
View File
@@ -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;
}
+7 -4
View File
@@ -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;
}
+15 -11
View File
@@ -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<void>;
pause(): Promise<void>;
@@ -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.
// *************************************************************************
-16
View File
@@ -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;
}
};
+17 -124
View File
@@ -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<boolean> => {
'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;
-32
View File
@@ -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;
-201
View File
@@ -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.
-7
View File
@@ -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).
-23
View File
@@ -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';
}
};
-58
View File
@@ -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';
}
};
-89
View File
@@ -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';
}
};
-204
View File
@@ -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;
}
-115
View File
@@ -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;
};
@@ -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',
}),
);
});
});
});
@@ -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',
});
});
@@ -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',
});
});
});
@@ -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,
}),
}),
);
});
});
});
@@ -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',
+1 -35
View File
@@ -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());
+1 -1
View File
@@ -285,7 +285,7 @@ describe('config defaults', () => {
},
},
status_bar: {
height: 46,
height: 30,
items: {
engine: {
enabled: true,
-27
View File
@@ -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();
});
});
+1 -23
View File
@@ -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();
+7 -1
View File
@@ -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",