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
This commit is contained in:
Bram Kragten 2019-11-04 21:07:09 +01:00 committed by Paulus Schoutsen
parent 2a617a9639
commit da35c263d2
40 changed files with 1327 additions and 95 deletions

View File

@ -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 {

View File

@ -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 {
<div>
<ha-entity-picker
.hass=${this.hass}
.domainFilter=${this.domainFilter}
.includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains}
.entityFilter=${this._entityFilter}
.label=${this.pickEntityLabel}
@value-changed=${this._addEntity}

View File

@ -60,7 +60,20 @@ class HaEntityPicker extends LitElement {
@property() public hass?: HomeAssistant;
@property() public label?: string;
@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() public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ type: Boolean }) private _opened?: boolean;
@property() private _hass?: HomeAssistant;
@ -68,7 +81,8 @@ class HaEntityPicker extends LitElement {
private _getStates = memoizeOne(
(
hass: this["hass"],
domainFilter: this["domainFilter"],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
entityFilter: this["entityFilter"]
) => {
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
);

View File

@ -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,

91
src/data/scene.ts Normal file
View File

@ -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<ServiceCallResponse> =>
hass.callService("scene", "turn_on", { entity_id: entityId });
export const applyScene = (
hass: HomeAssistant,
entities: SceneEntities
): Promise<ServiceCallResponse> =>
hass.callService("scene", "apply", { entities });
export const getSceneConfig = (
hass: HomeAssistant,
sceneId: string
): Promise<SceneConfig> =>
hass.callApi<SceneConfig>("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}`);

View File

@ -36,6 +36,7 @@ class DialogConfirmation extends LitElement {
<ha-paper-dialog
with-backdrop
opened
modal
@opened-changed="${this._openedChanged}"
>
<h2>
@ -48,10 +49,14 @@ class DialogConfirmation extends LitElement {
</paper-dialog-scrollable>
<div class="paper-dialog-buttons">
<mwc-button @click="${this._dismiss}">
${this.hass.localize("ui.dialogs.confirmation.cancel")}
${this._params.cancelBtnText
? this._params.cancelBtnText
: this.hass.localize("ui.dialogs.confirmation.cancel")}
</mwc-button>
<mwc-button @click="${this._confirm}">
${this.hass.localize("ui.dialogs.confirmation.ok")}
${this._params.confirmBtnText
? this._params.confirmBtnText
: this.hass.localize("ui.dialogs.confirmation.ok")}
</mwc-button>
</div>
</ha-paper-dialog>

View File

@ -3,6 +3,8 @@ import { fireEvent } from "../../common/dom/fire_event";
export interface ConfirmationDialogParams {
title?: string;
text: string;
confirmBtnText?: string;
cancelBtnText?: string;
confirm: () => void;
}

View File

@ -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}"
>
<h2>
${device.name ||
this.hass.localize("ui.panel.config.devices.unnamed_device")}
${computeDeviceName(device, this.hass)}
</h2>
<paper-dialog-scrollable>
${this._error

View File

@ -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(

View File

@ -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 = <T extends Constructor<LitElement>>(
export const SubscribeMixin = <T extends Constructor<UpdatingElement>>(
superClass: T
) => {
class SubscribeClass extends superClass {

View File

@ -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 },

View File

@ -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"
)})`;

View File

@ -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 || "<unknown>",
manufacturer: device.manufacturer || "<unknown>",
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}`);

View File

@ -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: () =>

View File

@ -52,7 +52,7 @@ export default class ZoneCondition extends Component<any> {
onChange={this.zonePicked}
hass={hass}
allowCustomEntity
domainFilter="zone"
includeDomains={["zone"]}
/>
</div>
);

View File

@ -24,7 +24,7 @@ export default class SceneAction extends Component<any> {
value={scene}
onChange={this.sceneChanged}
hass={hass}
domainFilter="scene"
includeDomains={["scene"]}
allowCustomEntity
/>
</div>

View File

@ -51,7 +51,7 @@ export default class GeolocationTrigger extends Component<any> {
onChange={this.zonePicked}
hass={hass}
allowCustomEntity
domainFilter="zone"
includeDomains={["zone"]}
/>
<label id="eventlabel">
{localize(

View File

@ -42,7 +42,7 @@ export default class ZoneTrigger extends Component<any> {
onChange={this.zonePicked}
hass={hass}
allowCustomEntity
domainFilter="zone"
includeDomains={["zone"]}
/>
<label id="eventlabel">
{localize(

View File

@ -104,7 +104,7 @@ class DialogPersonDetail extends LitElement {
<ha-entities-picker
.hass=${this.hass}
.value=${this._deviceTrackers}
domain-filter="device_tracker"
include-domains='["device_tracker"]'
.pickedEntityLabel=${this.hass.localize(
"ui.panel.config.person.detail.device_tracker_picked"
)}

View File

@ -0,0 +1,81 @@
import "@polymer/app-route/app-route";
import "./ha-scene-editor";
import "./ha-scene-dashboard";
import {
HassRouterPage,
RouterOptions,
} from "../../../layouts/hass-router-page";
import { property, customElement, PropertyValues } from "lit-element";
import { HomeAssistant } from "../../../types";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { compare } from "../../../common/string/compare";
import { SceneEntity } from "../../../data/scene";
import memoizeOne from "memoize-one";
import { HassEntities } from "home-assistant-js-websocket";
@customElement("ha-config-scene")
class HaConfigScene extends HassRouterPage {
@property() public hass!: HomeAssistant;
@property() public narrow!: boolean;
@property() public showAdvanced!: boolean;
@property() public scenes: SceneEntity[] = [];
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
routes: {
dashboard: {
tag: "ha-scene-dashboard",
cache: true,
},
edit: {
tag: "ha-scene-editor",
},
},
};
private _computeScenes = memoizeOne((states: HassEntities) => {
const scenes: SceneEntity[] = [];
Object.keys(states).forEach((entityId) => {
if (computeDomain(entityId) === "scene") {
scenes.push(states[entityId] as SceneEntity);
}
});
return scenes.sort((a, b) => {
return compare(computeStateName(a), computeStateName(b));
});
});
protected updatePageEl(pageEl, changedProps: PropertyValues) {
pageEl.hass = this.hass;
pageEl.narrow = this.narrow;
pageEl.showAdvanced = this.showAdvanced;
if (this.hass) {
pageEl.scenes = this._computeScenes(this.hass.states);
}
if (
(!changedProps || changedProps.has("route")) &&
this._currentPage === "edit"
) {
const sceneId = this.routeTail.path.substr(1);
pageEl.creatingNew = sceneId === "new" ? true : false;
pageEl.scene =
sceneId === "new"
? undefined
: pageEl.scenes.find(
(entity: SceneEntity) => entity.attributes.id === sceneId
);
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-scene": HaConfigScene;
}
}

View File

@ -0,0 +1,213 @@
import {
LitElement,
TemplateResult,
html,
CSSResultArray,
css,
property,
customElement,
} from "lit-element";
import "@polymer/paper-icon-button/paper-icon-button";
import "@polymer/paper-item/paper-item-body";
import "@polymer/paper-tooltip/paper-tooltip";
import "../../../layouts/hass-subpage";
import "../../../components/ha-card";
import "../../../components/ha-fab";
import "../ha-config-section";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { computeRTL } from "../../../common/util/compute_rtl";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { SceneEntity, activateScene } from "../../../data/scene";
import { showToast } from "../../../util/toast";
import { ifDefined } from "lit-html/directives/if-defined";
import { forwardHaptic } from "../../../data/haptics";
@customElement("ha-scene-dashboard")
class HaSceneDashboard extends LitElement {
@property() public hass!: HomeAssistant;
@property() public narrow!: boolean;
@property() public scenes!: SceneEntity[];
protected render(): TemplateResult | void {
return html`
<hass-subpage
.header=${this.hass.localize("ui.panel.config.scene.caption")}
>
<ha-config-section .isWide=${!this.narrow}>
<div slot="header">
${this.hass.localize("ui.panel.config.scene.picker.header")}
</div>
<div slot="introduction">
${this.hass.localize("ui.panel.config.scene.picker.introduction")}
<p>
<a
href="https://home-assistant.io/docs/scene/editor/"
target="_blank"
>
${this.hass.localize("ui.panel.config.scene.picker.learn_more")}
</a>
</p>
</div>
<ha-card
.heading=${this.hass.localize(
"ui.panel.config.scene.picker.pick_scene"
)}
>
${this.scenes.length === 0
? html`
<div class="card-content">
<p>
${this.hass.localize(
"ui.panel.config.scene.picker.no_scenes"
)}
</p>
</div>
`
: this.scenes.map(
(scene) => html`
<div class='scene'>
<paper-icon-button
.scene=${scene}
icon="hass:play"
title="${this.hass.localize(
"ui.panel.config.scene.picker.activate_scene"
)}"
@click=${this._activateScene}
></paper-icon-button>
<paper-item-body two-line>
<div>${computeStateName(scene)}</div>
</paper-item-body>
<a
href=${ifDefined(
scene.attributes.id
? `/config/scene/edit/${scene.attributes.id}`
: undefined
)}
>
<paper-icon-button
title="${this.hass.localize(
"ui.panel.config.scene.picker.edit_scene"
)}"
icon="hass:pencil"
.disabled=${!scene.attributes.id}
></paper-icon-button>
${
!scene.attributes.id
? html`
<paper-tooltip position="left">
${this.hass.localize(
"ui.panel.config.scene.picker.only_editable"
)}
</paper-tooltip>
`
: ""
}
</a>
</div>
</a>
`
)}
</ha-card>
</ha-config-section>
<a href="/config/scene/edit/new">
<ha-fab
slot="fab"
?is-wide=${!this.narrow}
icon="hass:plus"
title=${this.hass.localize(
"ui.panel.config.scene.picker.add_scene"
)}
?rtl=${computeRTL(this.hass)}
></ha-fab>
</a>
</hass-subpage>
`;
}
private async _activateScene(ev) {
const scene = ev.target.scene as SceneEntity;
await activateScene(this.hass, scene.entity_id);
showToast(this, {
message: this.hass.localize(
"ui.panel.config.scene.activated",
"name",
computeStateName(scene)
),
});
forwardHaptic("light");
}
static get styles(): CSSResultArray {
return [
haStyle,
css`
:host {
display: block;
}
hass-subpage {
min-height: 100vh;
}
ha-card {
margin-bottom: 56px;
}
.scene {
display: flex;
flex-direction: horizontal;
align-items: center;
padding: 0 8px 0 16px;
}
.scene a[href] {
color: var(--primary-text-color);
}
ha-entity-toggle {
margin-right: 16px;
}
ha-fab {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 1;
}
ha-fab[is-wide] {
bottom: 24px;
right: 24px;
}
ha-fab[rtl] {
right: auto;
left: 16px;
}
ha-fab[rtl][is-wide] {
bottom: 24px;
right: auto;
left: 24px;
}
a {
color: var(--primary-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-scene-dashboard": HaSceneDashboard;
}
}

View File

@ -0,0 +1,738 @@
import {
LitElement,
TemplateResult,
html,
CSSResult,
css,
PropertyValues,
property,
customElement,
} from "lit-element";
import "@polymer/app-layout/app-header/app-header";
import "@polymer/app-layout/app-toolbar/app-toolbar";
import "@polymer/paper-icon-button/paper-icon-button";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-icon-item";
import "@polymer/paper-item/paper-item-body";
import { classMap } from "lit-html/directives/class-map";
import "../../../components/ha-fab";
import "../../../components/device/ha-device-picker";
import "../../../components/entity/ha-entities-picker";
import "../../../components/ha-paper-icon-button-arrow-prev";
import "../../../layouts/ha-app-layout";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { navigate } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
import {
SceneEntity,
SceneConfig,
getSceneConfig,
deleteScene,
saveScene,
SCENE_IGNORED_DOMAINS,
SceneEntities,
SCENE_SAVED_ATTRIBUTES,
applyScene,
activateScene,
} from "../../../data/scene";
import { fireEvent } from "../../../common/dom/fire_event";
import {
DeviceRegistryEntry,
subscribeDeviceRegistry,
computeDeviceName,
} from "../../../data/device_registry";
import {
EntityRegistryEntry,
subscribeEntityRegistry,
} from "../../../data/entity_registry";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import memoizeOne from "memoize-one";
import { computeDomain } from "../../../common/entity/compute_domain";
import { HassEvent } from "home-assistant-js-websocket";
import { showConfirmationDialog } from "../../../dialogs/confirmation/show-dialog-confirmation";
interface DeviceEntities {
id: string;
name: string;
entities: string[];
}
interface DeviceEntitiesLookup {
[deviceId: string]: string[];
}
@customElement("ha-scene-editor")
export class HaSceneEditor extends SubscribeMixin(LitElement) {
@property() public hass!: HomeAssistant;
@property() public narrow?: boolean;
@property() public scene?: SceneEntity;
@property() public creatingNew?: boolean;
@property() public showAdvanced!: boolean;
@property() private _dirty?: boolean;
@property() private _errors?: string;
@property() private _config!: SceneConfig;
@property() private _entities: string[] = [];
@property() private _devices: string[] = [];
@property() private _deviceRegistryEntries: DeviceRegistryEntry[] = [];
@property() private _entityRegistryEntries: EntityRegistryEntry[] = [];
private _storedStates: SceneEntities = {};
private _unsubscribeEvents?: () => void;
private _deviceEntityLookup: DeviceEntitiesLookup = {};
private _activateContextId?: string;
private _getEntitiesDevices = memoizeOne(
(
entities: string[],
devices: string[],
deviceEntityLookup: DeviceEntitiesLookup,
deviceRegs: DeviceRegistryEntry[]
) => {
const outputDevices: DeviceEntities[] = [];
if (devices.length) {
const deviceLookup: { [deviceId: string]: DeviceRegistryEntry } = {};
for (const device of deviceRegs) {
deviceLookup[device.id] = device;
}
devices.forEach((deviceId) => {
const device = deviceLookup[deviceId];
const deviceEntities: string[] = deviceEntityLookup[deviceId] || [];
outputDevices.push({
name: computeDeviceName(
device,
this.hass,
this._deviceEntityLookup[device.id]
),
id: device.id,
entities: deviceEntities,
});
});
}
const outputEntities: string[] = [];
entities.forEach((entity) => {
if (!outputDevices.find((device) => device.entities.includes(entity))) {
outputEntities.push(entity);
}
});
return { devices: outputDevices, entities: outputEntities };
}
);
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsubscribeEvents) {
this._unsubscribeEvents();
this._unsubscribeEvents = undefined;
}
}
public hassSubscribe() {
return [
subscribeEntityRegistry(this.hass.connection, (entries) => {
this._entityRegistryEntries = entries;
}),
subscribeDeviceRegistry(this.hass.connection, (entries) => {
this._deviceRegistryEntries = entries;
}),
];
}
protected render(): TemplateResult | void {
if (!this.hass) {
return;
}
const { devices, entities } = this._getEntitiesDevices(
this._entities,
this._devices,
this._deviceEntityLookup,
this._deviceRegistryEntries
);
return html`
<ha-app-layout has-scrolling-region>
<app-header slot="header" fixed>
<app-toolbar>
<ha-paper-icon-button-arrow-prev
@click=${this._backTapped}
></ha-paper-icon-button-arrow-prev>
<div main-title>
${this.scene
? computeStateName(this.scene)
: this.hass.localize(
"ui.panel.config.scene.editor.default_name"
)}
</div>
${this.creatingNew
? ""
: html`
<paper-icon-button
title="${this.hass.localize(
"ui.panel.config.scene.picker.delete_scene"
)}"
icon="hass:delete"
@click=${this._deleteTapped}
></paper-icon-button>
`}
</app-toolbar>
</app-header>
<div class="content">
${this._errors
? html`
<div class="errors">${this._errors}</div>
`
: ""}
<div
id="root"
class="${classMap({
rtl: computeRTL(this.hass),
})}"
>
<ha-config-section .isWide=${!this.narrow}>
<div slot="header">
${this.scene
? computeStateName(this.scene)
: this.hass.localize(
"ui.panel.config.scene.editor.default_name"
)}
</div>
<div slot="introduction">
${this.hass.localize(
"ui.panel.config.scene.editor.introduction"
)}
</div>
<ha-card>
<div class="card-content">
<paper-input
.value=${this.scene ? computeStateName(this.scene) : ""}
@value-changed=${this._nameChanged}
label=${this.hass.localize(
"ui.panel.config.scene.editor.name"
)}
></paper-input>
</div>
</ha-card>
</ha-config-section>
<ha-config-section .isWide=${!this.narrow}>
<div slot="header">
${this.hass.localize(
"ui.panel.config.scene.editor.devices.header"
)}
</div>
<div slot="introduction">
${this.hass.localize(
"ui.panel.config.scene.editor.devices.introduction"
)}
</div>
${devices.map(
(device) =>
html`
<ha-card>
<div class="card-header">
${device.name}
<paper-icon-button
icon="hass:delete"
title="${this.hass.localize(
"ui.panel.config.scene.editor.devices.delete"
)}"
.device=${device.id}
@click=${this._deleteDevice}
></paper-icon-button>
</div>
${device.entities.map((entity) => {
const stateObj = this.hass.states[entity];
if (!stateObj) {
return html``;
}
return html`
<paper-icon-item
.entity=${stateObj.entity_id}
@click=${this._showMoreInfo}
class="device-entity"
>
<state-badge
.stateObj=${stateObj}
slot="item-icon"
></state-badge>
<paper-item-body>
${computeStateName(stateObj)}
</paper-item-body>
</paper-icon-item>
`;
})}
</ha-card>
`
)}
<ha-card
.header=${this.hass.localize(
"ui.panel.config.scene.editor.devices.add"
)}
>
<div class="card-content">
<ha-device-picker
@value-changed=${this._devicePicked}
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.scene.editor.devices.add"
)}
/>
</div>
</ha-card>
</ha-config-section>
${this.showAdvanced
? html`
<ha-config-section .isWide=${!this.narrow}>
<div slot="header">
${this.hass.localize(
"ui.panel.config.scene.editor.entities.header"
)}
</div>
<div slot="introduction">
${this.hass.localize(
"ui.panel.config.scene.editor.entities.introduction"
)}
</div>
${entities.length
? html`
<ha-card
class="entities"
.header=${this.hass.localize(
"ui.panel.config.scene.editor.entities.without_device"
)}
>
${entities.map((entity) => {
const stateObj = this.hass.states[entity];
if (!stateObj) {
return html``;
}
return html`
<paper-icon-item
.entity=${stateObj.entity_id}
@click=${this._showMoreInfo}
class="device-entity"
>
<state-badge
.stateObj=${stateObj}
slot="item-icon"
></state-badge>
<paper-item-body>
${computeStateName(stateObj)}
</paper-item-body>
<paper-icon-button
icon="hass:delete"
.entity=${entity}
.title="${this.hass.localize(
"ui.panel.config.scene.editor.entities.delete"
)}"
@click=${this._deleteEntity}
></paper-icon-button>
</paper-icon-item>
`;
})}
</ha-card>
`
: ""}
<ha-card
header=${this.hass.localize(
"ui.panel.config.scene.editor.entities.add"
)}
>
<div class="card-content">
${this.hass.localize(
"ui.panel.config.scene.editor.entities.device_entities"
)}
<ha-entity-picker
@value-changed=${this._entityPicked}
.excludeDomains=${SCENE_IGNORED_DOMAINS}
.hass=${this.hass}
label=${this.hass.localize(
"ui.panel.config.scene.editor.entities.add"
)}
/>
</div>
</ha-card>
</ha-config-section>
`
: ""}
</div>
</div>
<ha-fab
slot="fab"
?is-wide="${!this.narrow}"
?dirty="${this._dirty}"
icon="hass:content-save"
.title="${this.hass.localize("ui.panel.config.scene.editor.save")}"
@click=${this._saveScene}
class="${classMap({
rtl: computeRTL(this.hass),
})}"
></ha-fab>
</ha-app-layout>
`;
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
const oldscene = changedProps.get("scene") as SceneEntity;
if (
changedProps.has("scene") &&
this.scene &&
this.hass &&
// Only refresh config if we picked a new scene. If same ID, don't fetch it.
(!oldscene || oldscene.attributes.id !== this.scene.attributes.id)
) {
this._loadConfig();
}
if (changedProps.has("creatingNew") && this.creatingNew && this.hass) {
this._dirty = false;
this._config = {
name: this.hass.localize("ui.panel.config.scene.editor.default_name"),
entities: {},
};
}
if (changedProps.has("_entityRegistryEntries")) {
for (const entity of this._entityRegistryEntries) {
if (
!entity.device_id ||
SCENE_IGNORED_DOMAINS.includes(computeDomain(entity.entity_id))
) {
continue;
}
if (!(entity.device_id in this._deviceEntityLookup)) {
this._deviceEntityLookup[entity.device_id] = [];
}
if (
!this._deviceEntityLookup[entity.device_id].includes(entity.entity_id)
) {
this._deviceEntityLookup[entity.device_id].push(entity.entity_id);
}
}
}
}
private _showMoreInfo(ev: Event) {
const entityId = (ev.currentTarget as any).entity;
fireEvent(this, "hass-more-info", { entityId });
}
private async _loadConfig() {
let config: SceneConfig;
try {
config = await getSceneConfig(this.hass, this.scene!.attributes.id!);
} catch (err) {
alert(
err.status_code === 404
? this.hass.localize(
"ui.panel.config.scene.editor.load_error_not_editable"
)
: this.hass.localize(
"ui.panel.config.scene.editor.load_error_unknown",
"err_no",
err.status_code
)
);
history.back();
return;
}
if (!config.entities) {
config.entities = {};
}
this._entities = Object.keys(config.entities);
this._entities.forEach((entity) => {
this._storeState(entity);
});
const filteredEntityReg = this._entityRegistryEntries.filter((entityReg) =>
this._entities.includes(entityReg.entity_id)
);
for (const entityReg of filteredEntityReg) {
if (!entityReg.device_id) {
continue;
}
if (!this._devices.includes(entityReg.device_id)) {
this._devices = [...this._devices, entityReg.device_id];
}
}
const { context } = await activateScene(this.hass, this.scene!.entity_id);
this._activateContextId = context.id;
this._unsubscribeEvents = await this.hass!.connection.subscribeEvents<
HassEvent
>((event) => this._stateChanged(event), "state_changed");
this._dirty = false;
this._config = config;
}
private _entityPicked(ev: CustomEvent) {
const entityId = ev.detail.value;
(ev.target as any).value = "";
if (this._entities.includes(entityId)) {
return;
}
this._entities = [...this._entities, entityId];
this._storeState(entityId);
this._dirty = true;
}
private _deleteEntity(ev: Event) {
ev.stopPropagation();
const deleteEntityId = (ev.target as any).entityId;
this._entities = this._entities.filter(
(entityId) => entityId !== deleteEntityId
);
this._dirty = true;
}
private _devicePicked(ev: CustomEvent) {
const device = ev.detail.value;
(ev.target as any).value = "";
if (this._devices.includes(device)) {
return;
}
this._devices = [...this._devices, device];
const deviceEntities = this._deviceEntityLookup[device];
this._entities = [...this._entities, ...deviceEntities];
deviceEntities.forEach((entityId) => {
this._storeState(entityId);
});
this._dirty = true;
}
private _deleteDevice(ev: Event) {
const deviceId = (ev.target as any).device;
this._devices = this._devices.filter((device) => device !== deviceId);
const deviceEntities = this._deviceEntityLookup[deviceId];
this._entities = this._entities.filter(
(entityId) => !deviceEntities.includes(entityId)
);
this._dirty = true;
}
private _nameChanged(ev: CustomEvent) {
if (!this._config || this._config.name === ev.detail.value) {
return;
}
this._config.name = ev.detail.value;
this._dirty = true;
}
private _stateChanged(event: HassEvent) {
if (
event.context.id !== this._activateContextId &&
this._entities.includes(event.data.entity_id)
) {
this._dirty = true;
}
}
private _backTapped(): void {
if (this._dirty) {
showConfirmationDialog(this, {
text: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm"
),
confirmBtnText: this.hass!.localize("ui.common.yes"),
cancelBtnText: this.hass!.localize("ui.common.no"),
confirm: () => this._goBack(),
});
} else {
this._goBack();
}
}
private _goBack(): void {
applyScene(this.hass, this._storedStates);
history.back();
}
private _deleteTapped(): void {
showConfirmationDialog(this, {
text: this.hass!.localize("ui.panel.config.scene.picker.delete_confirm"),
confirmBtnText: this.hass!.localize("ui.common.yes"),
cancelBtnText: this.hass!.localize("ui.common.no"),
confirm: () => this._delete(),
});
}
private async _delete(): Promise<void> {
await deleteScene(this.hass, this.scene!.attributes.id!);
applyScene(this.hass, this._storedStates);
history.back();
}
private _calculateStates(): SceneEntities {
const output: SceneEntities = {};
this._entities.forEach((entityId) => {
const state = this._getCurrentState(entityId);
if (state) {
output[entityId] = state;
}
});
return output;
}
private _storeState(entityId: string): void {
if (entityId in this._storedStates) {
return;
}
const state = this._getCurrentState(entityId);
if (!state) {
return;
}
this._storedStates[entityId] = state;
}
private _getCurrentState(entityId: string) {
const stateObj = this.hass.states[entityId];
if (!stateObj) {
return;
}
const domain = computeDomain(entityId);
const attributes = {};
for (const attribute in stateObj.attributes) {
if (
SCENE_SAVED_ATTRIBUTES[domain] &&
SCENE_SAVED_ATTRIBUTES[domain].includes(attribute)
) {
attributes[attribute] = stateObj.attributes[attribute];
}
}
return { ...attributes, state: stateObj.state };
}
private async _saveScene(): Promise<void> {
const id = this.creatingNew ? "" + Date.now() : this.scene!.attributes.id!;
this._config = { ...this._config, entities: this._calculateStates() };
try {
await saveScene(this.hass, id, this._config);
this._dirty = false;
if (this.creatingNew) {
navigate(this, `/config/scene/edit/${id}`, true);
}
} catch (err) {
this._errors = err.body.message || err.message;
throw err;
}
}
static get styles(): CSSResult[] {
return [
haStyle,
css`
ha-card {
overflow: hidden;
}
.errors {
padding: 20px;
font-weight: bold;
color: var(--google-red-500);
}
.content {
padding-bottom: 20px;
}
.triggers,
.script {
margin-top: -16px;
}
.triggers ha-card,
.script ha-card {
margin-top: 16px;
}
.add-card mwc-button {
display: block;
text-align: center;
}
.card-menu {
position: absolute;
top: 0;
right: 0;
z-index: 1;
color: var(--primary-text-color);
}
.rtl .card-menu {
right: auto;
left: 0;
}
.card-menu paper-item {
cursor: pointer;
}
paper-icon-item {
padding: 8px 16px;
}
ha-card paper-icon-button {
color: var(--secondary-text-color);
}
.card-header > paper-icon-button {
float: right;
position: relative;
top: -8px;
}
.device-entity {
cursor: pointer;
}
span[slot="introduction"] a {
color: var(--primary-color);
}
ha-fab {
position: fixed;
bottom: 16px;
right: 16px;
z-index: 1;
margin-bottom: -80px;
transition: margin-bottom 0.3s;
}
ha-fab[is-wide] {
bottom: 24px;
right: 24px;
}
ha-fab[dirty] {
margin-bottom: 0;
}
ha-fab.rtl {
right: auto;
left: 16px;
}
ha-fab[is-wide].rtl {
bottom: 24px;
right: auto;
left: 24px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-scene-editor": HaSceneEditor;
}
}

View File

@ -112,7 +112,7 @@ class HaPanelDevService extends LocalizeMixin(PolymerElement) {
value="[[_computeEntityValue(parsedJSON)]]"
on-change="_entityPicked"
disabled="[[!validJSON]]"
domain-filter="[[_computeEntityDomainFilter(_domain)]]"
include-domains="[[_computeEntityDomainFilter(_domain)]]"
allow-custom-entity
></ha-entity-picker>
</template>
@ -285,7 +285,7 @@ class HaPanelDevService extends LocalizeMixin(PolymerElement) {
}
_computeEntityDomainFilter(domain) {
return ENTITY_COMPONENT_DOMAINS.includes(domain) ? domain : null;
return ENTITY_COMPONENT_DOMAINS.includes(domain) ? [domain] : null;
}
_callService() {

View File

@ -1,10 +1,10 @@
import { STATES_OFF } from "../../../../common/const";
import { turnOnOffEntity } from "./turn-on-off-entity";
import { HomeAssistant } from "../../../../types";
import { HomeAssistant, ServiceCallResponse } from "../../../../types";
export const toggleEntity = (
hass: HomeAssistant,
entityId: string
): Promise<void> => {
): Promise<ServiceCallResponse> => {
const turnOn = STATES_OFF.includes(hass.states[entityId].state);
return turnOnOffEntity(hass, entityId, turnOn);
};

View File

@ -1,11 +1,11 @@
import { computeDomain } from "../../../../common/entity/compute_domain";
import { HomeAssistant } from "../../../../types";
import { HomeAssistant, ServiceCallResponse } from "../../../../types";
export const turnOnOffEntity = (
hass: HomeAssistant,
entityId: string,
turnOn = true
): Promise<void> => {
): Promise<ServiceCallResponse> => {
const stateDomain = computeDomain(entityId);
const serviceDomain = stateDomain === "group" ? "homeassistant" : stateDomain;

View File

@ -78,7 +78,7 @@ export class HuiAlarmPanelCardEditor extends LitElement
.hass="${this.hass}"
.value="${this._entity}"
.configValue=${"entity"}
domain-filter="alarm_control_panel"
include-domains='["alarm_control_panel"]'
@change="${this._valueChanged}"
allow-custom-entity
></ha-entity-picker>

View File

@ -92,7 +92,7 @@ export class HuiGaugeCardEditor extends LitElement
.hass="${this.hass}"
.value="${this._entity}"
.configValue=${"entity"}
domain-filter="sensor"
include-domains='["sensor"]'
@change="${this._valueChanged}"
allow-custom-entity
></ha-entity-picker>

View File

@ -71,7 +71,7 @@ export class HuiLightCardEditor extends LitElement
.hass="${this.hass}"
.value="${this._entity}"
.configValue=${"entity"}
domain-filter="light"
include-domains='["light"]'
@change="${this._valueChanged}"
allow-custom-entity
></ha-entity-picker>

View File

@ -52,7 +52,7 @@ export class HuiMediaControlCardEditor extends LitElement
.hass="${this.hass}"
.value="${this._entity}"
.configValue=${"entity"}
domain-filter="media_player"
include-domains='["media_player"]'
@change="${this._valueChanged}"
allow-custom-entity
></ha-entity-picker>

View File

@ -152,7 +152,7 @@ export class HuiPictureEntityCardEditor extends LitElement
.value="${this._camera_image}"
.configValue=${"camera_image"}
@change="${this._valueChanged}"
domain-filter="camera"
include-domains='["camera"]'
allow-custom-entity
></ha-entity-picker>
<div class="side-by-side">

View File

@ -152,7 +152,7 @@ export class HuiPictureGlanceCardEditor extends LitElement
.configValue=${"camera_image"}
@change="${this._valueChanged}"
allow-custom-entity
domain-filter="camera"
include-domains='["camera"]'
></ha-entity-picker>
<div class="side-by-side">
<paper-dropdown-menu

View File

@ -67,7 +67,7 @@ export class HuiPlantStatusCardEditor extends LitElement
.hass="${this.hass}"
.value="${this._entity}"
.configValue=${"entity"}
domain-filter="plant"
include-domains='["plant"]'
@change="${this._valueChanged}"
allow-custom-entity
></ha-entity-picker>

View File

@ -96,7 +96,7 @@ export class HuiSensorCardEditor extends LitElement
.hass="${this.hass}"
.value="${this._entity}"
.configValue=${"entity"}
domain-filter="sensor"
include-domains='["sensor"]'
@change="${this._valueChanged}"
allow-custom-entity
></ha-entity-picker>

View File

@ -66,7 +66,7 @@ export class HuiThermostatCardEditor extends LitElement
.hass="${this.hass}"
.value="${this._entity}"
.configValue=${"entity"}
domain-filter="climate"
include-domains='["climate"]'
@change="${this._valueChanged}"
allow-custom-entity
></ha-entity-picker>

View File

@ -65,7 +65,7 @@ export class HuiWeatherForecastCardEditor extends LitElement
.hass="${this.hass}"
.value="${this._entity}"
.configValue=${"entity"}
domain-filter="weather"
include-domains='["weather"]'
@change="${this._valueChanged}"
allow-custom-entity
></ha-entity-picker>

View File

@ -16,10 +16,11 @@ import "../components/hui-warning";
import { HomeAssistant } from "../../../types";
import { EntityRow, EntityConfig } from "./types";
import { hasConfigOrEntityChanged } from "../common/has-changed";
import { activateScene } from "../../../data/scene";
@customElement("hui-scene-entity-row")
class HuiSceneEntityRow extends LitElement implements EntityRow {
@property() public hass?: HomeAssistant;
@property() public hass!: HomeAssistant;
@property() private _config?: EntityConfig;
@ -79,11 +80,9 @@ class HuiSceneEntityRow extends LitElement implements EntityRow {
`;
}
private _callService(ev): void {
private _callService(ev: Event): void {
ev.stopPropagation();
this.hass!.callService("scene", "turn_on", {
entity_id: this._config!.entity,
});
activateScene(this.hass, this._config!.entity);
}
}

View File

@ -5,6 +5,7 @@ import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../components/entity/state-info";
import LocalizeMixin from "../mixins/localize-mixin";
import { activateScene } from "../data/scene";
/*
* @appliesMixin LocalizeMixin
@ -23,7 +24,7 @@ class StateCardScene extends LocalizeMixin(PolymerElement) {
<div class="horizontal justified layout">
${this.stateInfoTemplate}
<mwc-button on-click="activateScene"
<mwc-button on-click="_activateScene"
>[[localize('ui.card.scene.activate')]]</mwc-button
>
</div>
@ -51,11 +52,9 @@ class StateCardScene extends LocalizeMixin(PolymerElement) {
};
}
activateScene(ev) {
_activateScene(ev) {
ev.stopPropagation();
this.hass.callService("scene", "turn_on", {
entity_id: this.stateObj.entity_id,
});
activateScene(this.hass, this.stateObj.entity_id);
}
}
customElements.define("state-card-scene", StateCardScene);

View File

@ -17,7 +17,7 @@ import hassCallApi from "../util/hass-call-api";
import { subscribePanels } from "../data/ws-panels";
import { forwardHaptic } from "../data/haptics";
import { fireEvent } from "../common/dom/fire_event";
import { Constructor } from "../types";
import { Constructor, ServiceCallResponse } from "../types";
import { HassBaseEl } from "./hass-base-mixin";
import { broadcastConnectionStatus } from "../data/connection-status";
@ -54,7 +54,12 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
console.log("Calling service", domain, service, serviceData);
}
try {
await callService(conn, domain, service, serviceData);
return (await callService(
conn,
domain,
service,
serviceData
)) as Promise<ServiceCallResponse>;
} catch (err) {
if (__DEV__) {
// tslint:disable-next-line: no-console

View File

@ -517,6 +517,8 @@
"loading": "Loading",
"cancel": "Cancel",
"save": "Save",
"yes": "Yes",
"no": "No",
"successfully_saved": "Successfully saved"
},
"components": {
@ -996,6 +998,47 @@
"link_available_actions": "Learn more about available actions."
}
},
"scene": {
"caption": "Scenes",
"description": "Create and edit scenes",
"activated": "Activated scene {name}.",
"picker": {
"header": "Scene Editor",
"introduction": "The scene editor allows you to create and edit scenes. Please follow the link below to read the instructions to make sure that you have configured Home Assistant correctly.",
"learn_more": "Learn more about scenes",
"pick_scene": "Pick scene to edit",
"no_scenes": "We couldnt find any editable scenes",
"add_scene": "Add scene",
"only_editable": "Only scenes defined in scenes.yaml are editable.",
"edit_scene": "Edit scene",
"show_info_scene": "Show info about scene",
"delete_scene": "Delete scene",
"delete_confirm": "Are you sure you want to delete this scene?"
},
"editor": {
"introduction": "Use scenes to bring your home alive.",
"default_name": "New Scene",
"load_error_not_editable": "Only scenes in scenes.yaml are editable.",
"load_error_unknown": "Error loading scene ({err_no}).",
"save": "Save",
"unsaved_confirm": "You have unsaved changes. Are you sure you want to leave?",
"name": "Name",
"devices": {
"header": "Devices",
"introduction": "Add the devices that you want to be included in your scene. Set all the devices to the state you want for this scene.",
"add": "Add a device",
"delete": "Delete device"
},
"entities": {
"header": "Entities",
"introduction": "Entities that do not belong to a devices can be set here.",
"without_device": "Entities without device",
"device_entities": "If you add an entity that belongs to a device, the device will be added.",
"add": "Add an entity",
"delete": "Delete entity"
}
}
},
"cloud": {
"caption": "Home Assistant Cloud",
"description_login": "Logged in as {email}",

View File

@ -116,6 +116,16 @@ export interface Resources {
[language: string]: { [key: string]: string };
}
export interface Context {
id: string;
parrent_id?: string;
user_id?: string;
}
export interface ServiceCallResponse {
context: Context;
}
export interface HomeAssistant {
auth: Auth & { external?: ExternalMessaging };
connection: Connection;
@ -150,7 +160,7 @@ export interface HomeAssistant {
domain: string,
service: string,
serviceData?: { [key: string]: any }
): Promise<void>;
): Promise<ServiceCallResponse>;
callApi<T>(
method: "GET" | "POST" | "PUT" | "DELETE",
path: string,