Merge pull request #247 from dermotduffy/state-in-submenus

Add support for submenus to reflect HA state
This commit is contained in:
Dermot Duffy
2021-12-19 13:10:02 -08:00
committed by GitHub
6 changed files with 175 additions and 98 deletions
+12 -1
View File
@@ -414,7 +414,18 @@ Parameters for the `custom:frigate-card-menu-submenu` element are identical to t
| Parameter | Description | | Parameter | Description |
| - | - | | - | - |
| `type` | Must be `custom:frigate-card-menu-submenu`. | | `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). See the [Configuring a Submenu example](#configuring-a-submenu-example).
+12 -2
View File
@@ -9,7 +9,7 @@ import {
} from 'lit'; } from 'lit';
import { customElement, property, query, state } from 'lit/decorators.js'; import { customElement, property, query, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.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 { until } from 'lit/directives/until.js';
import { import {
HomeAssistant, 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. * Get a FrigateCard MenuButton given a set of parameters.
* @param params Menu button parameters. * @param params Menu button parameters.
@@ -234,7 +244,7 @@ export class FrigateCard extends LitElement {
type: 'custom:frigate-card-menu-icon', type: 'custom:frigate-card-menu-icon',
title: params.title, title: params.title,
icon: params.icon, icon: params.icon,
style: params.emphasize ? FrigateCardMenu.getEmphasizedStyle() : {}, style: params.emphasize ? this._getEmphasizedStyle() : {},
tap_action: params.tap_action tap_action: params.tap_action
? createFrigateCardCustomAction(params.tap_action) ? createFrigateCardCustomAction(params.tap_action)
: undefined, : undefined,
+75 -3
View File
@@ -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 { ZodSchema, z } from 'zod';
import { MessageBase } from 'home-assistant-js-websocket';
import { HomeAssistant } from 'custom-card-helpers';
import { localize } from './localize/localize.js'; import { localize } from './localize/localize.js';
import { import {
ActionType, ActionType,
@@ -11,6 +13,7 @@ import {
Message, Message,
SignedPath, SignedPath,
signedPathSchema, signedPathSchema,
StateParameters,
} from './types.js'; } from './types.js';
const MEDIA_INFO_HEIGHT_CUTOFF = 50; 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. * @returns A FrigateCardCustomAction or null if it cannot be converted.
*/ */
export function convertActionToFrigateCardCustomAction( export function convertActionToFrigateCardCustomAction(
action: ActionType, action: ActionType | null,
): FrigateCardCustomAction | null { ): FrigateCardCustomAction | null {
if (!action) {
return null;
}
// Parse a custom event as other things could generate ll-custom events that // Parse a custom event as other things could generate ll-custom events that
// are not related to Frigate Card. // are not related to Frigate Card.
const parseResult = frigateCardCustomActionSchema.safeParse(action); const parseResult = frigateCardCustomActionSchema.safeParse(action);
@@ -310,3 +316,69 @@ export function getActionConfigGivenAction(
} }
return null; 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;
}
+15 -60
View File
@@ -1,16 +1,14 @@
import { HassEntity } from 'home-assistant-js-websocket'; import { HomeAssistant, handleAction, hasAction } from 'custom-card-helpers';
import { HomeAssistant, handleAction, hasAction, stateIcon } from 'custom-card-helpers';
import { import {
CSSResultGroup, CSSResultGroup,
LitElement, LitElement,
TemplateResult, TemplateResult,
html, html,
unsafeCSS, unsafeCSS,
PropertyValues,
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.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'; import { actionHandler } from '../action-handler-directive.js';
@@ -21,11 +19,12 @@ import type {
ExtendedHomeAssistant, ExtendedHomeAssistant,
MenuButton, MenuButton,
MenuConfig, MenuConfig,
StateParameters,
} from '../types.js'; } from '../types.js';
import { import {
convertActionToFrigateCardCustomAction, convertActionToFrigateCardCustomAction,
getActionConfigGivenAction, getActionConfigGivenAction,
shouldUpdateBasedOnHass, refreshDynamicStateParameters,
} from '../common.js'; } from '../common.js';
import menuStyle from '../scss/menu.scss'; import menuStyle from '../scss/menu.scss';
@@ -86,7 +85,7 @@ export class FrigateCardMenu extends LitElement {
const interaction: string = ev.detail.action; const interaction: string = ev.detail.action;
const action = getActionConfigGivenAction(interaction, config); const action = getActionConfigGivenAction(interaction, config);
if (!config || !action || !interaction) { if (!config || !interaction) {
return; return;
} }
@@ -109,39 +108,6 @@ export class FrigateCardMenu extends LitElement {
handleAction(this, this.hass as HomeAssistant, config, interaction); 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. * Render a button.
* @param button The button configuration to render. * @param button The button configuration to render.
@@ -150,19 +116,17 @@ export class FrigateCardMenu extends LitElement {
protected _renderButton(button: MenuButton): TemplateResult | void { protected _renderButton(button: MenuButton): TemplateResult | void {
if (button.type == 'custom:frigate-card-menu-submenu') { if (button.type == 'custom:frigate-card-menu-submenu') {
return html` <frigate-card-submenu return html` <frigate-card-submenu
.hass=${this.hass}
.submenu=${button} .submenu=${button}
@action=${this._actionHandler.bind(this)} @action=${this._actionHandler.bind(this)}
> >
</frigate-card-submenu>`; </frigate-card-submenu>`;
} }
let state: HassEntity | null = null; let stateParameters: StateParameters = {...button};
let title = button.title;
let icon = button.icon;
let style = button.style || {};
if (icon == FRIGATE_BUTTON_MENU_ICON) { if (stateParameters.icon == FRIGATE_BUTTON_MENU_ICON) {
icon = stateParameters.icon =
this._menuConfig?.mode.startsWith('hidden-') && !this.expand this._menuConfig?.mode.startsWith('hidden-') && !this.expand
? 'mdi:alpha-f-box-outline' ? 'mdi:alpha-f-box-outline'
: 'mdi:alpha-f-box'; : 'mdi:alpha-f-box';
@@ -172,16 +136,7 @@ export class FrigateCardMenu extends LitElement {
if (!this.hass) { if (!this.hass) {
return; return;
} }
state = this.hass.states[button.entity]; stateParameters = refreshDynamicStateParameters(this.hass, stateParameters);
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);
} }
const hasHold = hasAction(button.hold_action); const hasHold = hasAction(button.hold_action);
@@ -197,17 +152,17 @@ export class FrigateCardMenu extends LitElement {
// - title (replaced with .label) // - title (replaced with .label)
return html` <ha-icon-button return html` <ha-icon-button
class="${classMap(classes)}" class="${classMap(classes)}"
style="${styleMap(style)}" style="${styleMap(stateParameters.style || {})}"
icon=${icon || 'mdi:gesture-tap-button'} icon=${stateParameters.icon || 'mdi:gesture-tap-button'}
.label=${title || ''} .label=${stateParameters.title || ''}
title=${title || ''} title=${stateParameters.title || ''}
@action=${(ev) => this._actionHandler(ev, button)} @action=${(ev) => this._actionHandler(ev, button)}
.actionHandler=${actionHandler({ .actionHandler=${actionHandler({
hasHold: hasHold, hasHold: hasHold,
hasDoubleClick: hasDoubleClick, hasDoubleClick: hasDoubleClick,
})} })}
> >
<ha-icon icon="${icon || 'mdi:gesture-tap-button'}"></ha-icon> <ha-icon icon="${stateParameters.icon || 'mdi:gesture-tap-button'}"></ha-icon>
</ha-icon-button>`; </ha-icon-button>`;
} }
+42 -30
View File
@@ -1,22 +1,60 @@
import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit'; import { CSSResultGroup, html, LitElement, TemplateResult, unsafeCSS } from 'lit';
import { customElement, property } from 'lit/decorators'; 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 { actionHandler } from '../action-handler-directive.js';
import { MenuSubmenu } from '../types.js'; import { refreshDynamicStateParameters } from '../common.js';
import submenuStyle from '../scss/submenu.scss'; import submenuStyle from '../scss/submenu.scss';
import { hasAction } from 'custom-card-helpers';
import { styleMap } from 'lit/directives/style-map';
@customElement('frigate-card-submenu') @customElement('frigate-card-submenu')
export class FrigateCardSubmenu extends LitElement { export class FrigateCardSubmenu extends LitElement {
@property({ attribute: false })
public hass?: HomeAssistant & ExtendedHomeAssistant;
@property({ attribute: false }) @property({ attribute: false })
public submenu?: MenuSubmenu; public submenu?: MenuSubmenu;
protected _renderItem(item: MenuSubmenuItem): TemplateResult | void {
if (!this.hass) {
return;
}
const stateParameters = refreshDynamicStateParameters(this.hass, {...item});
return html`
<mwc-list-item
style="${styleMap(stateParameters.style || {})}"
graphic="icon"
aria-label="${stateParameters.title || ''}"
@action=${(ev) => {
// 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` <ha-icon
style="${styleMap(stateParameters.style || {})}"
slot="graphic"
icon="${stateParameters.icon}"
>
</ha-icon>`
: ``}
</mwc-list-item>
`;
}
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this.submenu) { if (!this.submenu) {
return html``; return html``;
} }
return html` return html`
<ha-button-menu corner="BOTTOM_LEFT"> <ha-button-menu corner="BOTTOM_LEFT">
<ha-icon-button <ha-icon-button
@@ -31,33 +69,7 @@ export class FrigateCardSubmenu extends LitElement {
> >
<ha-icon icon="${this.submenu.icon}"></ha-icon> <ha-icon icon="${this.submenu.icon}"></ha-icon>
</ha-icon-button> </ha-icon-button>
${this.submenu.items.map( ${this.submenu.items.map(this._renderItem.bind(this))}
(item) => html`
<mwc-list-item
style="${styleMap(item.style || {})}"
graphic="icon"
aria-label="${item.title || ''}"
@action=${(ev) => {
// 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` <ha-icon
style="${styleMap(item.style || {})}"
slot="graphic"
icon="${item.icon}"
>
</ha-icon>`
: ``}
</mwc-list-item>
`,
)}
</ha-button-menu> </ha-button-menu>
`; `;
} }
+19 -2
View File
@@ -1,3 +1,4 @@
import { StyleInfo } from 'lit/directives/style-map';
import { import {
CallServiceActionConfig, CallServiceActionConfig,
CustomActionConfig, CustomActionConfig,
@@ -269,11 +270,19 @@ export const menuStateIconSchema = stateIconSchema.merge(
); );
export type MenuStateIcon = z.infer<typeof menuStateIconSchema>; export type MenuStateIcon = z.infer<typeof menuStateIconSchema>;
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<typeof menuSubmenuItemSchema>;
export const menuSubmenuSchema = iconSchema.merge( export const menuSubmenuSchema = iconSchema.merge(
z.object({ z.object({
type: z.literal('custom:frigate-card-menu-submenu'), type: z.literal('custom:frigate-card-menu-submenu'),
// Menu items don't strictly require their own icon. items: menuSubmenuItemSchema.array(),
items: iconSchema.omit({ type: true }).partial({ icon: true }).array(),
}), }),
); );
export type MenuSubmenu = z.infer<typeof menuSubmenuSchema>; export type MenuSubmenu = z.infer<typeof menuSubmenuSchema>;
@@ -644,6 +653,14 @@ export interface Message {
icon?: string; icon?: string;
} }
export interface StateParameters {
entity?: string;
icon?: string;
title?: string | null;
state_color?: boolean;
style?: StyleInfo;
}
/** /**
* Home Assistant API types. * Home Assistant API types.
*/ */