diff --git a/README.md b/README.md
index febc27fe..16682aa8 100644
--- a/README.md
+++ b/README.md
@@ -414,7 +414,18 @@ Parameters for the `custom:frigate-card-menu-submenu` element are identical to t
| Parameter | Description |
| - | - |
| `type` | Must be `custom:frigate-card-menu-submenu`. |
-| `items` | A list of menu items. Each menu item in turn also follows the parameters the [stock Home Assistant Icon Element](https://www.home-assistant.io/lovelace/picture-elements/#icon-element). Typical usage would set the `title` parameter to control the text displayed for the menu item, the `icon` parameter to control the icon displayed for the menu item and one or more actions (e.g. `tap_action`, `double_tap_action` or `hold_action`) to configure the action to take. Unlike the stock Icon Element, the `icon` parameter is optional for individual menu items; if unspecified no icon is displayed for that menu item.|
+| `items` | A list of menu items, as described below. |
+
+##### Submenu Items
+
+| Parameter | Default | Description |
+| - | - | - |
+| `title` | | An optional title to display. |
+| `icon` | | An optional item icon to display. |
+| `entity` | | An optional Home Assistant entity from which title, icon and style can be automatically computed. |
+| `state_color` | `true` | Whether or not the title and icon should be stylized based on state. |
+| `style` | | Position and style the element using CSS. |
+| `tap_action`, `double_tap_action` or `hold_action` | | Standard [Home Assistant action configuration](https://www.home-assistant.io/lovelace/actions). |
See the [Configuring a Submenu example](#configuring-a-submenu-example).
diff --git a/src/card.ts b/src/card.ts
index f77c42a4..f1814bcc 100644
--- a/src/card.ts
+++ b/src/card.ts
@@ -9,7 +9,7 @@ import {
} from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
-import { styleMap } from 'lit/directives/style-map.js';
+import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { until } from 'lit/directives/until.js';
import {
HomeAssistant,
@@ -222,6 +222,16 @@ export class FrigateCard extends LitElement {
};
}
+ /**
+ * Get the style of emphasized menu items.
+ * @returns A StyleInfo.
+ */
+ protected _getEmphasizedStyle(): StyleInfo {
+ return {
+ color: 'var(--primary-color, white)',
+ };
+ }
+
/**
* Get a FrigateCard MenuButton given a set of parameters.
* @param params Menu button parameters.
@@ -234,7 +244,7 @@ export class FrigateCard extends LitElement {
type: 'custom:frigate-card-menu-icon',
title: params.title,
icon: params.icon,
- style: params.emphasize ? FrigateCardMenu.getEmphasizedStyle() : {},
+ style: params.emphasize ? this._getEmphasizedStyle() : {},
tap_action: params.tap_action
? createFrigateCardCustomAction(params.tap_action)
: undefined,
diff --git a/src/common.ts b/src/common.ts
index a87240aa..4c15471f 100644
--- a/src/common.ts
+++ b/src/common.ts
@@ -1,6 +1,8 @@
+import { HassEntity, MessageBase } from 'home-assistant-js-websocket';
+import { HomeAssistant, stateIcon } from 'custom-card-helpers';
+import { StyleInfo } from 'lit/directives/style-map';
import { ZodSchema, z } from 'zod';
-import { MessageBase } from 'home-assistant-js-websocket';
-import { HomeAssistant } from 'custom-card-helpers';
+
import { localize } from './localize/localize.js';
import {
ActionType,
@@ -11,6 +13,7 @@ import {
Message,
SignedPath,
signedPathSchema,
+ StateParameters,
} from './types.js';
const MEDIA_INFO_HEIGHT_CUTOFF = 50;
@@ -264,8 +267,11 @@ export function isValidMediaShowInfo(info: MediaShowInfo): boolean {
* @returns A FrigateCardCustomAction or null if it cannot be converted.
*/
export function convertActionToFrigateCardCustomAction(
- action: ActionType,
+ action: ActionType | null,
): FrigateCardCustomAction | null {
+ if (!action) {
+ return null;
+ }
// Parse a custom event as other things could generate ll-custom events that
// are not related to Frigate Card.
const parseResult = frigateCardCustomActionSchema.safeParse(action);
@@ -310,3 +316,69 @@ export function getActionConfigGivenAction(
}
return null;
}
+
+/**
+ * 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),
+ };
+}
+
+/**
+ * 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 &&
+ ['on', 'active', 'home'].includes(state.state)
+ ) {
+ params.style = { ...computeStyle(state), ...params.style };
+ }
+ params.title = params.title ?? (state?.attributes?.friendly_name || params.entity);
+ params.icon = params.icon ?? stateIcon(state);
+ return params;
+}
diff --git a/src/components/menu.ts b/src/components/menu.ts
index d3398512..787ea5d6 100644
--- a/src/components/menu.ts
+++ b/src/components/menu.ts
@@ -1,16 +1,14 @@
-import { HassEntity } from 'home-assistant-js-websocket';
-import { HomeAssistant, handleAction, hasAction, stateIcon } from 'custom-card-helpers';
+import { HomeAssistant, handleAction, hasAction } from 'custom-card-helpers';
import {
CSSResultGroup,
LitElement,
TemplateResult,
html,
unsafeCSS,
- PropertyValues,
} from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
-import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
+import { styleMap } from 'lit/directives/style-map.js';
import { actionHandler } from '../action-handler-directive.js';
@@ -21,11 +19,12 @@ import type {
ExtendedHomeAssistant,
MenuButton,
MenuConfig,
+ StateParameters,
} from '../types.js';
import {
convertActionToFrigateCardCustomAction,
getActionConfigGivenAction,
- shouldUpdateBasedOnHass,
+ refreshDynamicStateParameters,
} from '../common.js';
import menuStyle from '../scss/menu.scss';
@@ -86,7 +85,7 @@ export class FrigateCardMenu extends LitElement {
const interaction: string = ev.detail.action;
const action = getActionConfigGivenAction(interaction, config);
- if (!config || !action || !interaction) {
+ if (!config || !interaction) {
return;
}
@@ -109,39 +108,6 @@ export class FrigateCardMenu extends LitElement {
handleAction(this, this.hass as HomeAssistant, config, interaction);
}
- /**
- * Determine whether the menu should be updated.
- * @param changedProps The changed properties.
- * @returns `true` if the menu should be updated, otherwise `false`.
- */
- protected shouldUpdate(changedProps: PropertyValues): boolean {
- const oldHass = changedProps.get('hass') as HomeAssistant | undefined;
-
- if (changedProps.size > 1 || !oldHass) {
- return true;
- }
-
- // Extract the entities the menu rendering depends on (if any).
- const entities: string[] = [];
- for (let i = 0; i < this.buttons.length; i++) {
- const button = this.buttons[i];
- if (button.type == 'custom:frigate-card-menu-state-icon') {
- entities.push(button.entity);
- }
- }
- return shouldUpdateBasedOnHass(this.hass, oldHass, entities);
- }
-
- /**
- * Get the style of emphasized menu items.
- * @returns A StyleInfo.
- */
- public static getEmphasizedStyle(): StyleInfo {
- return {
- color: 'var(--primary-color, white)',
- };
- }
-
/**
* Render a button.
* @param button The button configuration to render.
@@ -150,19 +116,17 @@ export class FrigateCardMenu extends LitElement {
protected _renderButton(button: MenuButton): TemplateResult | void {
if (button.type == 'custom:frigate-card-menu-submenu') {
return html`
`;
}
- let state: HassEntity | null = null;
- let title = button.title;
- let icon = button.icon;
- let style = button.style || {};
+ let stateParameters: StateParameters = {...button};
- if (icon == FRIGATE_BUTTON_MENU_ICON) {
- icon =
+ if (stateParameters.icon == FRIGATE_BUTTON_MENU_ICON) {
+ stateParameters.icon =
this._menuConfig?.mode.startsWith('hidden-') && !this.expand
? 'mdi:alpha-f-box-outline'
: 'mdi:alpha-f-box';
@@ -172,16 +136,7 @@ export class FrigateCardMenu extends LitElement {
if (!this.hass) {
return;
}
- state = this.hass.states[button.entity];
- if (
- !!state &&
- button.state_color &&
- ['on', 'active', 'home'].includes(state.state)
- ) {
- style = { ...style, ...FrigateCardMenu.getEmphasizedStyle() };
- }
- title = title ?? (state?.attributes?.friendly_name || button.entity);
- icon = icon ?? stateIcon(state);
+ stateParameters = refreshDynamicStateParameters(this.hass, stateParameters);
}
const hasHold = hasAction(button.hold_action);
@@ -197,17 +152,17 @@ export class FrigateCardMenu extends LitElement {
// - title (replaced with .label)
return html` this._actionHandler(ev, button)}
.actionHandler=${actionHandler({
hasHold: hasHold,
hasDoubleClick: hasDoubleClick,
})}
>
-
+
`;
}
diff --git a/src/components/submenu.ts b/src/components/submenu.ts
index 58293024..c9b9f60e 100644
--- a/src/components/submenu.ts
+++ b/src/components/submenu.ts
@@ -1,22 +1,60 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators';
+import { hasAction, HomeAssistant } from 'custom-card-helpers';
+import { styleMap } from 'lit/directives/style-map';
+import { ExtendedHomeAssistant, MenuSubmenu, MenuSubmenuItem } from '../types.js';
import { actionHandler } from '../action-handler-directive.js';
-import { MenuSubmenu } from '../types.js';
+import { refreshDynamicStateParameters } from '../common.js';
import submenuStyle from '../scss/submenu.scss';
-import { hasAction } from 'custom-card-helpers';
-import { styleMap } from 'lit/directives/style-map';
@customElement('frigate-card-submenu')
export class FrigateCardSubmenu extends LitElement {
+ @property({ attribute: false })
+ public hass?: HomeAssistant & ExtendedHomeAssistant;
+
@property({ attribute: false })
public submenu?: MenuSubmenu;
+ protected _renderItem(item: MenuSubmenuItem): TemplateResult | void {
+ if (!this.hass) {
+ return;
+ }
+ const stateParameters = refreshDynamicStateParameters(this.hass, {...item});
+
+ return html`
+ {
+ // Attach the action config so ascendants have access to it.
+ ev.detail.config = item;
+ }}
+ .actionHandler=${actionHandler({
+ hasHold: hasAction(item.hold_action),
+ hasDoubleClick: hasAction(item.double_tap_action),
+ })}
+ >
+ ${stateParameters.title || ''}
+ ${stateParameters.icon
+ ? html`
+ `
+ : ``}
+
+ `;
+ }
+
protected render(): TemplateResult {
if (!this.submenu) {
return html``;
}
+
return html`
- ${this.submenu.items.map(
- (item) => html`
- {
- // Attach the action config so ascendants have access to it.
- ev.detail.config = item;
- }}
- .actionHandler=${actionHandler({
- hasHold: hasAction(item.hold_action),
- hasDoubleClick: hasAction(item.double_tap_action),
- })}
- >
- ${item.title || ''}
- ${item.icon
- ? html`
- `
- : ``}
-
- `,
- )}
+ ${this.submenu.items.map(this._renderItem.bind(this))}
`;
}
diff --git a/src/types.ts b/src/types.ts
index 6355ec65..622aa007 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,3 +1,4 @@
+import { StyleInfo } from 'lit/directives/style-map';
import {
CallServiceActionConfig,
CustomActionConfig,
@@ -269,11 +270,19 @@ export const menuStateIconSchema = stateIconSchema.merge(
);
export type MenuStateIcon = z.infer;
+const menuSubmenuItemSchema = elementsBaseSchema.merge(
+ z.object({
+ entity: z.string().optional(),
+ icon: z.string().optional(),
+ state_color: z.boolean().default(true),
+ }),
+);
+export type MenuSubmenuItem = z.infer;
+
export const menuSubmenuSchema = iconSchema.merge(
z.object({
type: z.literal('custom:frigate-card-menu-submenu'),
- // Menu items don't strictly require their own icon.
- items: iconSchema.omit({ type: true }).partial({ icon: true }).array(),
+ items: menuSubmenuItemSchema.array(),
}),
);
export type MenuSubmenu = z.infer;
@@ -644,6 +653,14 @@ export interface Message {
icon?: string;
}
+export interface StateParameters {
+ entity?: string;
+ icon?: string;
+ title?: string | null;
+ state_color?: boolean;
+ style?: StyleInfo;
+}
+
/**
* Home Assistant API types.
*/