From da35c263d2c54cb4484108b3ada2ce86be48dd5b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 4 Nov 2019 21:07:09 +0100 Subject: [PATCH] Add scene editor (#4164) * Add scene editor * Update ha-config-scene.ts * Update en.json * Update ha-scene-editor.ts * Partial comments * Types * 1 more * Comments * Lint * Update ha-device-picker.ts * Update ha-device-card.ts --- src/components/device/ha-device-picker.ts | 26 +- src/components/entity/ha-entities-picker.ts | 22 +- src/components/entity/ha-entity-picker.ts | 31 +- src/data/device_registry.ts | 29 + src/data/scene.ts | 91 +++ .../confirmation/dialog-confirmation.ts | 9 +- .../confirmation/show-dialog-confirmation.ts | 2 + .../dialog-device-registry-detail.ts | 4 +- src/fake_data/provide_hass.ts | 1 + src/mixins/subscribe-mixin.ts | 4 +- .../config/dashboard/ha-config-navigation.ts | 1 + .../devices/device-detail/ha-device-card.ts | 11 +- .../config/devices/ha-devices-data-table.ts | 30 +- src/panels/config/ha-panel-config.ts | 5 + src/panels/config/js/condition/zone.tsx | 2 +- src/panels/config/js/script/scene.tsx | 2 +- src/panels/config/js/trigger/geo_location.tsx | 2 +- src/panels/config/js/trigger/zone.tsx | 2 +- .../config/person/dialog-person-detail.ts | 2 +- src/panels/config/scene/ha-config-scene.ts | 81 ++ src/panels/config/scene/ha-scene-dashboard.ts | 213 +++++ src/panels/config/scene/ha-scene-editor.ts | 738 ++++++++++++++++++ .../service/developer-tools-service.js | 4 +- .../lovelace/common/entity/toggle-entity.ts | 4 +- .../common/entity/turn-on-off-entity.ts | 4 +- .../hui-alarm-panel-card-editor.ts | 2 +- .../config-elements/hui-gauge-card-editor.ts | 2 +- .../config-elements/hui-light-card-editor.ts | 2 +- .../hui-media-control-card-editor.ts | 2 +- .../hui-picture-entity-card-editor.ts | 2 +- .../hui-picture-glance-card-editor.ts | 2 +- .../hui-plant-status-card-editor.ts | 2 +- .../config-elements/hui-sensor-card-editor.ts | 2 +- .../hui-thermostat-card-editor.ts | 2 +- .../hui-weather-forecast-card-editor.ts | 2 +- .../entity-rows/hui-scene-entity-row.ts | 9 +- src/state-summary/state-card-scene.js | 9 +- src/state/connection-mixin.ts | 9 +- src/translations/en.json | 43 + src/types.ts | 12 +- 40 files changed, 1327 insertions(+), 95 deletions(-) create mode 100644 src/data/scene.ts create mode 100644 src/panels/config/scene/ha-config-scene.ts create mode 100644 src/panels/config/scene/ha-scene-dashboard.ts create mode 100644 src/panels/config/scene/ha-scene-editor.ts diff --git a/src/components/device/ha-device-picker.ts b/src/components/device/ha-device-picker.ts index 29fcc20115..c374c5a686 100644 --- a/src/components/device/ha-device-picker.ts +++ b/src/components/device/ha-device-picker.ts @@ -21,6 +21,7 @@ import { fireEvent } from "../../common/dom/fire_event"; import { DeviceRegistryEntry, subscribeDeviceRegistry, + computeDeviceName, } from "../../data/device_registry"; import { compare } from "../../common/string/compare"; import { PolymerChangedEvent } from "../../polymer-types"; @@ -33,7 +34,6 @@ import { EntityRegistryEntry, subscribeEntityRegistry, } from "../../data/entity_registry"; -import { computeStateName } from "../../common/entity/compute_state_name"; interface Device { name: string; @@ -102,11 +102,11 @@ class HaDevicePicker extends SubscribeMixin(LitElement) { const outputDevices = devices.map((device) => { return { id: device.id, - name: - device.name_by_user || - device.name || - this._fallbackDeviceName(device.id, deviceEntityLookup) || - "No name", + name: computeDeviceName( + device, + this.hass, + deviceEntityLookup[device.id] + ), area: device.area_id ? areaLookup[device.area_id].name : "No area", }; }); @@ -209,20 +209,6 @@ class HaDevicePicker extends SubscribeMixin(LitElement) { } } - private _fallbackDeviceName( - deviceId: string, - deviceEntityLookup: DeviceEntityLookup - ): string | undefined { - for (const entity of deviceEntityLookup[deviceId] || []) { - const stateObj = this.hass.states[entity.entity_id]; - if (stateObj) { - return computeStateName(stateObj); - } - } - - return undefined; - } - static get styles(): CSSResult { return css` paper-input > paper-icon-button { diff --git a/src/components/entity/ha-entities-picker.ts b/src/components/entity/ha-entities-picker.ts index 0ac70b9223..48cf365207 100644 --- a/src/components/entity/ha-entities-picker.ts +++ b/src/components/entity/ha-entities-picker.ts @@ -22,7 +22,20 @@ import { HassEntity } from "home-assistant-js-websocket"; class HaEntitiesPickerLight extends LitElement { @property() public hass?: HomeAssistant; @property() public value?: string[]; - @property({ attribute: "domain-filter" }) public domainFilter?: string; + /** + * Show entities from specific domains. + * @type {string} + * @attr include-domains + */ + @property({ type: Array, attribute: "include-domains" }) + public includeDomains?: string[]; + /** + * Show no entities of these domains. + * @type {Array} + * @attr exclude-domains + */ + @property({ type: Array, attribute: "exclude-domains" }) + public excludeDomains?: string[]; @property({ attribute: "picked-entity-label" }) public pickedEntityLabel?: string; @property({ attribute: "pick-entity-label" }) public pickEntityLabel?: string; @@ -31,6 +44,7 @@ class HaEntitiesPickerLight extends LitElement { if (!this.hass) { return; } + const currentEntities = this._currentEntities; return html` ${currentEntities.map( @@ -40,7 +54,8 @@ class HaEntitiesPickerLight extends LitElement { allow-custom-entity .curValue=${entityId} .hass=${this.hass} - .domainFilter=${this.domainFilter} + .includeDomains=${this.includeDomains} + .excludeDomains=${this.excludeDomains} .entityFilter=${this._entityFilter} .value=${entityId} .label=${this.pickedEntityLabel} @@ -52,7 +67,8 @@ class HaEntitiesPickerLight extends LitElement {
{ let states: HassEntity[] = []; @@ -78,9 +92,15 @@ class HaEntityPicker extends LitElement { } let entityIds = Object.keys(hass.states); - if (domainFilter) { + if (includeDomains) { + entityIds = entityIds.filter((eid) => + includeDomains.includes(eid.substr(0, eid.indexOf("."))) + ); + } + + if (excludeDomains) { entityIds = entityIds.filter( - (eid) => eid.substr(0, eid.indexOf(".")) === domainFilter + (eid) => !excludeDomains.includes(eid.substr(0, eid.indexOf("."))) ); } @@ -108,7 +128,8 @@ class HaEntityPicker extends LitElement { protected render(): TemplateResult | void { const states = this._getStates( this._hass, - this.domainFilter, + this.includeDomains, + this.excludeDomains, this.entityFilter ); diff --git a/src/data/device_registry.ts b/src/data/device_registry.ts index 873d6f9c9c..cdd8052b1a 100644 --- a/src/data/device_registry.ts +++ b/src/data/device_registry.ts @@ -1,6 +1,8 @@ import { HomeAssistant } from "../types"; import { createCollection, Connection } from "home-assistant-js-websocket"; import { debounce } from "../common/util/debounce"; +import { EntityRegistryEntry } from "./entity_registry"; +import { computeStateName } from "../common/entity/compute_state_name"; export interface DeviceRegistryEntry { id: string; @@ -20,6 +22,33 @@ export interface DeviceRegistryEntryMutableParams { name_by_user?: string | null; } +export const computeDeviceName = ( + device: DeviceRegistryEntry, + hass: HomeAssistant, + entities?: EntityRegistryEntry[] | string[] +) => { + return ( + device.name_by_user || + device.name || + (entities && fallbackDeviceName(hass, entities)) || + hass.localize("ui.panel.config.devices.unnamed_device") + ); +}; + +export const fallbackDeviceName = ( + hass: HomeAssistant, + entities: EntityRegistryEntry[] | string[] +) => { + for (const entity of entities || []) { + const entityId = typeof entity === "string" ? entity : entity.entity_id; + const stateObj = hass.states[entityId]; + if (stateObj) { + return computeStateName(stateObj); + } + } + return undefined; +}; + export const updateDeviceRegistryEntry = ( hass: HomeAssistant, deviceId: string, diff --git a/src/data/scene.ts b/src/data/scene.ts new file mode 100644 index 0000000000..26ac840144 --- /dev/null +++ b/src/data/scene.ts @@ -0,0 +1,91 @@ +import { + HassEntityBase, + HassEntityAttributeBase, +} from "home-assistant-js-websocket"; + +import { HomeAssistant, ServiceCallResponse } from "../types"; + +export const SCENE_IGNORED_DOMAINS = [ + "sensor", + "binary_sensor", + "device_tracker", + "person", + "persistent_notification", + "configuration", + "image_processing", + "sun", + "weather", + "zone", +]; + +export const SCENE_SAVED_ATTRIBUTES = { + light: [ + "brightness", + "color_temp", + "effect", + "rgb_color", + "xy_color", + "hs_color", + ], + media_player: [ + "is_volume_muted", + "volume_level", + "sound_mode", + "source", + "media_content_id", + "media_content_type", + ], + climate: [ + "target_temperature", + "target_temperature_high", + "target_temperature_low", + "target_humidity", + "fan_mode", + "swing_mode", + "hvac_mode", + "preset_mode", + ], + vacuum: ["cleaning_mode"], + fan: ["speed", "current_direction"], + water_heather: ["temperature", "operation_mode"], +}; + +export interface SceneEntity extends HassEntityBase { + attributes: HassEntityAttributeBase & { id?: string }; +} + +export interface SceneConfig { + name: string; + entities: SceneEntities; +} + +export interface SceneEntities { + [entityId: string]: string | { state: string; [key: string]: any }; +} + +export const activateScene = ( + hass: HomeAssistant, + entityId: string +): Promise => + hass.callService("scene", "turn_on", { entity_id: entityId }); + +export const applyScene = ( + hass: HomeAssistant, + entities: SceneEntities +): Promise => + hass.callService("scene", "apply", { entities }); + +export const getSceneConfig = ( + hass: HomeAssistant, + sceneId: string +): Promise => + hass.callApi("GET", `config/scene/config/${sceneId}`); + +export const saveScene = ( + hass: HomeAssistant, + sceneId: string, + config: SceneConfig +) => hass.callApi("POST", `config/scene/config/${sceneId}`, config); + +export const deleteScene = (hass: HomeAssistant, id: string) => + hass.callApi("DELETE", `config/scene/config/${id}`); diff --git a/src/dialogs/confirmation/dialog-confirmation.ts b/src/dialogs/confirmation/dialog-confirmation.ts index 23bc7d312e..2ef6003b54 100644 --- a/src/dialogs/confirmation/dialog-confirmation.ts +++ b/src/dialogs/confirmation/dialog-confirmation.ts @@ -36,6 +36,7 @@ class DialogConfirmation extends LitElement {

@@ -48,10 +49,14 @@ class DialogConfirmation extends LitElement {
- ${this.hass.localize("ui.dialogs.confirmation.cancel")} + ${this._params.cancelBtnText + ? this._params.cancelBtnText + : this.hass.localize("ui.dialogs.confirmation.cancel")} - ${this.hass.localize("ui.dialogs.confirmation.ok")} + ${this._params.confirmBtnText + ? this._params.confirmBtnText + : this.hass.localize("ui.dialogs.confirmation.ok")}
diff --git a/src/dialogs/confirmation/show-dialog-confirmation.ts b/src/dialogs/confirmation/show-dialog-confirmation.ts index 47b3e556b8..df57cf5558 100644 --- a/src/dialogs/confirmation/show-dialog-confirmation.ts +++ b/src/dialogs/confirmation/show-dialog-confirmation.ts @@ -3,6 +3,8 @@ import { fireEvent } from "../../common/dom/fire_event"; export interface ConfirmationDialogParams { title?: string; text: string; + confirmBtnText?: string; + cancelBtnText?: string; confirm: () => void; } diff --git a/src/dialogs/device-registry-detail/dialog-device-registry-detail.ts b/src/dialogs/device-registry-detail/dialog-device-registry-detail.ts index fc51180de8..5043adf429 100644 --- a/src/dialogs/device-registry-detail/dialog-device-registry-detail.ts +++ b/src/dialogs/device-registry-detail/dialog-device-registry-detail.ts @@ -24,6 +24,7 @@ import { subscribeAreaRegistry, AreaRegistryEntry, } from "../../data/area_registry"; +import { computeDeviceName } from "../../data/device_registry"; @customElement("dialog-device-registry-detail") class DialogDeviceRegistryDetail extends LitElement { @@ -75,8 +76,7 @@ class DialogDeviceRegistryDetail extends LitElement { @opened-changed="${this._openedChanged}" >

- ${device.name || - this.hass.localize("ui.panel.config.devices.unnamed_device")} + ${computeDeviceName(device, this.hass)}

${this._error diff --git a/src/fake_data/provide_hass.ts b/src/fake_data/provide_hass.ts index f3101d5125..6870415ce1 100644 --- a/src/fake_data/provide_hass.ts +++ b/src/fake_data/provide_hass.ts @@ -180,6 +180,7 @@ export const provideHass = ( dockedSidebar: "auto", vibrate: true, moreInfoEntityId: null as any, + // @ts-ignore async callService(domain, service, data) { if (data && "entity_id" in data) { await Promise.all( diff --git a/src/mixins/subscribe-mixin.ts b/src/mixins/subscribe-mixin.ts index 840be26d08..034235fa30 100644 --- a/src/mixins/subscribe-mixin.ts +++ b/src/mixins/subscribe-mixin.ts @@ -1,4 +1,4 @@ -import { LitElement, PropertyValues, property } from "lit-element"; +import { PropertyValues, property, UpdatingElement } from "lit-element"; import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { HomeAssistant, Constructor } from "../types"; @@ -7,7 +7,7 @@ export interface HassSubscribeElement { } /* tslint:disable-next-line:variable-name */ -export const SubscribeMixin = >( +export const SubscribeMixin = >( superClass: T ) => { class SubscribeClass extends superClass { diff --git a/src/panels/config/dashboard/ha-config-navigation.ts b/src/panels/config/dashboard/ha-config-navigation.ts index ed64269965..72abd3ad17 100644 --- a/src/panels/config/dashboard/ha-config-navigation.ts +++ b/src/panels/config/dashboard/ha-config-navigation.ts @@ -29,6 +29,7 @@ const PAGES: Array<{ { page: "area_registry", core: true }, { page: "automation" }, { page: "script" }, + { page: "scene" }, { page: "zha" }, { page: "zwave" }, { page: "customize", core: true, advanced: true }, diff --git a/src/panels/config/devices/device-detail/ha-device-card.ts b/src/panels/config/devices/device-detail/ha-device-card.ts index 86e3109793..a9b5ad7b89 100644 --- a/src/panels/config/devices/device-detail/ha-device-card.ts +++ b/src/panels/config/devices/device-detail/ha-device-card.ts @@ -1,6 +1,9 @@ import "../../../../components/ha-card"; -import { DeviceRegistryEntry } from "../../../../data/device_registry"; +import { + DeviceRegistryEntry, + computeDeviceName, +} from "../../../../data/device_registry"; import { loadDeviceRegistryDetailDialog } from "../../../../dialogs/device-registry-detail/show-dialog-device-registry-detail"; import { LitElement, @@ -93,14 +96,10 @@ export class HaDeviceCard extends LitElement { return areas.find((area) => area.area_id === device.area_id).name; } - private _deviceName(device) { - return device.name_by_user || device.name; - } - private _computeDeviceName(devices, deviceId) { const device = devices.find((dev) => dev.id === deviceId); return device - ? this._deviceName(device) + ? computeDeviceName(device, this.hass) : `(${this.hass.localize( "ui.panel.config.integrations.config_entry.device_unavailable" )})`; diff --git a/src/panels/config/devices/ha-devices-data-table.ts b/src/panels/config/devices/ha-devices-data-table.ts index 20969fc859..66993e8392 100644 --- a/src/panels/config/devices/ha-devices-data-table.ts +++ b/src/panels/config/devices/ha-devices-data-table.ts @@ -18,13 +18,15 @@ import { DataTableRowData, } from "../../../components/data-table/ha-data-table"; // tslint:disable-next-line -import { DeviceRegistryEntry } from "../../../data/device_registry"; +import { + DeviceRegistryEntry, + computeDeviceName, +} from "../../../data/device_registry"; import { EntityRegistryEntry } from "../../../data/entity_registry"; import { ConfigEntry } from "../../../data/config_entries"; import { AreaRegistryEntry } from "../../../data/area_registry"; import { navigate } from "../../../common/navigate"; import { LocalizeFunc } from "../../../common/translations/localize"; -import { computeStateName } from "../../../common/entity/compute_state_name"; export interface DeviceRowData extends DeviceRegistryEntry { device?: DeviceRowData; @@ -99,11 +101,11 @@ export class HaDevicesDataTable extends LitElement { outputDevices = outputDevices.map((device) => { return { ...device, - name: - device.name_by_user || - device.name || - this._fallbackDeviceName(device.id, deviceEntityLookup) || - "No name", + name: computeDeviceName( + device, + this.hass, + deviceEntityLookup[device.id] + ), model: device.model || "", manufacturer: device.manufacturer || "", area: device.area_id ? areaLookup[device.area_id].name : "No area", @@ -250,20 +252,6 @@ export class HaDevicesDataTable extends LitElement { return batteryEntity ? batteryEntity.entity_id : undefined; } - private _fallbackDeviceName( - deviceId: string, - deviceEntityLookup: DeviceEntityLookup - ): string | undefined { - for (const entity of deviceEntityLookup[deviceId] || []) { - const stateObj = this.hass.states[entity.entity_id]; - if (stateObj) { - return computeStateName(stateObj); - } - } - - return undefined; - } - private _handleRowClicked(ev: CustomEvent) { const deviceId = (ev.detail as RowClickedEvent).id; navigate(this, `/config/devices/device/${deviceId}`); diff --git a/src/panels/config/ha-panel-config.ts b/src/panels/config/ha-panel-config.ts index ea57036028..ed2bd9b1bb 100644 --- a/src/panels/config/ha-panel-config.ts +++ b/src/panels/config/ha-panel-config.ts @@ -88,6 +88,11 @@ class HaPanelConfig extends HassRouterPage { load: () => import(/* webpackChunkName: "panel-config-script" */ "./script/ha-config-script"), }, + scene: { + tag: "ha-config-scene", + load: () => + import(/* webpackChunkName: "panel-config-scene" */ "./scene/ha-config-scene"), + }, users: { tag: "ha-config-users", load: () => diff --git a/src/panels/config/js/condition/zone.tsx b/src/panels/config/js/condition/zone.tsx index 5d8ba95431..924284dd98 100644 --- a/src/panels/config/js/condition/zone.tsx +++ b/src/panels/config/js/condition/zone.tsx @@ -52,7 +52,7 @@ export default class ZoneCondition extends Component { onChange={this.zonePicked} hass={hass} allowCustomEntity - domainFilter="zone" + includeDomains={["zone"]} />
); diff --git a/src/panels/config/js/script/scene.tsx b/src/panels/config/js/script/scene.tsx index a4fbb9a11f..001b360445 100644 --- a/src/panels/config/js/script/scene.tsx +++ b/src/panels/config/js/script/scene.tsx @@ -24,7 +24,7 @@ export default class SceneAction extends Component { value={scene} onChange={this.sceneChanged} hass={hass} - domainFilter="scene" + includeDomains={["scene"]} allowCustomEntity /> diff --git a/src/panels/config/js/trigger/geo_location.tsx b/src/panels/config/js/trigger/geo_location.tsx index bc20980f84..52deb4a6b2 100644 --- a/src/panels/config/js/trigger/geo_location.tsx +++ b/src/panels/config/js/trigger/geo_location.tsx @@ -51,7 +51,7 @@ export default class GeolocationTrigger extends Component { onChange={this.zonePicked} hass={hass} allowCustomEntity - domainFilter="zone" + includeDomains={["zone"]} />