From 8c6047da69a468607756638555fc1a5b9b408100 Mon Sep 17 00:00:00 2001 From: Keiran O'Leary Date: Thu, 29 Feb 2024 09:34:34 +1000 Subject: [PATCH 1/3] Use targets selector This uses the native homeassistant target selector over the previous entities selector. This means we can target areas as well as individual lights, and aligns with the expectations of the platform in general. This commit also includes a refactor of the card configuration that will migrate previous configurations to the latest state (specifically from the use of entities to targets for previous version migration) --- cypress/e2e/card.cy.js | 79 ++++++++++++++++++++++++++++- package.json | 1 + src/config/lookup.ts | 35 +++++++++++++ src/config/parse.ts | 35 +++++++++++++ src/const.ts | 6 +-- src/editor.ts | 40 ++++++--------- src/elements/entities-picker.ts | 88 --------------------------------- src/elements/targets-picker.ts | 60 ++++++++++++++++++++++ src/magic-home-party-card.ts | 81 +++++++++++++++++++----------- src/types.ts | 16 ++++-- src/util.ts | 10 +++- yarn.lock | 5 ++ 12 files changed, 303 insertions(+), 153 deletions(-) create mode 100644 src/config/lookup.ts create mode 100644 src/config/parse.ts delete mode 100644 src/elements/entities-picker.ts create mode 100644 src/elements/targets-picker.ts diff --git a/cypress/e2e/card.cy.js b/cypress/e2e/card.cy.js index cae874c..d78f59d 100644 --- a/cypress/e2e/card.cy.js +++ b/cypress/e2e/card.cy.js @@ -5,6 +5,20 @@ describe('magic home party', () => { const lime = [0, 255, 0]; const yellow = [255, 255, 0]; + const hassAreas = () => ({ + areas: { + kitchen: { name: "Kitchen" }, + bathroom: { name: "Bathroom" }, + } + }) + + const hassDevices = () => ({ + devices: { + 123456: { name: "***", name_by_user: "Dishwasher" }, + ABCDEF: { name: "Fan" }, + } + }) + const hassStates = () => ({ states: { foo: { @@ -29,6 +43,65 @@ describe('magic home party', () => { .waitForCustomElement('magic-home-party-card') ); + describe('cofiguration', () => { + const hass = { + ...hassAreas(), + ...hassDevices(), + ...hassStates(), + }; + + const withConfig = (config) => + cy.setupCustomCard('magic-home-party-card', { + colours: [red, lime], + ...config, + }, hass).get('.targets') + + it('resolves legacy entities', () => { + withConfig({ one: "one", entities: ['foo', 'bar']}) + .should('contain', 'Foo light') + .and('contain', 'Bar light') + }); + + describe("entity_id targets", () => { + it('resolves a single entity_id', () => { + withConfig({ two: "two", targets: {entity_id: 'foo'}}) + .should('contain', 'Foo light') + }); + + it('resolves a multiple entity_ids', () => { + withConfig({ targets: {entity_id: ['foo', 'bar']}}) + .should('contain', 'Foo light') + .and('contain', 'Bar light') + }); + }) + + describe("device_id targets", () => { + it('resolves a single device_id', () => { + withConfig({ two: "two", targets: {device_id: '123456'}}) + .should('contain', 'Dishwasher') + }); + + it('resolves a multiple device_ids', () => { + withConfig({ two: "two", targets: {device_id: ['123456', 'ABCDEF']}}) + .should('contain', 'Dishwasher') + .and('contain', 'Fan') + }); + }) + + describe("area_id targets", () => { + it('resolves a single area_id', () => { + withConfig({ two: "two", targets: {area_id: 'kitchen'}}) + .should('contain', 'Kitchen') + }); + + it('resolves a multiple area_id', () => { + withConfig({ two: "two", targets: {area_id: ['kitchen', 'bathroom']}}) + .should('contain', 'Kitchen') + .and('contain', 'Bathroom') + }); + }) + }); + describe('card', () => { it('renders a gradient', () => { const colours = [red, lime]; @@ -43,12 +116,14 @@ describe('magic home party', () => { it('plays the effect on click', () => { const hass = { + ...hassAreas(), ...hassStates(), callService: cy.stub(), }; const cardConfig = { entities: Object.keys(hass.states), + targets: { area_id: 'kitchen', entity_id: ['foo', 'bar'] }, colours: [red, lime], }; @@ -57,7 +132,7 @@ describe('magic home party', () => { .click() .then(() => { expect(hass.callService).to.be.calledWith('flux_led', 'set_custom_effect', { - entity_id: cardConfig.entities, + ...cardConfig.targets, colors: cardConfig.colours, speed_pct: 20, transition: 'gradual', @@ -96,7 +171,7 @@ describe('magic home party', () => { .should( () => { expect(hass.callService).to.be.calledWith('light', 'turn_on', { - entity_id: cardConfig.entities, + target: {entity_id: cardConfig.entities}, rgb_color: yellow, }); }, diff --git a/package.json b/package.json index dfaed40..b374deb 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "typescript": "^4.9.4" }, "dependencies": { + "@mdi/js": "^7.4.47", "custom-card-helpers": "^1.9.0", "lit": "^2.6.0" } diff --git a/src/config/lookup.ts b/src/config/lookup.ts new file mode 100644 index 0000000..a27b130 --- /dev/null +++ b/src/config/lookup.ts @@ -0,0 +1,35 @@ +import { HomeAssistant } from "custom-card-helpers" + +type Area = { + name: string +} + +type Device = { + name: string; + name_by_user: string +} + +type HomeAssistantWithExtras = HomeAssistant & { + areas: Record + devices: Record +} + +export const toFriendlyName = ( + identifier: 'area_id' | 'device_id' | 'entity_id', + _hass: HomeAssistant, +) => (value: string): string => { + const hass = _hass as HomeAssistantWithExtras + let name: string | undefined = undefined + + if (identifier == 'area_id') { + name = hass.areas[value].name + } else if (identifier == 'device_id') { + name = hass.devices[value].name_by_user || hass.devices[value].name + } else if (identifier == 'entity_id') { + name = toState(hass)(value).attributes.friendly_name + } + + return name || `Unknown (${value})` +} + +export const toState = (hass: HomeAssistant) => (value: string) => hass.states[value] diff --git a/src/config/parse.ts b/src/config/parse.ts new file mode 100644 index 0000000..92e5435 --- /dev/null +++ b/src/config/parse.ts @@ -0,0 +1,35 @@ +import { BaseConfig, Colour, HassServiceTarget, MagicHomePartyConfig } from "../types" + +export const parseConfig = (input: BaseConfig): MagicHomePartyConfig => + withTargets(withSpeed(withColours(input))) + +const withColours = (config: T): T & {colours: Colour[]} => ({ + colours: [], + ...config, +}) + +const withSpeed = (config: T): T & {speed: number} => ({ + speed: 20, + ...config, +}) + +const withTargets = (config: T): T & {targets: HassServiceTarget} => { + // Has targets already + if ((config as any).targets !== undefined) { + return config as T & {entities: [], targets: HassServiceTarget} + // Migrate entities to targets + } else if ((config as any).entities !== undefined) { + return { + ...config, + targets: { + entity_id: (config as any).entities + } + } + // Defaults + } else { + return { + ...config, + targets: {} + } + } +} diff --git a/src/const.ts b/src/const.ts index 6b6f753..da3612b 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,9 +1,5 @@ import { css } from 'lit'; -import { Colours, MagicHomePartyConfig } from './types'; - -export const DEFAULT_CONFIG: Partial = { - speed: 20, -} +import { Colours } from './types'; export const CARD_VERSION = '0.1.3'; diff --git a/src/editor.ts b/src/editor.ts index b3a96a8..743d8c8 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -1,12 +1,13 @@ import { fireEvent, HomeAssistant } from 'custom-card-helpers'; import { css, html, LitElement } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import { colours, DEFAULT_CONFIG } from './const'; +import { colours } from './const'; import './elements/chip'; -import './elements/entities-picker'; +import './elements/targets-picker'; import './elements/palette'; -import { ChipEvent, Colour, MagicHomePartyConfig } from './types'; +import { BaseConfig, ChipEvent, Colour, MagicHomePartyConfig } from './types'; import { copyToClipboard } from './clipboard'; +import { parseConfig } from './config/parse'; const { values } = Object; @@ -16,19 +17,11 @@ export class MagicHomePartyEditor extends LitElement { @state() private config!: MagicHomePartyConfig; @state() private selectedColours: Colour[] = []; - @state() private selectedEntities: string[] = []; - @state() private speed: number = 50; - - public setConfig(config: MagicHomePartyConfig) { - this.config = { - ...DEFAULT_CONFIG, - ...config, - colours: config.colours || [], - entities: config.entities || [], - }; + @state() private speed: number | undefined = undefined; + public setConfig(config: BaseConfig) { + this.config = parseConfig(config) this.selectedColours = this.config.colours; - this.selectedEntities = this.config.entities; this.speed = this.config.speed; } @@ -41,7 +34,7 @@ export class MagicHomePartyEditor extends LitElement { Single click to preview a color. Double click to add-or-remove a color.
-

Selected Colours

+

Selected colours

Copy to clipboard
-

Selected Lights

- Selected lights + - +

Transition speed

this.hass.callService('light', 'turn_on', { - entity_id: this.selectedEntities, + target: this.config.targets, rgb_color: colour, }); - private _entitiesChanged(event: any) { + private _targetsChanged(event: any) { event.stopPropagation(); - this.selectedEntities = [...event.detail.value]; + this.config.targets = {...event.detail.value}; this._updateConfig(); } @@ -116,7 +109,6 @@ export class MagicHomePartyEditor extends LitElement { const newConfig = { ...this.config, colours: this.selectedColours, - entities: this.selectedEntities, speed: this.speed, }; diff --git a/src/elements/entities-picker.ts b/src/elements/entities-picker.ts deleted file mode 100644 index 1d2d151..0000000 --- a/src/elements/entities-picker.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { fireEvent, HomeAssistant } from 'custom-card-helpers'; -import { css, html, LitElement } from 'lit'; -import { customElement, property, state } from 'lit/decorators.js'; -import { EntityFilter } from '../types'; -import { loadHomeAsssistantComponents } from '../util'; - -@customElement('magic-home-party-entities-picker') -export class EntitiesPicker extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - @property({ type: Array }) value?: string[]; - @property() domains: string[] = ['light']; - - private get currentEntities() { - return this.value || []; - } - - connectedCallback() { - super.connectedCallback(); - void loadHomeAsssistantComponents(); - } - - // The entity-picker states dropdown is cached - // (breaking this with a different entity filter each tile - _buildEntityFilter: () => EntityFilter = () => (e: { entity_id: string }) => - !this.value || !this.value.includes(e.entity_id); - - @state() private entityFilter: EntityFilter = this._buildEntityFilter(); - - public render = () => html` ${this.currentEntities.map( - entityId => - html`` - )} - `; - - private _entityChanged(event: any) { - event.stopPropagation(); - - const currentValue = (event.currentTarget as any).curValue; - const newValue = event.detail.value; - - if (!newValue || this.currentEntities.includes(newValue)) { - this._updateEntities(this.currentEntities.filter(entity => entity !== currentValue)); - } else { - this._updateEntities( - this.currentEntities.map(entity => (entity === currentValue ? newValue : entity)) - ); - } - } - - private _updateEntities(entities: string[]) { - this.value = entities.filter(Boolean); - fireEvent(this, 'value-changed', { value: this.value }); - this.entityFilter = this._buildEntityFilter(); - } - - private _addEntity(event: any) { - event.stopPropagation(); - (event.currentTarget as any).value = ''; // Reset value of adding element - this._updateEntities([...this.currentEntities, event.detail.value]); - } - - static styles = css` - :host { - overflow-x: hidden; - } - ` -} - -declare global { - interface HTMLElementTagNameMap { - 'magic-home-party-entities-picker': EntitiesPicker; - } -} diff --git a/src/elements/targets-picker.ts b/src/elements/targets-picker.ts new file mode 100644 index 0000000..91f10b3 --- /dev/null +++ b/src/elements/targets-picker.ts @@ -0,0 +1,60 @@ +import { fireEvent, HomeAssistant } from 'custom-card-helpers'; +import { css, html, LitElement } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { EntityFilter, HassServiceTarget } from '../types'; +import { loadHomeAsssistantComponents } from '../util'; + +interface TargetSelector { + target: { + entity?: EntitySelectorFilter | readonly EntitySelectorFilter[]; + // device?: DeviceSelectorFilter | readonly DeviceSelectorFilter[]; + } | null; +} + +interface EntitySelectorFilter { + integration?: string; + domain?: string | readonly string[]; + device_class?: string | readonly string[]; + supported_features?: number | [number]; +} + +@customElement('magic-home-party-targets-picker') +export class TargetsPicker extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + @property({ type: Object }) value?: HassServiceTarget; + + _targetSelector: TargetSelector = { + target: { + entity: { + domain: "light", + integration: "flux_led", + }, + }, + }; + + public render = () => html` + ` + + private _targetsChanged(event: Event & {detail: { value: HassServiceTarget }}) { + event.stopPropagation(); + fireEvent(this, 'value-changed', { value: event.detail.value }); + } + + static styles = css` + :host { + } + ` +} + +declare global { + interface HTMLElementTagNameMap { + 'magic-home-party-targets-picker': TargetsPicker; + } +} + diff --git a/src/magic-home-party-card.ts b/src/magic-home-party-card.ts index 9ea4388..7658162 100644 --- a/src/magic-home-party-card.ts +++ b/src/magic-home-party-card.ts @@ -1,10 +1,13 @@ import { HomeAssistant, LovelaceCardEditor } from 'custom-card-helpers'; import { css, html, LitElement } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import { CARD_VERSION, DEFAULT_CONFIG, RADIUS } from './const'; +import { CARD_VERSION, RADIUS } from './const'; import './elements/palette'; -import { MagicHomePartyConfig } from './types'; -import { labelColour, linearGradient } from './util'; +import { BaseConfig, MagicHomePartyConfig } from './types'; +import { toFriendlyName, toState } from './config/lookup'; +import { ensureArray, labelColour, linearGradient } from './util'; +import { parseConfig } from './config/parse'; +import { mdiDevices, mdiLightbulb, mdiSofa } from '@mdi/js' console.info( `%c MAGIC-HOME-PARTY-CARD \n%c Version ${CARD_VERSION} `, @@ -26,13 +29,8 @@ export class MagicHomeParty extends LitElement { @state() private config!: MagicHomePartyConfig; - setConfig(config: MagicHomePartyConfig) { - this.config = { - ...DEFAULT_CONFIG, - ...config, - title: '', - entities: config.entities || [], - }; + setConfig(config: BaseConfig) { + this.config = parseConfig(config) } render() { @@ -45,22 +43,17 @@ export class MagicHomeParty extends LitElement { } const foreground = labelColour(this.config.colours[0]); - - const entitiesHtml = this.stateEntities.map( - stateEntity => - html`
- - ${stateEntity.attributes.friendly_name || stateEntity.entity_id} -
` - ); - return html`
-
${entitiesHtml}
+
+ ${this._friendlyAreaNames()} + ${this._friendlyDeviceNames()} + ${this._friendlyEntityNames()} +
`; @@ -71,17 +64,38 @@ export class MagicHomeParty extends LitElement { return document.createElement('magic-home-party-card-editor') as LovelaceCardEditor; } - static getStubConfig() { - return { entities: [] }; - } - - private get stateEntities() { - return this.config.entities.map(entity => this.hass.states[entity]); + private _friendlyAreaNames = () => + ensureArray(this.config.targets.area_id) + .map(toFriendlyName('area_id', this.hass)) + .map(name => html`
+ ${this._icon(mdiSofa)} ${name} +
`) + + private _friendlyDeviceNames = () => + ensureArray(this.config.targets.device_id) + .map(toFriendlyName('device_id', this.hass)) + .map(name => html`
+ ${this._icon(mdiDevices)} ${name} +
`) + + private _friendlyEntityNames = () => + ensureArray(this.config.targets.entity_id) + .map(toState(this.hass)) + .map(state => html`
+ ${this._icon(mdiLightbulb)} ${state.attributes.friendly_name || state.entity_id} +
`) + + private _icon(path: string) { + return html`
+ + + +
` } private playEffect = () => this.hass.callService('flux_led', 'set_custom_effect', { - entity_id: this.config.entities, + ...this.config.targets, colors: this.config.colours, speed_pct: this.config.speed, transition: 'gradual', @@ -96,17 +110,24 @@ export class MagicHomeParty extends LitElement { color: #fff; } - .entities { - padding: 1em 0.5em; + .targets { + padding: 1em 1em; display: flex; flex-wrap: wrap; } - .entity { + .target { display: flex; align-items: center; border-radius: ${RADIUS}; max-height: 1.6em; + margin-right: 6px; + } + + .icon { + width: 20px; + height: 20px; + margin-right: 4px; } `; } diff --git a/src/types.ts b/src/types.ts index 0fab6b2..2cb0f82 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,15 +1,25 @@ import { LovelaceCardConfig } from "custom-card-helpers"; +export type NonUndefined = T extends undefined ? never : T; + export type DoubleClickHandler = (doubleClicked: boolean) => void -export interface MagicHomePartyConfig extends LovelaceCardConfig { +export type BaseConfig = LovelaceCardConfig & { type: "custom:magic-home-party-card", - title?: string - entities: string[] +} + +export interface MagicHomePartyConfig extends BaseConfig { + targets: HassServiceTarget colours: Colour[] speed: number } +export type HassServiceTarget = { + entity_id?: string | string[]; + device_id?: string | string[]; + area_id?: string | string[]; +}; + export type EntityFilter = (entity: {entity_id: string}) => boolean export type ChipEvent = CustomEvent<{colour: Colour, index: number}> diff --git a/src/util.ts b/src/util.ts index 4ec82e0..e995310 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,7 +1,8 @@ import { css } from 'lit'; import { colours } from './const'; -import { Colour, DoubleClickHandler } from './types'; +import { Colour, DoubleClickHandler, NonUndefined } from './types'; +// Availalbe components via Array.from(customElements.l.keys()).sort().join(", "); export const loadHomeAsssistantComponents = () => { if (!customElements.get('ha-entity-picker')) { (customElements.get('hui-entities-card') as any)?.getConfigElement(); @@ -28,6 +29,13 @@ export const labelColour = (background: Colour) => { } }; +type EnsureArray = { + (value: undefined): undefined; + (value: T | T[]): NonUndefined[]; +}; +export const ensureArray: EnsureArray = (value: T | T[]) => + (value === undefined || Array.isArray(value)) ? value || [] : [value] + export const DOUBLE_CLICK_TIMEOUT = 250; export const withDoubleClick = (handler: DoubleClickHandler) => (e: any) => { diff --git a/yarn.lock b/yarn.lock index 93d9462..b953b63 100644 --- a/yarn.lock +++ b/yarn.lock @@ -142,6 +142,11 @@ dependencies: "@lit-labs/ssr-dom-shim" "^1.0.0" +"@mdi/js@^7.4.47": + version "7.4.47" + resolved "https://registry.yarnpkg.com/@mdi/js/-/js-7.4.47.tgz#7d8a4edc9631bffeed80d1ec784f9beae559a76a" + integrity sha512-KPnNOtm5i2pMabqZxpUz7iQf+mfrYZyKCZ8QNz85czgEt7cuHcGorWfdzUMWYA0SD+a6Hn4FmJ+YhzzzjkTZrQ== + "@rollup/plugin-commonjs@^24.0.0": version "24.0.0" resolved "https://registry.yarnpkg.com/@rollup/plugin-commonjs/-/plugin-commonjs-24.0.0.tgz#fb7cf4a6029f07ec42b25daa535c75b05a43f75c" From 19be9bda7830d278f2349f47edc96b21e459d1c9 Mon Sep 17 00:00:00 2001 From: Keiran O'Leary Date: Sat, 2 Mar 2024 09:53:23 +1000 Subject: [PATCH 2/3] Retype speed config I want it to be shown as optional, so that a default can be applied later in the flow (rather than ensuring it exists) --- src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index 2cb0f82..fbc3c49 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,7 +11,7 @@ export type BaseConfig = LovelaceCardConfig & { export interface MagicHomePartyConfig extends BaseConfig { targets: HassServiceTarget colours: Colour[] - speed: number + speed?: number } export type HassServiceTarget = { From c6c1f1fbe6577671762ad2d1f1f0b7572bc3f112 Mon Sep 17 00:00:00 2001 From: Keiran O'Leary Date: Sat, 2 Mar 2024 09:54:46 +1000 Subject: [PATCH 3/3] Bump card version --- src/const.ts | 2 +- src/magic-home-party-card.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/const.ts b/src/const.ts index da3612b..488326f 100644 --- a/src/const.ts +++ b/src/const.ts @@ -1,7 +1,7 @@ import { css } from 'lit'; import { Colours } from './types'; -export const CARD_VERSION = '0.1.3'; +export const CARD_VERSION = '0.1.4'; export const RADIUS = css`0.8em`; diff --git a/src/magic-home-party-card.ts b/src/magic-home-party-card.ts index 7658162..4af65c1 100644 --- a/src/magic-home-party-card.ts +++ b/src/magic-home-party-card.ts @@ -10,9 +10,12 @@ import { parseConfig } from './config/parse'; import { mdiDevices, mdiLightbulb, mdiSofa } from '@mdi/js' console.info( - `%c MAGIC-HOME-PARTY-CARD \n%c Version ${CARD_VERSION} `, - 'color: white; font-weight: bold; background: purple', - 'color: white; font-weight: bold; background: dimgray' + `%c Magic %c Home %c Party %c Card %c ${CARD_VERSION} `, + 'color: #222; font-weight: bold; background: #90f1ef', + 'color: #222; font-weight: bold; background: #ffd6e0', + 'color: #222; font-weight: bold; background: #ffef9f', + 'color: #222; font-weight: bold; background: #c1fba4', + 'color: #ff70a6; font-weight: bold;', ); // Register to UI picker