diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2737b9a696..3529bccbf4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,8 +1,6 @@ name: Report a bug with the UI, Frontend or Lovelace -about: Report an issue related to the Home Assistant frontend. +description: Report an issue related to the Home Assistant frontend. labels: bug -title: "" -issue_body: true body: - type: markdown attributes: @@ -97,11 +95,7 @@ body: If your issue is about how an entity is shown in the UI, please add the state and attributes for all situations. You can find this information at Developer Tools -> States. - value: | - ```yaml - # Paste your state here. - - ``` + render: txt - type: textarea attributes: label: Problem-relevant frontend configuration @@ -110,29 +104,18 @@ body: configuration of the used cards. Fill this out even if it seems unimportant to you. Please be sure to remove personal information like passwords, private URLs and other credentials. - value: | - ```yaml - # Paste your YAML here. - - ``` + render: yaml - type: textarea attributes: label: Javascript errors shown in your browser console/inspector description: > If you come across any Javascript or other error logs, e.g., in your browser console/inspector please provide them. - value: | - ```txt - # Paste your logs here. - - ``` - - type: markdown + render: txt + - type: textarea attributes: - value: | - ## Additional information - - type: markdown - attributes: - value: | + label: Additional information + description: > If you have any additional information for us, use the field below. - Please note, you can attach screenshots or screen recordings here, - by dragging and dropping files in the field below. + Please note, you can attach screenshots or screen recordings here, by + dragging and dropping files in the field below. diff --git a/cast/src/receiver/layout/hc-lovelace.ts b/cast/src/receiver/layout/hc-lovelace.ts index be23073207..ba94b42394 100644 --- a/cast/src/receiver/layout/hc-lovelace.ts +++ b/cast/src/receiver/layout/hc-lovelace.ts @@ -35,6 +35,7 @@ class HcLovelace extends LitElement { } const lovelace: Lovelace = { config: this.lovelaceConfig, + rawConfig: this.lovelaceConfig, editMode: false, urlPath: this.urlPath!, enableFullEditMode: () => undefined, diff --git a/cast/src/receiver/layout/hc-main.ts b/cast/src/receiver/layout/hc-main.ts index e035d5dd4e..e8623dfa65 100644 --- a/cast/src/receiver/layout/hc-main.ts +++ b/cast/src/receiver/layout/hc-main.ts @@ -221,11 +221,17 @@ export class HcMain extends HassElement { } private async _generateLovelaceConfig() { - const { generateLovelaceConfigFromHass } = await import( - "../../../../src/panels/lovelace/common/generate-lovelace-config" + const { generateLovelaceDashboardStrategy } = await import( + "../../../../src/panels/lovelace/strategies/get-strategy" ); this._handleNewLovelaceConfig( - await generateLovelaceConfigFromHass(this.hass!) + await generateLovelaceDashboardStrategy( + { + hass: this.hass!, + narrow: false, + }, + "original-states" + ) ); } diff --git a/demo/src/configs/arsaboo/entities.ts b/demo/src/configs/arsaboo/entities.ts index 63b4909255..ea1de467f2 100644 --- a/demo/src/configs/arsaboo/entities.ts +++ b/demo/src/configs/arsaboo/entities.ts @@ -246,11 +246,15 @@ export const demoEntitiesArsaboo: DemoConfig["entities"] = (localize) => "light.living_room_lights": { entity_id: "light.living_room_lights", - state: "off", + state: "on", attributes: { min_mireds: 111, max_mireds: 400, + brightness: 175, + color_temp: 300, + supported_color_modes: ["brightness", "color_temp"], friendly_name: "Living Room Lights", + color_mode: "color_temp", supported_features: 55, }, }, @@ -263,13 +267,27 @@ export const demoEntitiesArsaboo: DemoConfig["entities"] = (localize) => }, "light.kitchen_lights": { entity_id: "light.kitchen_lights", + state: "on", + attributes: { + min_mireds: 111, + max_mireds: 400, + brightness: 200, + rgb_color: [255, 175, 96], + supported_color_modes: ["brightness", "color_temp", "rgb"], + color_mode: "rgb", + friendly_name: "Kitchen Lights", + supported_features: 55, + }, + }, + "light.lifx5": { + entity_id: "light.lifx5", state: "off", attributes: { - friendly_name: "Kitchen Lights", + supported_color_modes: ["brightness"], + friendly_name: "Garage Lights", supported_features: 1, }, }, - "sensor.plexspy": { entity_id: "sensor.plexspy", state: "0", @@ -482,16 +500,6 @@ export const demoEntitiesArsaboo: DemoConfig["entities"] = (localize) => icon: "hademo:history", }, }, - "light.lifx5": { - entity_id: "light.lifx5", - state: "on", - attributes: { - min_mireds: 111, - max_mireds: 400, - friendly_name: "Garage Lights", - supported_features: 55, - }, - }, "sensor.alok_to_home": { entity_id: "sensor.alok_to_home", state: "41", diff --git a/demo/src/configs/teachingbirds/entities.ts b/demo/src/configs/teachingbirds/entities.ts index 4b61b57a36..476eab9219 100644 --- a/demo/src/configs/teachingbirds/entities.ts +++ b/demo/src/configs/teachingbirds/entities.ts @@ -1114,6 +1114,9 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () => min_mireds: 153, max_mireds: 500, brightness: 63, + color_temp: 200, + supported_color_modes: ["brightness", "color_temp", "rgb"], + color_mode: "color_temp", friendly_name: "Upstairs lights", supported_features: 63, custom_ui_state_card: "state-card-custom-ui", @@ -1125,6 +1128,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () => attributes: { friendly_name: "Walk in closet lights", supported_features: 41, + supported_color_modes: ["brightness", "color_temp"], custom_ui_state_card: "state-card-custom-ui", icon: "mdi:wall-sconce", }, @@ -1136,6 +1140,8 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () => brightness: 254, friendly_name: "Outdoor lights", supported_features: 41, + supported_color_modes: ["brightness"], + color_mode: "brightness", custom_ui_state_card: "state-card-custom-ui", icon: "mdi:wall-sconce", }, @@ -1148,6 +1154,8 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () => max_mireds: 500, brightness: 128, color_temp: 366, + supported_color_modes: ["brightness", "color_temp", "rgb"], + color_mode: "color_temp", effect_list: ["colorloop"], friendly_name: "Downstairs lights", supported_features: 63, @@ -1307,6 +1315,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () => attributes: { min_mireds: 153, max_mireds: 500, + supported_color_modes: ["brightness", "color_temp"], is_deconz_group: false, friendly_name: "Bedside Lamp", supported_features: 63, @@ -1320,6 +1329,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () => attributes: { min_mireds: 153, max_mireds: 500, + supported_color_modes: ["brightness", "color_temp"], is_deconz_group: false, friendly_name: "Floorlamp Reading Light", supported_features: 43, @@ -1335,6 +1345,8 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () => max_mireds: 500, brightness: 128, color_temp: 366, + supported_color_modes: ["brightness", "color_temp", "rgb"], + color_mode: "color_temp", effect_list: ["colorloop"], is_deconz_group: false, friendly_name: "Hallway window light", @@ -1349,6 +1361,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () => attributes: { brightness: 77, is_deconz_group: false, + supported_color_modes: ["brightness"], friendly_name: "Isa Ceiling Light", supported_features: 41, custom_ui_state_card: "state-card-custom-ui", @@ -1363,6 +1376,8 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () => max_mireds: 500, brightness: 150, color_temp: 366, + supported_color_modes: ["brightness", "color_temp"], + color_mode: "color_temp", effect_list: ["colorloop"], is_deconz_group: false, friendly_name: "Floorlamp", @@ -1377,6 +1392,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () => attributes: { friendly_name: "Bedroom Ceiling Light", supported_features: 41, + supported_color_modes: ["brightness"], custom_ui_state_card: "state-card-custom-ui", icon: "mdi:ceiling-light", }, @@ -1387,6 +1403,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () => attributes: { friendly_name: "Nightlight", supported_features: 17, + supported_color_modes: ["brightness"], custom_ui_state_card: "state-card-custom-ui", icon: "mdi:lamp", }, @@ -1753,6 +1770,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () => power_consumption: 2.2, friendly_name: "Upstairs Hallway Light", supported_features: 33, + supported_color_modes: ["brightness"], custom_ui_state_card: "state-card-custom-ui", icon: "mdi:ceiling-light", }, @@ -1768,6 +1786,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () => power_consumption: 0, friendly_name: "Dining Room Light", supported_features: 33, + supported_color_modes: ["brightness"], custom_ui_state_card: "state-card-custom-ui", icon: "mdi:ceiling-light", }, @@ -1783,6 +1802,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () => power_consumption: 0, friendly_name: "Living room Spotlights", supported_features: 33, + supported_color_modes: ["brightness"], custom_ui_state_card: "state-card-custom-ui", icon: "mdi:track-light", }, @@ -1799,6 +1819,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () => power_consumption: 2.5, friendly_name: "Passage Lights", supported_features: 33, + supported_color_modes: ["brightness"], custom_ui_state_card: "state-card-custom-ui", icon: "mdi:track-light", }, @@ -1843,6 +1864,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () => power_consumption: 37.4, friendly_name: "Kitchen Lights", supported_features: 33, + supported_color_modes: ["brightness"], custom_ui_state_card: "state-card-custom-ui", icon: "mdi:track-light", }, diff --git a/gallery/src/demos/demo-integration-card.ts b/gallery/src/demos/demo-integration-card.ts new file mode 100644 index 0000000000..03a44a1853 --- /dev/null +++ b/gallery/src/demos/demo-integration-card.ts @@ -0,0 +1,350 @@ +import { + customElement, + html, + css, + internalProperty, + LitElement, + TemplateResult, + property, +} from "lit-element"; +import "../../../src/components/ha-formfield"; +import "../../../src/components/ha-switch"; + +import { IntegrationManifest } from "../../../src/data/integration"; + +import { provideHass } from "../../../src/fake_data/provide_hass"; +import { HomeAssistant } from "../../../src/types"; +import "../../../src/panels/config/integrations/ha-integration-card"; +import "../../../src/panels/config/integrations/ha-ignored-config-entry-card"; +import "../../../src/panels/config/integrations/ha-config-flow-card"; +import type { + ConfigEntryExtended, + DataEntryFlowProgressExtended, +} from "../../../src/panels/config/integrations/ha-config-integrations"; +import { DeviceRegistryEntry } from "../../../src/data/device_registry"; +import { EntityRegistryEntry } from "../../../src/data/entity_registry"; +import { classMap } from "lit-html/directives/class-map"; + +const createConfigEntry = ( + title: string, + override: Partial = {} +): ConfigEntryExtended => ({ + entry_id: title, + domain: "esphome", + localized_domain_name: "ESPHome", + title, + source: "zeroconf", + state: "loaded", + connection_class: "local_push", + supports_options: false, + supports_unload: true, + disabled_by: null, + reason: null, + ...override, +}); + +const createManifest = ( + isCustom: boolean, + isCloud: boolean, + name = "ESPHome" +): IntegrationManifest => ({ + name, + domain: "esphome", + is_built_in: !isCustom, + config_flow: false, + documentation: "https://www.home-assistant.io/integrations/esphome/", + iot_class: isCloud ? "cloud_polling" : "local_polling", +}); + +const loadedEntry = createConfigEntry("Loaded"); +const nameAsDomainEntry = createConfigEntry("ESPHome"); +const longNameEntry = createConfigEntry( + "Entry with a super long name that is going to the next line" +); +const configPanelEntry = createConfigEntry("Config Panel", { + domain: "mqtt", + localized_domain_name: "MQTT", +}); +const optionsFlowEntry = createConfigEntry("Options Flow", { + supports_options: true, +}); +const setupErrorEntry = createConfigEntry("Setup Error", { + state: "setup_error", +}); +const migrationErrorEntry = createConfigEntry("Migration Error", { + state: "migration_error", +}); +const setupRetryEntry = createConfigEntry("Setup Retry", { + state: "setup_retry", +}); +const setupRetryReasonEntry = createConfigEntry("Setup Retry", { + state: "setup_retry", + reason: "connection_error", +}); +const setupRetryReasonMissingKeyEntry = createConfigEntry("Setup Retry", { + state: "setup_retry", + reason: "resolve_error", +}); +const failedUnloadEntry = createConfigEntry("Failed Unload", { + state: "failed_unload", +}); +const notLoadedEntry = createConfigEntry("Not Loaded", { state: "not_loaded" }); +const disabledEntry = createConfigEntry("Disabled", { + state: "not_loaded", + disabled_by: "user", +}); +const disabledFailedUnloadEntry = createConfigEntry( + "Disabled - Failed Unload", + { + state: "failed_unload", + disabled_by: "user", + } +); + +const configFlows: DataEntryFlowProgressExtended[] = [ + { + flow_id: "adbb401329d8439ebb78ef29837826a8", + handler: "roku", + context: { + source: "ssdp", + unique_id: "YF008D862864", + title_placeholders: { + name: "Living room Roku", + }, + }, + step_id: "discovery_confirm", + localized_title: "Living room Roku", + }, + { + flow_id: "adbb401329d8439ebb78ef29837826a8", + handler: "hue", + context: { + source: "reauth", + unique_id: "YF008D862864", + title_placeholders: { + name: "Living room Roku", + }, + }, + step_id: "discovery_confirm", + localized_title: "Philips Hue", + }, +]; + +const configEntries: Array<{ + items: ConfigEntryExtended[]; + is_custom?: boolean; + disabled?: boolean; + highlight?: string; +}> = [ + { items: [loadedEntry] }, + { items: [configPanelEntry] }, + { items: [optionsFlowEntry] }, + { items: [nameAsDomainEntry] }, + { items: [longNameEntry] }, + { items: [setupErrorEntry] }, + { items: [migrationErrorEntry] }, + { items: [setupRetryEntry] }, + { items: [setupRetryReasonEntry] }, + { items: [setupRetryReasonMissingKeyEntry] }, + { items: [failedUnloadEntry] }, + { items: [notLoadedEntry] }, + { + items: [ + loadedEntry, + setupErrorEntry, + migrationErrorEntry, + longNameEntry, + setupRetryEntry, + failedUnloadEntry, + notLoadedEntry, + disabledEntry, + nameAsDomainEntry, + configPanelEntry, + optionsFlowEntry, + ], + }, + { disabled: true, items: [disabledEntry] }, + { disabled: true, items: [disabledFailedUnloadEntry] }, + { + disabled: true, + items: [disabledEntry, disabledFailedUnloadEntry], + }, + { + items: [loadedEntry, configPanelEntry], + highlight: "Loaded", + }, +]; + +const createEntityRegistryEntries = ( + item: ConfigEntryExtended +): EntityRegistryEntry[] => [ + { + config_entry_id: item.entry_id, + device_id: "mock-device-id", + area_id: null, + disabled_by: null, + entity_id: "binary_sensor.updater", + name: null, + icon: null, + platform: "updater", + }, +]; + +const createDeviceRegistryEntries = ( + item: ConfigEntryExtended +): DeviceRegistryEntry[] => [ + { + entry_type: null, + config_entries: [item.entry_id], + connections: [], + manufacturer: "ESPHome", + model: "Mock Device", + name: "Tag Reader", + sw_version: null, + id: "mock-device-id", + identifiers: [], + via_device_id: null, + area_id: null, + name_by_user: null, + disabled_by: null, + }, +]; + +@customElement("demo-integration-card") +export class DemoIntegrationCard extends LitElement { + @property({ attribute: false }) hass?: HomeAssistant; + + @internalProperty() isCustomIntegration = false; + + @internalProperty() isCloud = false; + + protected render(): TemplateResult { + if (!this.hass) { + return html``; + } + return html` +
+
+ + + + + + +
+ + + + ${configFlows.map( + (flow) => html` + + ` + )} + ${configEntries.map( + (info) => html` + + ` + )} +
+
+ + +
+ `; + } + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + const hass = provideHass(this); + hass.updateTranslations(null, "en"); + hass.updateTranslations("config", "en"); + // Normally this string is loaded from backend + hass.addTranslations( + { + "component.esphome.config.error.connection_error": + "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.", + }, + "en" + ); + } + + private _toggleCustomIntegration() { + this.isCustomIntegration = !this.isCustomIntegration; + } + + private _toggleCloud() { + this.isCloud = !this.isCloud; + } + + static get styles() { + return css` + .container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + grid-gap: 16px 16px; + padding: 8px 16px 16px; + margin-bottom: 64px; + } + + .container > * { + max-width: 500px; + } + + ha-formfield { + margin: 8px 0; + display: block; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "demo-integration-card": DemoIntegrationCard; + } +} diff --git a/gallery/src/demos/demo-more-info-light.ts b/gallery/src/demos/demo-more-info-light.ts index d469da2212..399873aa9c 100644 --- a/gallery/src/demos/demo-more-info-light.ts +++ b/gallery/src/demos/demo-more-info-light.ts @@ -9,13 +9,10 @@ import { } from "lit-element"; import "../../../src/components/ha-card"; import { - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, + LightColorModes, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_TRANSITION, - SUPPORT_WHITE_VALUE, } from "../../../src/data/light"; import "../../../src/dialogs/more-info/more-info-content"; import { getEntity } from "../../../src/fake_data/entity"; @@ -32,7 +29,8 @@ const ENTITIES = [ getEntity("light", "kitchen_light", "on", { friendly_name: "Brightness Light", brightness: 200, - supported_features: SUPPORT_BRIGHTNESS, + supported_color_modes: [LightColorModes.BRIGHTNESS], + color_mode: LightColorModes.BRIGHTNESS, }), getEntity("light", "color_temperature_light", "on", { friendly_name: "White Color Temperature Light", @@ -40,20 +38,96 @@ const ENTITIES = [ color_temp: 75, min_mireds: 30, max_mireds: 150, - supported_features: SUPPORT_BRIGHTNESS + SUPPORT_COLOR_TEMP, + supported_color_modes: [ + LightColorModes.BRIGHTNESS, + LightColorModes.COLOR_TEMP, + ], + color_mode: LightColorModes.COLOR_TEMP, }), - getEntity("light", "color_effectslight", "on", { - friendly_name: "Color Effets Light", + getEntity("light", "color_hs_light", "on", { + friendly_name: "Color HS Light", brightness: 255, hs_color: [30, 100], - white_value: 36, - supported_features: - SUPPORT_BRIGHTNESS + - SUPPORT_EFFECT + - SUPPORT_FLASH + - SUPPORT_COLOR + - SUPPORT_TRANSITION + - SUPPORT_WHITE_VALUE, + rgb_color: [30, 100, 255], + min_mireds: 30, + max_mireds: 150, + supported_features: SUPPORT_EFFECT + SUPPORT_FLASH + SUPPORT_TRANSITION, + supported_color_modes: [ + LightColorModes.BRIGHTNESS, + LightColorModes.COLOR_TEMP, + LightColorModes.HS, + ], + color_mode: LightColorModes.HS, + effect_list: ["random", "colorloop"], + }), + getEntity("light", "color_rgb_ct_light", "on", { + friendly_name: "Color RGB + CT Light", + brightness: 255, + color_temp: 75, + min_mireds: 30, + max_mireds: 150, + supported_features: SUPPORT_EFFECT + SUPPORT_FLASH + SUPPORT_TRANSITION, + supported_color_modes: [ + LightColorModes.BRIGHTNESS, + LightColorModes.COLOR_TEMP, + LightColorModes.RGB, + ], + color_mode: LightColorModes.COLOR_TEMP, + effect_list: ["random", "colorloop"], + }), + getEntity("light", "color_RGB_light", "on", { + friendly_name: "Color Effets Light", + brightness: 255, + rgb_color: [30, 100, 255], + supported_features: SUPPORT_EFFECT + SUPPORT_FLASH + SUPPORT_TRANSITION, + supported_color_modes: [LightColorModes.BRIGHTNESS, LightColorModes.RGB], + color_mode: LightColorModes.RGB, + effect_list: ["random", "colorloop"], + }), + getEntity("light", "color_rgbw_light", "on", { + friendly_name: "Color RGBW Light", + brightness: 255, + rgbw_color: [30, 100, 255, 125], + min_mireds: 30, + max_mireds: 150, + supported_features: SUPPORT_EFFECT + SUPPORT_FLASH + SUPPORT_TRANSITION, + supported_color_modes: [ + LightColorModes.BRIGHTNESS, + LightColorModes.COLOR_TEMP, + LightColorModes.RGBW, + ], + color_mode: LightColorModes.RGBW, + effect_list: ["random", "colorloop"], + }), + getEntity("light", "color_rgbww_light", "on", { + friendly_name: "Color RGBWW Light", + brightness: 255, + rgbww_color: [30, 100, 255, 125, 10], + min_mireds: 30, + max_mireds: 150, + supported_features: SUPPORT_EFFECT + SUPPORT_FLASH + SUPPORT_TRANSITION, + supported_color_modes: [ + LightColorModes.BRIGHTNESS, + LightColorModes.COLOR_TEMP, + LightColorModes.RGBWW, + ], + color_mode: LightColorModes.RGBWW, + effect_list: ["random", "colorloop"], + }), + getEntity("light", "color_xy_light", "on", { + friendly_name: "Color XY Light", + brightness: 255, + xy_color: [30, 100], + rgb_color: [30, 100, 255], + min_mireds: 30, + max_mireds: 150, + supported_features: SUPPORT_EFFECT + SUPPORT_FLASH + SUPPORT_TRANSITION, + supported_color_modes: [ + LightColorModes.BRIGHTNESS, + LightColorModes.COLOR_TEMP, + LightColorModes.XY, + ], + color_mode: LightColorModes.XY, effect_list: ["random", "colorloop"], }), ]; diff --git a/hassio/src/addon-view/hassio-addon-dashboard.ts b/hassio/src/addon-view/hassio-addon-dashboard.ts index 4e11853aca..6287c7907d 100644 --- a/hassio/src/addon-view/hassio-addon-dashboard.ts +++ b/hassio/src/addon-view/hassio-addon-dashboard.ts @@ -177,8 +177,9 @@ class HassioAddonDashboard extends LitElement { const requestedAddon = extractSearchParam("addon"); if (requestedAddon) { const addonsInfo = await fetchHassioAddonsInfo(this.hass); - const validAddon = addonsInfo.addons - .some((addon) => addon.slug === requestedAddon); + const validAddon = addonsInfo.addons.some( + (addon) => addon.slug === requestedAddon + ); if (!validAddon) { this._error = this.supervisor.localize("my.error_addon_not_found"); } else { diff --git a/hassio/src/addon-view/info/hassio-addon-info.ts b/hassio/src/addon-view/info/hassio-addon-info.ts index e4161971bb..b0f10392fa 100644 --- a/hassio/src/addon-view/info/hassio-addon-info.ts +++ b/hassio/src/addon-view/info/hassio-addon-info.ts @@ -242,14 +242,18 @@ class HassioAddonInfo extends LitElement { ? html` Current version: ${this.addon.version}
- (${ - this.supervisor.localize("addon.dashboard.changelog")}${this.supervisor.localize( + "addon.dashboard.changelog" + )})
` - : html`${ - this.supervisor.localize("addon.dashboard.changelog") - }`} + : html`${this.supervisor.localize( + "addon.dashboard.changelog" + )}`}
diff --git a/hassio/src/components/supervisor-metric.ts b/hassio/src/components/supervisor-metric.ts index 25eabcdf60..5241733876 100644 --- a/hassio/src/components/supervisor-metric.ts +++ b/hassio/src/components/supervisor-metric.ts @@ -73,7 +73,7 @@ class SupervisorMetric extends LitElement { ); } .value { - width: 42px; + width: 48px; padding-right: 4px; } `; diff --git a/hassio/src/hassio-main.ts b/hassio/src/hassio-main.ts index fd05060c94..e5b8f89530 100644 --- a/hassio/src/hassio-main.ts +++ b/hassio/src/hassio-main.ts @@ -44,7 +44,10 @@ export class HassioMain extends SupervisorBaseElement { // We changed the navigate event to fire directly on the window, as that's // where we are listening for it. However, the older panel_custom will // listen on this element for navigation events, so we need to forward them. - window.addEventListener("location-changed", (ev) => + + // Joakim - April 26, 2021 + // Due to changes in behavior in Google Chrome, we changed navigate to fire on the top element + top.addEventListener("location-changed", (ev) => // @ts-ignore fireEvent(this, ev.type, ev.detail, { bubbles: false, diff --git a/hassio/src/system/hassio-supervisor-info.ts b/hassio/src/system/hassio-supervisor-info.ts index 74585bbec2..bfa21d0d69 100644 --- a/hassio/src/system/hassio-supervisor-info.ts +++ b/hassio/src/system/hassio-supervisor-info.ts @@ -269,13 +269,15 @@ class HassioSupervisorInfo extends LitElement {

${this.supervisor.localize("system.supervisor.beta_release_items")} -
  • Home Assistant Core
  • -
  • Home Assistant Supervisor
  • -
  • Home Assistant Operating System
  • +
    - ${this.supervisor.localize("system.supervisor.join_beta_action")}`, + ${this.supervisor.localize("system.supervisor.beta_join_confirm")}`, confirmText: this.supervisor.localize( - "system.supervisor.beta_join_confirm" + "system.supervisor.join_beta_action" ), dismissText: this.supervisor.localize("common.cancel"), }); diff --git a/package.json b/package.json index d0482825d6..458085538a 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@braintree/sanitize-url": "^5.0.0", "@codemirror/commands": "^0.18.0", "@codemirror/gutter": "^0.18.0", - "@codemirror/highlight": "^0.18.1", + "@codemirror/highlight": "^0.18.0", "@codemirror/history": "^0.18.0", "@codemirror/legacy-modes": "^0.18.0", "@codemirror/rectangular-selection": "^0.18.0", @@ -100,7 +100,6 @@ "@webcomponents/webcomponentsjs": "^2.2.7", "chart.js": "~2.8.0", "chartjs-chart-timeline": "^0.3.0", - "codemirror": "^5.49.0", "comlink": "^4.3.0", "core-js": "^3.6.5", "cropperjs": "^1.5.7", @@ -109,7 +108,7 @@ "fecha": "^4.2.0", "fuse.js": "^6.0.0", "google-timezones-json": "^1.0.2", - "hls.js": "^0.13.2", + "hls.js": "^1.0.1", "home-assistant-js-websocket": "^5.9.0", "idb-keyval": "^3.2.0", "intl-messageformat": "^8.3.9", @@ -139,10 +138,12 @@ "vue": "^2.6.11", "vue2-daterange-picker": "^0.5.1", "web-animations-js": "^2.3.2", - "workbox-core": "^5.1.3", - "workbox-precaching": "^5.1.3", - "workbox-routing": "^5.1.3", - "workbox-strategies": "^5.1.3", + "workbox-cacheable-response": "^6.1.5", + "workbox-core": "^6.1.5", + "workbox-expiration": "^6.1.5", + "workbox-precaching": "^6.1.5", + "workbox-routing": "^6.1.5", + "workbox-strategies": "^6.1.5", "xss": "^1.0.6" }, "devDependencies": { @@ -167,8 +168,6 @@ "@types/chai": "^4.1.7", "@types/chromecast-caf-receiver": "^5.0.11", "@types/chromecast-caf-sender": "^1.0.3", - "@types/codemirror": "^0.0.97", - "@types/hls.js": "^0.12.3", "@types/js-yaml": "^3.12.1", "@types/leaflet": "^1.4.3", "@types/leaflet-draw": "^1.0.1", @@ -228,14 +227,14 @@ "terser-webpack-plugin": "^5.1.1", "ts-lit-plugin": "^1.2.1", "ts-mocha": "^7.0.0", - "typescript": "^4.0.3", + "typescript": "^4.2.4", "vinyl-buffer": "^1.0.1", "vinyl-source-stream": "^2.0.0", "webpack": "^5.24.1", "webpack-cli": "^4.5.0", "webpack-dev-server": "^3.11.2", "webpack-manifest-plugin": "^3.0.0", - "workbox-build": "^5.1.3" + "workbox-build": "^6.1.5" }, "_comment": "Polymer fixed to 3.1 because 3.2 throws on logbook page", "_comment_2": "Fix in https://github.com/Polymer/polymer/pull/5569", diff --git a/setup.py b/setup.py index 3e60acb104..18d6b372e6 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="home-assistant-frontend", - version="20210407.3", + version="20210428.0", description="The Home Assistant frontend", url="https://github.com/home-assistant/home-assistant-polymer", author="The Home Assistant Authors", diff --git a/src/auth/ha-authorize.ts b/src/auth/ha-authorize.ts index 3cd85aa5f2..ceabc2b409 100644 --- a/src/auth/ha-authorize.ts +++ b/src/auth/ha-authorize.ts @@ -8,6 +8,7 @@ import { PropertyValues, } from "lit-element"; import punycode from "punycode"; +import { applyThemesOnElement } from "../common/dom/apply_themes_on_element"; import { extractSearchParamsObject } from "../common/url/search-params"; import { AuthProvider, @@ -116,6 +117,20 @@ class HaAuthorize extends litLocalizeLiteMixin(LitElement) { this._fetchAuthProviders(); this._fetchDiscoveryInfo(); + if (matchMedia("(prefers-color-scheme: dark)").matches) { + applyThemesOnElement( + document.documentElement, + { + default_theme: "default", + default_dark_theme: null, + themes: {}, + darkMode: false, + }, + "default", + { dark: true } + ); + } + if (!this.redirectUri) { return; } diff --git a/src/cast/receiver_messages.ts b/src/cast/receiver_messages.ts index 460054135d..953d766abd 100644 --- a/src/cast/receiver_messages.ts +++ b/src/cast/receiver_messages.ts @@ -62,7 +62,7 @@ export const ensureConnectedCastSession = (cast: CastManager, auth: Auth) => { return undefined; } - return new Promise((resolve) => { + return new Promise((resolve) => { const unsub = cast.addEventListener("connection-changed", () => { if (cast.castConnectedToOurHass) { unsub(); diff --git a/src/common/color/convert-color.ts b/src/common/color/convert-color.ts index 29f95b4283..a0716a68e4 100644 --- a/src/common/color/convert-color.ts +++ b/src/common/color/convert-color.ts @@ -102,3 +102,18 @@ export const lab2hex = (lab: [number, number, number]): string => { const rgb = lab2rgb(lab); return rgb2hex(rgb); }; + +export const rgb2hsv = ( + rgb: [number, number, number] +): [number, number, number] => { + const [r, g, b] = rgb; + const v = Math.max(r, g, b); + const c = v - Math.min(r, g, b); + const h = + c && (v === r ? (g - b) / c : v === g ? 2 + (b - r) / c : 4 + (r - g) / c); + return [60 * (h < 0 ? h + 6 : h), v && c / v, v]; +}; + +export const rgb2hs = (rgb: [number, number, number]): [number, number] => { + return rgb2hsv(rgb).slice(0, 2) as [number, number]; +}; diff --git a/src/common/dom/apply_themes_on_element.ts b/src/common/dom/apply_themes_on_element.ts index 926b2eb670..176068c92b 100644 --- a/src/common/dom/apply_themes_on_element.ts +++ b/src/common/dom/apply_themes_on_element.ts @@ -70,13 +70,18 @@ export const applyThemesOnElement = ( themeRules["text-accent-color"] = rgbContrast(rgbAccentColor, [33, 33, 33]) < 6 ? "#fff" : "#212121"; } + + // Nothing was changed + if (element._themes?.cacheKey === cacheKey) { + return; + } } if (selectedTheme && themes.themes[selectedTheme]) { themeRules = themes.themes[selectedTheme]; } - if (!element._themes && !Object.keys(themeRules).length) { + if (!element._themes?.keys && !Object.keys(themeRules).length) { // No styles to reset, and no styles to set return; } @@ -87,8 +92,8 @@ export const applyThemesOnElement = ( : undefined; // Add previous set keys to reset them, and new theme - const styles = { ...element._themes, ...newTheme?.styles }; - element._themes = newTheme?.keys; + const styles = { ...element._themes?.keys, ...newTheme?.styles }; + element._themes = { cacheKey, keys: newTheme?.keys }; // Set and/or reset styles if (element.updateStyles) { diff --git a/src/common/navigate.ts b/src/common/navigate.ts index e8505ca3d4..7bbc589f29 100644 --- a/src/common/navigate.ts +++ b/src/common/navigate.ts @@ -12,20 +12,24 @@ declare global { export const navigate = (_node: any, path: string, replace = false) => { if (__DEMO__) { if (replace) { - history.replaceState( - history.state?.root ? { root: true } : null, + top.history.replaceState( + top.history.state?.root ? { root: true } : null, "", - `${location.pathname}#${path}` + `${top.location.pathname}#${path}` ); } else { - window.location.hash = path; + top.location.hash = path; } } else if (replace) { - history.replaceState(history.state?.root ? { root: true } : null, "", path); + top.history.replaceState( + top.history.state?.root ? { root: true } : null, + "", + path + ); } else { - history.pushState(null, "", path); + top.history.pushState(null, "", path); } - fireEvent(window, "location-changed", { + fireEvent(top, "location-changed", { replace, }); }; diff --git a/src/common/string/filter/sequence-matching.ts b/src/common/string/filter/sequence-matching.ts index aa67093497..8c50326fd2 100644 --- a/src/common/string/filter/sequence-matching.ts +++ b/src/common/string/filter/sequence-matching.ts @@ -10,10 +10,13 @@ import { fuzzyScore } from "./filter"; * @return {number} Score representing how well the word matches the filter. Return of 0 means no match. */ -export const fuzzySequentialMatch = (filter: string, ...words: string[]) => { +export const fuzzySequentialMatch = ( + filter: string, + item: ScorableTextItem +) => { let topScore = Number.NEGATIVE_INFINITY; - for (const word of words) { + for (const word of item.strings) { const scores = fuzzyScore( filter, filter.toLowerCase(), @@ -28,13 +31,9 @@ export const fuzzySequentialMatch = (filter: string, ...words: string[]) => { continue; } - // The VS Code implementation of filter returns a: - // - Negative score for a good match that starts in the middle of the string - // - Positive score if the match starts at the beginning of the string - // - 0 if the filter string is just barely a match - // - undefined for no match - // The "0" return is problematic since .filter() will remove that match, even though a 0 == good match. - // So, if we encounter a 0 return, set it to 1 so the match will be included, and still respect ordering. + // The VS Code implementation of filter returns a 0 for a weak match. + // But if .filter() sees a "0", it considers that a failed match and will remove it. + // So, we set score to 1 in these cases so the match will be included, and mostly respect correct ordering. const score = scores[0] === 0 ? 1 : scores[0]; if (score > topScore) { @@ -49,10 +48,22 @@ export const fuzzySequentialMatch = (filter: string, ...words: string[]) => { return topScore; }; +/** + * An interface that objects must extend in order to use the fuzzy sequence matcher + * + * @param {number} score - A number representing the existence and strength of a match. + * - `< 0` means a good match that starts in the middle of the string + * - `> 0` means a good match that starts at the beginning of the string + * - `0` means just barely a match + * - `undefined` means not a match + * + * @param {string} strings - Array of strings (aliases) representing the item. The filter string will be compared against each of these for a match. + * + */ + export interface ScorableTextItem { score?: number; - filterText: string; - altText?: string; + strings: string[]; } type FuzzyFilterSort = ( @@ -63,9 +74,7 @@ type FuzzyFilterSort = ( export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) => { return items .map((item) => { - item.score = item.altText - ? fuzzySequentialMatch(filter, item.filterText, item.altText) - : fuzzySequentialMatch(filter, item.filterText); + item.score = fuzzySequentialMatch(filter, item); return item; }) .filter((item) => item.score !== undefined) diff --git a/src/common/string/format_number.ts b/src/common/string/format_number.ts index 0f171bd0ea..da8af2798a 100644 --- a/src/common/string/format_number.ts +++ b/src/common/string/format_number.ts @@ -58,7 +58,7 @@ export const formatNumber = ( ).format(Number(num)); } } - return num ? num.toString() : ""; + return num.toString(); }; /** diff --git a/src/common/util/render-status.ts b/src/common/util/render-status.ts index b51baffce4..c0319c623c 100644 --- a/src/common/util/render-status.ts +++ b/src/common/util/render-status.ts @@ -1,4 +1,4 @@ -export const afterNextRender = (cb: () => void): void => { +export const afterNextRender = (cb: (value: unknown) => void): void => { requestAnimationFrame(() => setTimeout(cb, 0)); }; diff --git a/src/components/entity/state-badge.ts b/src/components/entity/state-badge.ts index 0b67adcc6a..2fa735f5b6 100644 --- a/src/components/entity/state-badge.ts +++ b/src/components/entity/state-badge.ts @@ -15,6 +15,7 @@ import { computeActiveState } from "../../common/entity/compute_active_state"; import { computeStateDomain } from "../../common/entity/compute_state_domain"; import { stateIcon } from "../../common/entity/state_icon"; import { iconColorCSS } from "../../common/style/icon_color_css"; +import { getLightRgbColor, LightEntity } from "../../data/light"; import type { HomeAssistant } from "../../types"; import "../ha-icon"; @@ -99,11 +100,13 @@ export class StateBadge extends LitElement { hostStyle.backgroundImage = `url(${imageUrl})`; this._showIcon = false; } else if (stateObj.state === "on") { - if (stateObj.attributes.hs_color && this.stateColor !== false) { - const hue = stateObj.attributes.hs_color[0]; - const sat = stateObj.attributes.hs_color[1]; - if (sat > 10) { - iconStyle.color = `hsl(${hue}, 100%, ${100 - sat / 2}%)`; + if ( + computeStateDomain(stateObj) === "light" && + this.stateColor !== false + ) { + const rgb = getLightRgbColor(stateObj as LightEntity); + if (rgb) { + iconStyle.color = `rgb(${rgb.slice(0, 3).join(",")})`; } } if (stateObj.attributes.brightness && this.stateColor !== false) { diff --git a/src/components/ha-analytics-learn-more.ts b/src/components/ha-analytics-learn-more.ts index 8b7c756efa..75acdab0b5 100644 --- a/src/components/ha-analytics-learn-more.ts +++ b/src/components/ha-analytics-learn-more.ts @@ -6,5 +6,6 @@ export const analyticsLearnMore = (hass: HomeAssistant) => html`${hass.localize("ui.panel.config.core.section.core.analytics.learn_more")}`; +> + How we process your data +`; diff --git a/src/components/ha-analytics.ts b/src/components/ha-analytics.ts index 6cf9d37b07..f6d40fe356 100644 --- a/src/components/ha-analytics.ts +++ b/src/components/ha-analytics.ts @@ -8,7 +8,6 @@ import { property, TemplateResult, } from "lit-element"; -import { isComponentLoaded } from "../common/config/is_component_loaded"; import { fireEvent } from "../common/dom/fire_event"; import { Analytics, AnalyticsPreferences } from "../data/analytics"; import { haStyle } from "../resources/styles"; @@ -17,7 +16,18 @@ import "./ha-checkbox"; import type { HaCheckbox } from "./ha-checkbox"; import "./ha-settings-row"; -const ADDITIONAL_PREFERENCES = ["usage", "statistics"]; +const ADDITIONAL_PREFERENCES = [ + { + key: "usage", + title: "Usage", + description: "Details of what you use with Home Assistant", + }, + { + key: "statistics", + title: "Statistical data", + description: "Counts containing total number of datapoints", + }, +]; declare global { interface HASSDomEvents { @@ -48,14 +58,10 @@ export class HaAnalytics extends LitElement { - ${this.hass.localize( - `ui.panel.config.core.section.core.analytics.preference.base.title` - )} + Basic analytics - ${this.hass.localize( - `ui.panel.config.core.section.core.analytics.preference.base.description` - )} + This includes information about your system. ${ADDITIONAL_PREFERENCES.map( @@ -64,44 +70,23 @@ export class HaAnalytics extends LitElement { ${!baseEnabled - ? html`${this.hass.localize( - "ui.panel.config.core.section.core.analytics.needs_base" - )} + ? html` + You need to enable basic analytics for this option to be + available ` : ""} - - ${preference === "usage" - ? isComponentLoaded(this.hass, "hassio") - ? this.hass.localize( - `ui.panel.config.core.section.core.analytics.preference.usage_supervisor.title` - ) - : this.hass.localize( - `ui.panel.config.core.section.core.analytics.preference.usage.title` - ) - : this.hass.localize( - `ui.panel.config.core.section.core.analytics.preference.${preference}.title` - )} + + ${preference.title} - - ${preference !== "usage" - ? this.hass.localize( - `ui.panel.config.core.section.core.analytics.preference.${preference}.description` - ) - : isComponentLoaded(this.hass, "hassio") - ? this.hass.localize( - `ui.panel.config.core.section.core.analytics.preference.usage_supervisor.description` - ) - : this.hass.localize( - `ui.panel.config.core.section.core.analytics.preference.usage.description` - )} + + ${preference.description} ` )} @@ -117,14 +102,10 @@ export class HaAnalytics extends LitElement { - ${this.hass.localize( - `ui.panel.config.core.section.core.analytics.preference.diagnostics.title` - )} + Diagnostics - ${this.hass.localize( - `ui.panel.config.core.section.core.analytics.preference.diagnostics.description` - )} + Share crash reports when unexpected errors occur. `; @@ -161,7 +142,10 @@ export class HaAnalytics extends LitElement { preferences[preference] = checkbox.checked; - if (ADDITIONAL_PREFERENCES.includes(preference) && checkbox.checked) { + if ( + ADDITIONAL_PREFERENCES.some((entry) => entry.key === preference) && + checkbox.checked + ) { preferences.base = true; } else if (preference === "base" && !checkbox.checked) { preferences.usage = false; diff --git a/src/components/ha-button-toggle-group.ts b/src/components/ha-button-toggle-group.ts index 33c65cdf82..8facec61bf 100644 --- a/src/components/ha-button-toggle-group.ts +++ b/src/components/ha-button-toggle-group.ts @@ -9,6 +9,7 @@ import { property, TemplateResult, } from "lit-element"; +import { styleMap } from "lit-html/directives/style-map"; import { fireEvent } from "../common/dom/fire_event"; import type { ToggleButton } from "../types"; import "./ha-svg-icon"; @@ -19,6 +20,8 @@ export class HaButtonToggleGroup extends LitElement { @property() public active?: string; + @property({ type: Boolean }) public fullWidth = false; + protected render(): TemplateResult { return html`
    @@ -33,6 +36,11 @@ export class HaButtonToggleGroup extends LitElement { ` : html` @@ -282,12 +291,13 @@ class HaColorPicker extends EventsMixin(PolymerElement) { processUserSelect(ev) { const canvasXY = this.convertToCanvasCoordinates(ev.clientX, ev.clientY); const hs = this.getColor(canvasXY.x, canvasXY.y); - this.onColorSelect(hs); + const rgb = this.getRgbColor(canvasXY.x, canvasXY.y); + this.onColorSelect(hs, rgb); } // apply color to marker position and canvas - onColorSelect(hs) { - this.setMarkerOnColor(hs); // marker always follows mounse 'raw' hs value (= mouse position) + onColorSelect(hs, rgb) { + this.setMarkerOnColor(hs); // marker always follows mouse 'raw' hs value (= mouse position) if (!this.ignoreSegments) { // apply segments if needed hs = this.applySegmentFilter(hs); @@ -301,11 +311,11 @@ class HaColorPicker extends EventsMixin(PolymerElement) { // eventually after throttle limit has passed clearTimeout(this.ensureFinalSelect); this.ensureFinalSelect = setTimeout(() => { - this.fireColorSelected(hs); // do it for the final time + this.fireColorSelected(hs, rgb); // do it for the final time }, this.throttle); return; } - this.fireColorSelected(hs); // do it + this.fireColorSelected(hs, rgb); // do it this.colorSelectIsThrottled = true; setTimeout(() => { this.colorSelectIsThrottled = false; @@ -313,9 +323,9 @@ class HaColorPicker extends EventsMixin(PolymerElement) { } // set color values and fire colorselected event - fireColorSelected(hs) { + fireColorSelected(hs, rgb) { this.hsColor = hs; - this.fire("colorselected", { hs: { h: hs.h, s: hs.s } }); + this.fire("colorselected", { hs, rgb }); } /* @@ -363,6 +373,11 @@ class HaColorPicker extends EventsMixin(PolymerElement) { this.applyColorToCanvas(hs); } + applyRgbColor(rgb) { + const [h, s] = rgb2hs(rgb); + this.applyHsColor({ h, s }); + } + /* * input processing helpers */ @@ -395,6 +410,15 @@ class HaColorPicker extends EventsMixin(PolymerElement) { return { h: hue, s: sat }; } + getRgbColor(x, y) { + // get current pixel + const imageData = this.backgroundLayer + .getContext("2d") + .getImageData(x + 250, y + 250, 1, 1); + const pixel = imageData.data; + return { r: pixel[0], g: pixel[1], b: pixel[2] }; + } + applySegmentFilter(hs) { // apply hue segment steps if (this.hueSegments) { @@ -468,7 +492,7 @@ class HaColorPicker extends EventsMixin(PolymerElement) { .getPropertyValue("--wheel-bordercolor") .trim(); const wheelShadow = wheelStyle.getPropertyValue("--wheel-shadow").trim(); - // extract shadow properties from CCS variable + // extract shadow properties from CSS variable // the shadow should be defined as: "10px 5px 5px 0px COLOR" if (wheelShadow !== "none") { const values = wheelShadow.split("px "); diff --git a/src/components/ha-hls-player.ts b/src/components/ha-hls-player.ts index 777684d187..e841993c1b 100644 --- a/src/components/ha-hls-player.ts +++ b/src/components/ha-hls-player.ts @@ -1,3 +1,4 @@ +import type HlsType from "hls.js"; import { css, CSSResult, @@ -15,8 +16,6 @@ import { nextRender } from "../common/util/render-status"; import { getExternalConfig } from "../external_app/external_config"; import type { HomeAssistant } from "../types"; -type HLSModule = typeof import("hls.js"); - @customElement("ha-hls-player") class HaHLSPlayer extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -43,7 +42,7 @@ class HaHLSPlayer extends LitElement { @internalProperty() private _attached = false; - private _hlsPolyfillInstance?: Hls; + private _hlsPolyfillInstance?: HlsType; private _useExoPlayer = false; @@ -107,8 +106,8 @@ class HaHLSPlayer extends LitElement { const useExoPlayerPromise = this._getUseExoPlayer(); const masterPlaylistPromise = fetch(this.url); - const hls = ((await import("hls.js")) as any).default as HLSModule; - let hlsSupported = hls.isSupported(); + const Hls = (await import("hls.js")).default; + let hlsSupported = Hls.isSupported(); if (!hlsSupported) { hlsSupported = @@ -144,8 +143,8 @@ class HaHLSPlayer extends LitElement { // If codec is HEVC and ExoPlayer is supported, use ExoPlayer. if (this._useExoPlayer && match !== null && match[1] !== undefined) { this._renderHLSExoPlayer(playlist_url); - } else if (hls.isSupported()) { - this._renderHLSPolyfill(videoEl, hls, playlist_url); + } else if (Hls.isSupported()) { + this._renderHLSPolyfill(videoEl, Hls, playlist_url); } else { this._renderHLSNative(videoEl, playlist_url); } @@ -182,7 +181,7 @@ class HaHLSPlayer extends LitElement { private async _renderHLSPolyfill( videoEl: HTMLVideoElement, - Hls: HLSModule, + Hls: typeof HlsType, url: string ) { const hls = new Hls({ diff --git a/src/components/ha-selector/ha-selector-time.ts b/src/components/ha-selector/ha-selector-time.ts index f573773868..8fb0d530c0 100644 --- a/src/components/ha-selector/ha-selector-time.ts +++ b/src/components/ha-selector/ha-selector-time.ts @@ -1,12 +1,9 @@ import { customElement, html, LitElement, property } from "lit-element"; +import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; import { TimeSelector } from "../../data/selector"; import { HomeAssistant } from "../../types"; import "../paper-time-input"; - -const test = new Date().toLocaleString(); -const useAMPM = test.includes("AM") || test.includes("PM"); - @customElement("ha-selector-time") export class HaTimeSelector extends LitElement { @property() public hass!: HomeAssistant; @@ -19,16 +16,24 @@ export class HaTimeSelector extends LitElement { @property({ type: Boolean }) public disabled = false; + private _useAmPm = memoizeOne((language: string) => { + const test = new Date().toLocaleString(language); + return test.includes("AM") || test.includes("PM"); + }); + protected render() { + const useAMPM = this._useAmPm(this.hass.locale.language); + const parts = this.value?.split(":") || []; - const hours = useAMPM ? parts[0] ?? "12" : parts[0] ?? "0"; + const hours = parts[0]; return html` 12 ? Number(hours) - 12 : hours} - .min=${parts[1] ?? "00"} - .sec=${parts[2] ?? "00"} + .hour=${hours && + (useAMPM && Number(hours) > 12 ? Number(hours) - 12 : hours)} + .min=${parts[1]} + .sec=${parts[2]} .format=${useAMPM ? 12 : 24} .amPm=${useAMPM && (Number(hours) > 12 ? "PM" : "AM")} .disabled=${this.disabled} @@ -42,12 +47,16 @@ export class HaTimeSelector extends LitElement { private _timeChanged(ev) { let value = ev.target.value; - if (useAMPM) { - let hours = Number(ev.target.hour); + const useAMPM = this._useAmPm(this.hass.locale.language); + let hours = Number(ev.target.hour || 0); + if (value && useAMPM) { if (ev.target.amPm === "PM") { hours += 12; } - value = `${hours}:${ev.target.min}:${ev.target.sec}`; + value = `${hours}:${ev.target.min || "00"}:${ev.target.sec || "00"}`; + } + if (value === this.value) { + return; } fireEvent(this, "value-changed", { value, diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index 0d72abc7ee..66152e4f8a 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -1,3 +1,4 @@ +import { mdiHelpCircle } from "@mdi/js"; import { HassService, HassServiceTarget } from "home-assistant-js-websocket"; import { css, @@ -18,11 +19,12 @@ import { ENTITY_COMPONENT_DOMAINS } from "../data/entity"; import { Selector } from "../data/selector"; import { PolymerChangedEvent } from "../polymer-types"; import { HomeAssistant } from "../types"; +import { documentationUrl } from "../util/documentation-url"; +import "./ha-checkbox"; import "./ha-selector/ha-selector"; import "./ha-service-picker"; import "./ha-settings-row"; import "./ha-yaml-editor"; -import "./ha-checkbox"; import type { HaYamlEditor } from "./ha-yaml-editor"; interface ExtHassService extends Omit { @@ -49,6 +51,8 @@ export class HaServiceControl extends LitElement { data?: Record; }; + @internalProperty() private _value!: this["value"]; + @property({ reflect: true, type: Boolean }) public narrow!: boolean; @property({ type: Boolean }) public showAdvanced?: boolean; @@ -57,7 +61,7 @@ export class HaServiceControl extends LitElement { @query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor; - protected updated(changedProperties: PropertyValues) { + protected updated(changedProperties: PropertyValues) { if (!changedProperties.has("value")) { return; } @@ -92,21 +96,23 @@ export class HaServiceControl extends LitElement { target.device_id = this.value.data.device_id; } - this.value = { + this._value = { ...this.value, target, data: { ...this.value.data }, }; - delete this.value.data!.entity_id; - delete this.value.data!.device_id; - delete this.value.data!.area_id; + delete this._value.data!.entity_id; + delete this._value.data!.device_id; + delete this._value.data!.area_id; + } else { + this._value = this.value; } - if (this.value?.data) { + if (this._value?.data) { const yamlEditor = this._yamlEditor; - if (yamlEditor && yamlEditor.value !== this.value.data) { - yamlEditor.setValue(this.value.data); + if (yamlEditor && yamlEditor.value !== this._value.data) { + yamlEditor.setValue(this._value.data); } } } @@ -151,12 +157,12 @@ export class HaServiceControl extends LitElement { }); protected render() { - const serviceData = this._getServiceInfo(this.value?.service); + const serviceData = this._getServiceInfo(this._value?.service); const shouldRenderServiceDataYaml = (serviceData?.fields.length && !serviceData.hasSelector.length) || (serviceData && - Object.keys(this.value?.data || {}).some( + Object.keys(this._value?.data || {}).some( (key) => !serviceData!.hasSelector.includes(key) )); @@ -171,10 +177,32 @@ export class HaServiceControl extends LitElement { return html` -

    ${serviceData?.description}

    +
    +

    ${serviceData?.description}

    + ${this.value?.service + ? html` + + + + ` + : ""} +
    ${serviceData && "target" in serviceData ? html` ${hasOptional @@ -195,19 +223,19 @@ export class HaServiceControl extends LitElement { ? { target: serviceData.target } : { target: { - entity: { domain: computeDomain(this.value!.service) }, + entity: { domain: computeDomain(this._value!.service) }, }, }} @value-changed=${this._targetChanged} - .value=${this.value?.target} + .value=${this._value?.target} >` : entityId ? html`` @@ -218,15 +246,15 @@ export class HaServiceControl extends LitElement { "ui.components.service-control.service_data" )} .name=${"data"} - .defaultValue=${this.value?.data} + .defaultValue=${this._value?.data} @value-changed=${this._dataChanged} >` : serviceData?.fields.map((dataField) => dataField.selector && (!dataField.advanced || this.showAdvanced || - (this.value?.data && - this.value.data[dataField.key] !== undefined)) + (this._value?.data && + this._value.data[dataField.key] !== undefined)) ? html` ${dataField.required ? hasOptional @@ -235,8 +263,8 @@ export class HaServiceControl extends LitElement { : html``} @@ -245,15 +273,15 @@ export class HaServiceControl extends LitElement { >` @@ -268,13 +296,13 @@ export class HaServiceControl extends LitElement { this._checkedKeys.add(key); } else { this._checkedKeys.delete(key); - const data = { ...this.value?.data }; + const data = { ...this._value?.data }; delete data[key]; fireEvent(this, "value-changed", { value: { - ...this.value, + ...this._value, data, }, }); @@ -284,7 +312,7 @@ export class HaServiceControl extends LitElement { private _serviceChanged(ev: PolymerChangedEvent) { ev.stopPropagation(); - if (ev.detail.value === this.value?.service) { + if (ev.detail.value === this._value?.service) { return; } fireEvent(this, "value-changed", { @@ -295,17 +323,17 @@ export class HaServiceControl extends LitElement { private _entityPicked(ev: CustomEvent) { ev.stopPropagation(); const newValue = ev.detail.value; - if (this.value?.data?.entity_id === newValue) { + if (this._value?.data?.entity_id === newValue) { return; } let value; - if (!newValue && this.value?.data) { - value = { ...this.value }; + if (!newValue && this._value?.data) { + value = { ...this._value }; delete value.data.entity_id; } else { value = { - ...this.value, - data: { ...this.value?.data, entity_id: ev.detail.value }, + ...this._value, + data: { ...this._value?.data, entity_id: ev.detail.value }, }; } fireEvent(this, "value-changed", { @@ -316,15 +344,15 @@ export class HaServiceControl extends LitElement { private _targetChanged(ev: CustomEvent) { ev.stopPropagation(); const newValue = ev.detail.value; - if (this.value?.target === newValue) { + if (this._value?.target === newValue) { return; } let value; if (!newValue) { - value = { ...this.value }; + value = { ...this._value }; delete value.target; } else { - value = { ...this.value, target: ev.detail.value }; + value = { ...this._value, target: ev.detail.value }; } fireEvent(this, "value-changed", { value, @@ -336,13 +364,13 @@ export class HaServiceControl extends LitElement { const key = (ev.currentTarget as any).key; const value = ev.detail.value; if ( - this.value?.data?.[key] === value || - (!this.value?.data?.[key] && (value === "" || value === undefined)) + this._value?.data?.[key] === value || + (!this._value?.data?.[key] && (value === "" || value === undefined)) ) { return; } - const data = { ...this.value?.data, [key]: value }; + const data = { ...this._value?.data, [key]: value }; if (value === "" || value === undefined) { delete data[key]; @@ -350,7 +378,7 @@ export class HaServiceControl extends LitElement { fireEvent(this, "value-changed", { value: { - ...this.value, + ...this._value, data, }, }); @@ -363,7 +391,7 @@ export class HaServiceControl extends LitElement { } fireEvent(this, "value-changed", { value: { - ...this.value, + ...this._value, data: ev.detail.value, }, }); @@ -406,6 +434,15 @@ export class HaServiceControl extends LitElement { ha-checkbox { margin-left: -16px; } + .help-icon { + color: var(--secondary-text-color); + } + .description { + justify-content: space-between; + display: flex; + align-items: center; + padding-right: 2px; + } `; } } diff --git a/src/components/paper-time-input.js b/src/components/paper-time-input.js index c057b3dc36..bc4f442ba7 100644 --- a/src/components/paper-time-input.js +++ b/src/components/paper-time-input.js @@ -133,7 +133,7 @@ export class PaperTimeInput extends PolymerElement { always-float-label$="[[alwaysFloatInputLabels]]" disabled="[[disabled]]" > - : + : @@ -303,28 +303,28 @@ export class PaperTimeInput extends PolymerElement { notify: true, }, /** - * Suffix for the hour input + * Label for the hour input */ hourLabel: { type: String, value: "", }, /** - * Suffix for the min input + * Label for the min input */ minLabel: { type: String, - value: ":", + value: "", }, /** - * Suffix for the sec input + * Label for the sec input */ secLabel: { type: String, value: "", }, /** - * Suffix for the milli sec input + * Label for the milli sec input */ millisecLabel: { type: String, diff --git a/src/components/trace/hat-trace-timeline.ts b/src/components/trace/hat-trace-timeline.ts index a17cb3c81e..49898be86f 100644 --- a/src/components/trace/hat-trace-timeline.ts +++ b/src/components/trace/hat-trace-timeline.ts @@ -314,16 +314,18 @@ class ActionRenderer { if (defaultExecuted) { this._renderEntry(choosePath, `${name}: Default action executed`); - } else { + } else if (chooseTrace.result) { const choiceConfig = this._getDataFromPath( - `${this.keys[index]}/choose/${chooseTrace.result?.choice}` + `${this.keys[index]}/choose/${chooseTrace.result.choice}` ) as ChooseActionChoice | undefined; const choiceName = choiceConfig ? `${ - choiceConfig.alias || `Choice ${chooseTrace.result?.choice}` + choiceConfig.alias || `Choice ${chooseTrace.result.choice}` } executed` : `Error: ${chooseTrace.error}`; this._renderEntry(choosePath, `${name}: ${choiceName}`); + } else { + this._renderEntry(choosePath, `${name}: No action taken`); } let i; diff --git a/src/data/bootstrap_integrations.ts b/src/data/bootstrap_integrations.ts new file mode 100644 index 0000000000..864cc2efef --- /dev/null +++ b/src/data/bootstrap_integrations.ts @@ -0,0 +1,16 @@ +import { HomeAssistant } from "../types"; + +export type BootstrapIntegrationsTimings = { [key: string]: number }; + +export const subscribeBootstrapIntegrations = ( + hass: HomeAssistant, + callback: (message: BootstrapIntegrationsTimings) => void +) => { + const unsubProm = hass.connection.subscribeMessage< + BootstrapIntegrationsTimings + >((message) => callback(message), { + type: "subscribe_bootstrap_integrations", + }); + + return unsubProm; +}; diff --git a/src/data/config_entries.ts b/src/data/config_entries.ts index d75de78536..9bb203c942 100644 --- a/src/data/config_entries.ts +++ b/src/data/config_entries.ts @@ -5,11 +5,18 @@ export interface ConfigEntry { domain: string; title: string; source: string; - state: string; + state: + | "loaded" + | "setup_error" + | "migration_error" + | "setup_retry" + | "not_loaded" + | "failed_unload"; connection_class: string; supports_options: boolean; supports_unload: boolean; - disabled_by: string | null; + disabled_by: "user" | null; + reason: string | null; } export interface ConfigEntryMutableParams { diff --git a/src/data/data_entry_flow.ts b/src/data/data_entry_flow.ts index d1447b1c74..5a7d8088a8 100644 --- a/src/data/data_entry_flow.ts +++ b/src/data/data_entry_flow.ts @@ -28,6 +28,7 @@ export interface DataEntryFlowStepForm { data_schema: HaFormSchema[]; errors: Record; description_placeholders: Record; + last_step: boolean | null; } export interface DataEntryFlowStepExternal { diff --git a/src/data/device_registry.ts b/src/data/device_registry.ts index 8fc1807586..d2d119d300 100644 --- a/src/data/device_registry.ts +++ b/src/data/device_registry.ts @@ -9,13 +9,13 @@ export interface DeviceRegistryEntry { config_entries: string[]; connections: Array<[string, string]>; identifiers: Array<[string, string]>; - manufacturer: string; - model?: string; - name?: string; - sw_version?: string; - via_device_id?: string; - area_id?: string; - name_by_user?: string; + manufacturer: string | null; + model: string | null; + name: string | null; + sw_version: string | null; + via_device_id: string | null; + area_id: string | null; + name_by_user: string | null; entry_type: "service" | null; disabled_by: string | null; } diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts index 81796ff822..0bf6a5b253 100644 --- a/src/data/entity_registry.ts +++ b/src/data/entity_registry.ts @@ -5,12 +5,12 @@ import { HomeAssistant } from "../types"; export interface EntityRegistryEntry { entity_id: string; - name: string; - icon?: string; + name: string | null; + icon: string | null; platform: string; - config_entry_id?: string; - device_id?: string; - area_id?: string; + config_entry_id: string | null; + device_id: string | null; + area_id: string | null; disabled_by: string | null; } diff --git a/src/data/integration.ts b/src/data/integration.ts index d8caf1b591..f69a763a7c 100644 --- a/src/data/integration.ts +++ b/src/data/integration.ts @@ -15,7 +15,18 @@ export interface IntegrationManifest { ssdp?: Array<{ manufacturer?: string; modelName?: string; st?: string }>; zeroconf?: string[]; homekit?: { models: string[] }; - quality_scale?: string; + quality_scale?: "gold" | "internal" | "platinum" | "silver"; + iot_class: + | "assumed_state" + | "cloud_polling" + | "cloud_push" + | "local_polling" + | "local_push"; +} + +export interface IntegrationSetup { + domain: string; + seconds?: number; } export const integrationIssuesUrl = ( @@ -38,3 +49,6 @@ export const fetchIntegrationManifest = ( hass: HomeAssistant, integration: string ) => hass.callWS({ type: "manifest/get", integration }); + +export const fetchIntegrationSetups = (hass: HomeAssistant) => + hass.callWS({ type: "integration/setup_info" }); diff --git a/src/data/light.ts b/src/data/light.ts index ff0070e7d0..27f335709e 100644 --- a/src/data/light.ts +++ b/src/data/light.ts @@ -3,26 +3,82 @@ import { HassEntityBase, } from "home-assistant-js-websocket"; +export enum LightColorModes { + UNKNOWN = "unknown", + ONOFF = "onoff", + BRIGHTNESS = "brightness", + COLOR_TEMP = "color_temp", + HS = "hs", + XY = "xy", + RGB = "rgb", + RGBW = "rgbw", + RGBWW = "rgbww", +} + +const modesSupportingColor = [ + LightColorModes.HS, + LightColorModes.XY, + LightColorModes.RGB, + LightColorModes.RGBW, + LightColorModes.RGBWW, +]; + +const modesSupportingDimming = [ + ...modesSupportingColor, + LightColorModes.COLOR_TEMP, + LightColorModes.BRIGHTNESS, +]; + +export const SUPPORT_EFFECT = 4; +export const SUPPORT_FLASH = 8; +export const SUPPORT_TRANSITION = 32; + +export const lightSupportsColorMode = ( + entity: LightEntity, + mode: LightColorModes +) => { + return entity.attributes.supported_color_modes?.includes(mode); +}; + +export const lightIsInColorMode = (entity: LightEntity) => { + return modesSupportingColor.includes(entity.attributes.color_mode); +}; + +export const lightSupportsColor = (entity: LightEntity) => { + return entity.attributes.supported_color_modes?.some((mode) => + modesSupportingColor.includes(mode) + ); +}; + +export const lightSupportsDimming = (entity: LightEntity) => { + return entity.attributes.supported_color_modes?.some((mode) => + modesSupportingDimming.includes(mode) + ); +}; + +export const getLightRgbColor = (entity: LightEntity): number[] | undefined => + entity.attributes.color_mode === LightColorModes.RGBWW + ? entity.attributes.rgbww_color + : entity.attributes.color_mode === LightColorModes.RGBW + ? entity.attributes.rgbw_color + : entity.attributes.rgb_color; + interface LightEntityAttributes extends HassEntityAttributeBase { min_mireds: number; max_mireds: number; friendly_name: string; brightness: number; - hs_color: number[]; + hs_color: [number, number]; + rgb_color: [number, number, number]; + rgbw_color: [number, number, number, number]; + rgbww_color: [number, number, number, number, number]; color_temp: number; - white_value: number; effect?: string; effect_list: string[] | null; + supported_color_modes: LightColorModes[]; + color_mode: LightColorModes; } export interface LightEntity extends HassEntityBase { attributes: LightEntityAttributes; } - -export const SUPPORT_BRIGHTNESS = 1; -export const SUPPORT_COLOR_TEMP = 2; -export const SUPPORT_EFFECT = 4; -export const SUPPORT_FLASH = 8; -export const SUPPORT_COLOR = 16; -export const SUPPORT_TRANSITION = 32; -export const SUPPORT_WHITE_VALUE = 128; diff --git a/src/data/lovelace.ts b/src/data/lovelace.ts index 89611b44ef..06c1294053 100644 --- a/src/data/lovelace.ts +++ b/src/data/lovelace.ts @@ -19,6 +19,10 @@ export interface LovelacePanelConfig { export interface LovelaceConfig { title?: string; + strategy?: { + type: string; + options?: Record; + }; views: LovelaceViewConfig[]; background?: string; } @@ -77,6 +81,10 @@ export interface LovelaceViewConfig { index?: number; title?: string; type?: string; + strategy?: { + type: string; + options?: Record; + }; badges?: Array; cards?: LovelaceCardConfig[]; path?: string; @@ -94,6 +102,7 @@ export interface LovelaceViewElement extends HTMLElement { index?: number; cards?: Array; badges?: LovelaceBadge[]; + isStrategy: boolean; setConfig(config: LovelaceViewConfig): void; } diff --git a/src/data/media-player.ts b/src/data/media-player.ts index 95b5156c33..143b7e1bf0 100644 --- a/src/data/media-player.ts +++ b/src/data/media-player.ts @@ -292,9 +292,11 @@ export const computeMediaControls = ( ? "hass:pause" : "hass:stop", action: - state === "playing" && !supportsFeature(stateObj, SUPPORT_PAUSE) - ? "media_stop" - : "media_play_pause", + state !== "playing" + ? "media_play" + : supportsFeature(stateObj, SUPPORT_PAUSE) + ? "media_pause" + : "media_stop", }); } diff --git a/src/data/service.ts b/src/data/service.ts index 120d3096c9..5e83fd6741 100644 --- a/src/data/service.ts +++ b/src/data/service.ts @@ -6,3 +6,6 @@ export const callExecuteScript = (hass: HomeAssistant, sequence: Action[]) => type: "execute_script", sequence, }); + +export const serviceCallWillDisconnect = (domain: string, service: string) => + domain === "homeassistant" && ["restart", "stop"].includes(service); diff --git a/src/data/system_log.ts b/src/data/system_log.ts index 865558ebb9..f2acb5f82b 100644 --- a/src/data/system_log.ts +++ b/src/data/system_log.ts @@ -16,9 +16,27 @@ export interface LoggedError { export const fetchSystemLog = (hass: HomeAssistant) => hass.callApi("GET", "error/all"); -export const getLoggedErrorIntegration = (item: LoggedError) => - item.name.startsWith("homeassistant.components.") - ? item.name.split(".")[2] - : item.name.startsWith("custom_components.") - ? item.name.split(".")[1] - : undefined; +export const getLoggedErrorIntegration = (item: LoggedError) => { + // Try to derive from logger name + if (item.name.startsWith("homeassistant.components.")) { + return item.name.split(".")[2]; + } + if (item.name.startsWith("custom_components.")) { + return item.name.split(".")[1]; + } + + // Try to derive from logged location + if (item.source[0].startsWith("custom_components/")) { + return item.source[0].split("/")[1]; + } + + if (item.source[0].startsWith("homeassistant/components/")) { + return item.source[0].split("/")[2]; + } + + return undefined; +}; + +export const isCustomIntegrationError = (item: LoggedError) => + item.name.startsWith("custom_components.") || + item.source[0].startsWith("custom_components/"); diff --git a/src/data/zha.ts b/src/data/zha.ts index e57df4078a..5913cf4073 100644 --- a/src/data/zha.ts +++ b/src/data/zha.ts @@ -1,4 +1,5 @@ import { HassEntity } from "home-assistant-js-websocket"; +import { HaFormSchema } from "../components/ha-form/ha-form"; import { HomeAssistant } from "../types"; export interface ZHAEntityReference extends HassEntity { @@ -54,6 +55,52 @@ export interface Cluster { type: string; } +export interface ClusterConfigurationData { + cluster_name: string; + cluster_id: number; + success: boolean; +} + +export interface ClusterAttributeData { + cluster_name: string; + cluster_id: number; + attributes: AttributeConfigurationStatus[]; +} + +export interface AttributeConfigurationStatus { + id: number; + name: string; + success: boolean | undefined; + min: number; + max: number; + change: number; +} + +export interface ClusterConfigurationStatus { + cluster: Cluster; + bindSuccess: boolean | undefined; + attributes: Map; +} + +interface ClusterConfigurationBindEvent { + type: "zha_channel_bind"; + zha_channel_msg_data: ClusterConfigurationData; +} + +interface ClusterConfigurationReportConfigurationEvent { + type: "zha_channel_configure_reporting"; + zha_channel_msg_data: ClusterAttributeData; +} + +interface ClusterConfigurationEventFinish { + type: "zha_channel_cfg_done"; +} + +export type ClusterConfigurationEvent = + | ClusterConfigurationReportConfigurationEvent + | ClusterConfigurationBindEvent + | ClusterConfigurationEventFinish; + export interface Command { name: string; id: number; @@ -75,6 +122,11 @@ export interface ZHAGroup { members: ZHADeviceEndpoint[]; } +export interface ZHAConfiguration { + data: Record>; + schemas: Record; +} + export interface ZHAGroupMember { ieee: string; endpoint_id: string; @@ -83,10 +135,10 @@ export interface ZHAGroupMember { export const reconfigureNode = ( hass: HomeAssistant, ieeeAddress: string, - callbackFunction: any + callbackFunction: (message: ClusterConfigurationEvent) => void ) => { return hass.connection.subscribeMessage( - (message) => callbackFunction(message), + (message: ClusterConfigurationEvent) => callbackFunction(message), { type: "zha/devices/reconfigure", ieee: ieeeAddress, @@ -282,6 +334,22 @@ export const addGroup = ( members: membersToAdd, }); +export const fetchZHAConfiguration = ( + hass: HomeAssistant +): Promise => + hass.callWS({ + type: "zha/configuration", + }); + +export const updateZHAConfiguration = ( + hass: HomeAssistant, + data: any +): Promise => + hass.callWS({ + type: "zha/configuration/update", + data: data, + }); + export const INITIALIZED = "INITIALIZED"; export const INTERVIEW_COMPLETE = "INTERVIEW_COMPLETE"; export const CONFIGURED = "CONFIGURED"; @@ -301,3 +369,7 @@ export const DEVICE_MESSAGE_TYPES = [ DEVICE_FULLY_INITIALIZED, ]; export const LOG_OUTPUT = "log_output"; +export const ZHA_CHANNEL_MSG = "zha_channel_message"; +export const ZHA_CHANNEL_MSG_BIND = "zha_channel_bind"; +export const ZHA_CHANNEL_MSG_CFG_RPT = "zha_channel_configure_reporting"; +export const ZHA_CHANNEL_CFG_DONE = "zha_channel_cfg_done"; diff --git a/src/data/zwave_js.ts b/src/data/zwave_js.ts index d6db70bd21..af5f30436e 100644 --- a/src/data/zwave_js.ts +++ b/src/data/zwave_js.ts @@ -1,3 +1,4 @@ +import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { HomeAssistant } from "../types"; import { DeviceRegistryEntry } from "./device_registry"; @@ -29,6 +30,10 @@ export interface ZWaveJSNode { } export interface ZWaveJSNodeConfigParams { + [key: string]: ZWaveJSNodeConfigParam; +} + +export interface ZWaveJSNodeConfigParam { property: number; value: any; configuration_value_type: string; @@ -56,6 +61,22 @@ export interface ZWaveJSSetConfigParamData { value: string | number; } +export interface ZWaveJSSetConfigParamResult { + value_id?: string; + status?: string; + error?: string; +} + +export interface ZWaveJSDataCollectionStatus { + enabled: boolean; + opted_in: boolean; +} + +export interface ZWaveJSRefreshNodeStatusMessage { + event: string; + stage?: string; +} + export enum NodeStatus { Unknown, Asleep, @@ -75,6 +96,26 @@ export const fetchNetworkStatus = ( entry_id, }); +export const fetchDataCollectionStatus = ( + hass: HomeAssistant, + entry_id: string +): Promise => + hass.callWS({ + type: "zwave_js/data_collection_status", + entry_id, + }); + +export const setDataCollectionPreference = ( + hass: HomeAssistant, + entry_id: string, + opted_in: boolean +): Promise => + hass.callWS({ + type: "zwave_js/update_data_collection_preference", + entry_id, + opted_in, + }); + export const fetchNodeStatus = ( hass: HomeAssistant, entry_id: string, @@ -90,7 +131,7 @@ export const fetchNodeConfigParameters = ( hass: HomeAssistant, entry_id: string, node_id: number -): Promise => +): Promise => hass.callWS({ type: "zwave_js/get_config_parameters", entry_id, @@ -104,7 +145,7 @@ export const setNodeConfigParameter = ( property: number, value: number, property_key?: number -): Promise => { +): Promise => { const data: ZWaveJSSetConfigParamData = { type: "zwave_js/set_config_parameter", entry_id, @@ -116,9 +157,25 @@ export const setNodeConfigParameter = ( return hass.callWS(data); }; -export const getIdentifiersFromDevice = function ( +export const reinterviewNode = ( + hass: HomeAssistant, + entry_id: string, + node_id: number, + callbackFunction: (message: ZWaveJSRefreshNodeStatusMessage) => void +): Promise => { + return hass.connection.subscribeMessage( + (message: any) => callbackFunction(message), + { + type: "zwave_js/refresh_node_info", + entry_id: entry_id, + node_id: node_id, + } + ); +}; + +export const getIdentifiersFromDevice = ( device: DeviceRegistryEntry -): ZWaveJSNodeIdentifiers | undefined { +): ZWaveJSNodeIdentifiers | undefined => { if (!device) { return undefined; } @@ -136,3 +193,48 @@ export const getIdentifiersFromDevice = function ( home_id: identifiers[0], }; }; + +export interface ZWaveJSLogMessage { + timestamp: string; + level: string; + primary_tags: string; + message: string | string[]; +} + +export const subscribeZWaveJSLogs = ( + hass: HomeAssistant, + entry_id: string, + callback: (message: ZWaveJSLogMessage) => void +) => + hass.connection.subscribeMessage(callback, { + type: "zwave_js/subscribe_logs", + entry_id, + }); + +export interface ZWaveJSLogConfig { + level: string; + enabled: boolean; + filename: string; + log_to_file: boolean; + force_console: boolean; +} + +export const fetchZWaveJSLogConfig = ( + hass: HomeAssistant, + entry_id: string +): Promise => + hass.callWS({ + type: "zwave_js/get_log_config", + entry_id, + }); + +export const setZWaveJSLogLevel = ( + hass: HomeAssistant, + entry_id: string, + level: string +): Promise => + hass.callWS({ + type: "zwave_js/update_log_config", + entry_id, + config: { level }, + }); diff --git a/src/dialogs/make-dialog-manager.ts b/src/dialogs/make-dialog-manager.ts index a7c496d960..bc32b08050 100644 --- a/src/dialogs/make-dialog-manager.ts +++ b/src/dialogs/make-dialog-manager.ts @@ -45,7 +45,8 @@ export const showDialog = async ( root: ShadowRoot | HTMLElement, dialogTag: string, dialogParams: unknown, - dialogImport?: () => Promise + dialogImport?: () => Promise, + addHistory = true ) => { if (!(dialogTag in LOADED)) { if (!dialogImport) { @@ -59,36 +60,37 @@ export const showDialog = async ( }); } - history.replaceState( - { - dialog: dialogTag, - open: false, - oldState: - history.state?.open && history.state?.dialog !== dialogTag - ? history.state - : null, - }, - "" - ); - try { - history.pushState( - { dialog: dialogTag, dialogParams: dialogParams, open: true }, - "" - ); - } catch (err) { - // dialogParams could not be cloned, probably contains callback - history.pushState( - { dialog: dialogTag, dialogParams: null, open: true }, + if (addHistory) { + top.history.replaceState( + { + dialog: dialogTag, + open: false, + oldState: + top.history.state?.open && top.history.state?.dialog !== dialogTag + ? top.history.state + : null, + }, "" ); + try { + top.history.pushState( + { dialog: dialogTag, dialogParams: dialogParams, open: true }, + "" + ); + } catch (err) { + // dialogParams could not be cloned, probably contains callback + top.history.pushState( + { dialog: dialogTag, dialogParams: null, open: true }, + "" + ); + } } - const dialogElement = await LOADED[dialogTag]; dialogElement.showDialog(dialogParams); }; export const replaceDialog = () => { - history.replaceState({ ...history.state, replaced: true }, ""); + top.history.replaceState({ ...top.history.state, replaced: true }, ""); }; export const closeDialog = async (dialogTag: string): Promise => { diff --git a/src/dialogs/more-info/controls/more-info-light.ts b/src/dialogs/more-info/controls/more-info-light.ts index 2ac9af776d..7bb67a8b77 100644 --- a/src/dialogs/more-info/controls/more-info-light.ts +++ b/src/dialogs/more-info/controls/more-info-light.ts @@ -11,7 +11,6 @@ import { PropertyValues, TemplateResult, } from "lit-element"; -import { classMap } from "lit-html/directives/class-map"; import { supportsFeature } from "../../../common/entity/supports-feature"; import "../../../components/ha-attributes"; import "../../../components/ha-color-picker"; @@ -19,20 +18,22 @@ import "../../../components/ha-icon-button"; import "../../../components/ha-labeled-slider"; import "../../../components/ha-paper-dropdown-menu"; import { + getLightRgbColor, + LightColorModes, LightEntity, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, + lightIsInColorMode, + lightSupportsColor, + lightSupportsColorMode, + lightSupportsDimming, SUPPORT_EFFECT, - SUPPORT_WHITE_VALUE, } from "../../../data/light"; import type { HomeAssistant } from "../../../types"; +import "../../../components/ha-button-toggle-group"; -interface HueSatColor { - h: number; - s: number; -} - +const toggleButtons = [ + { label: "Color", value: "color" }, + { label: "Temperature", value: LightColorModes.COLOR_TEMP }, +]; @customElement("more-info-light") class MoreInfoLight extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -41,28 +42,51 @@ class MoreInfoLight extends LitElement { @internalProperty() private _brightnessSliderValue = 0; - @internalProperty() private _ctSliderValue = 0; + @internalProperty() private _ctSliderValue?: number; - @internalProperty() private _wvSliderValue = 0; + @internalProperty() private _cwSliderValue?: number; + + @internalProperty() private _wwSliderValue?: number; + + @internalProperty() private _wvSliderValue?: number; + + @internalProperty() private _colorBrightnessSliderValue?: number; + + @internalProperty() private _brightnessAdjusted?: number; @internalProperty() private _hueSegments = 24; @internalProperty() private _saturationSegments = 8; - @internalProperty() private _colorPickerColor?: HueSatColor; + @internalProperty() private _colorPickerColor?: [number, number, number]; + + @internalProperty() private _mode?: "color" | LightColorModes.COLOR_TEMP; protected render(): TemplateResult { if (!this.hass || !this.stateObj) { return html``; } + const supportsTemp = lightSupportsColorMode( + this.stateObj, + LightColorModes.COLOR_TEMP + ); + + const supportsRgbww = lightSupportsColorMode( + this.stateObj, + LightColorModes.RGBWW + ); + + const supportsRgbw = + !supportsRgbww && + lightSupportsColorMode(this.stateObj, LightColorModes.RGBW); + + const supportsColor = + supportsRgbww || supportsRgbw || lightSupportsColor(this.stateObj); + return html` -
    - ${supportsFeature(this.stateObj!, SUPPORT_BRIGHTNESS) +
    + ${lightSupportsDimming(this.stateObj) ? html` ` : ""} + ${supportsTemp && supportsColor + ? html`` + : ""} + ${supportsTemp && + (!supportsColor || this._mode === LightColorModes.COLOR_TEMP) ? html` +
    ` : ""} - ${supportsFeature(this.stateObj, SUPPORT_WHITE_VALUE) - ? html` - - ` - : ""} - ${supportsFeature(this.stateObj, SUPPORT_COLOR) + ${supportsColor && (!supportsTemp || this._mode === "color") ? html`
    + + ${ + supportsRgbw || supportsRgbww + ? html`` + : "" + } + ${ + supportsRgbw + ? html` + + ` + : "" + } + ${ + supportsRgbww + ? html` + + + ` + : "" + } +
    ` : ""} ${supportsFeature(this.stateObj, SUPPORT_EFFECT) && @@ -151,34 +235,85 @@ class MoreInfoLight extends LitElement { : ""}
    `; } - protected updated(changedProps: PropertyValues): void { + protected updated(changedProps: PropertyValues) { + if (!changedProps.has("stateObj")) { + return; + } const stateObj = this.stateObj! as LightEntity; - if (changedProps.has("stateObj")) { - if (stateObj.state === "on") { - this._brightnessSliderValue = Math.round( - (stateObj.attributes.brightness * 100) / 255 - ); - this._ctSliderValue = stateObj.attributes.color_temp; - this._wvSliderValue = stateObj.attributes.white_value; + const oldStateObj = changedProps.get("stateObj") as LightEntity | undefined; - if (stateObj.attributes.hs_color) { - this._colorPickerColor = { - h: stateObj.attributes.hs_color[0], - s: stateObj.attributes.hs_color[1] / 100, - }; + if (stateObj.state === "on") { + // Don't change tab when the color mode changes + if ( + oldStateObj?.entity_id !== stateObj.entity_id || + oldStateObj?.state !== stateObj.state + ) { + this._mode = lightIsInColorMode(this.stateObj!) + ? "color" + : LightColorModes.COLOR_TEMP; + } + + let brightnessAdjust = 100; + if ( + stateObj.attributes.color_mode === LightColorModes.RGB && + !lightSupportsColorMode(stateObj, LightColorModes.RGBWW) && + !lightSupportsColorMode(stateObj, LightColorModes.RGBW) + ) { + const maxVal = Math.max(...stateObj.attributes.rgb_color); + if (maxVal < 255) { + this._brightnessAdjusted = maxVal; + brightnessAdjust = (this._brightnessAdjusted / 255) * 100; } } else { - this._brightnessSliderValue = 0; + this._brightnessAdjusted = undefined; } + this._brightnessSliderValue = Math.round( + (stateObj.attributes.brightness * brightnessAdjust) / 255 + ); + this._ctSliderValue = stateObj.attributes.color_temp; + this._wvSliderValue = + stateObj.attributes.color_mode === LightColorModes.RGBW + ? Math.round((stateObj.attributes.rgbw_color[3] * 100) / 255) + : undefined; + this._cwSliderValue = + stateObj.attributes.color_mode === LightColorModes.RGBWW + ? Math.round((stateObj.attributes.rgbww_color[3] * 100) / 255) + : undefined; + this._wwSliderValue = + stateObj.attributes.color_mode === LightColorModes.RGBWW + ? Math.round((stateObj.attributes.rgbww_color[4] * 100) / 255) + : undefined; + this._colorBrightnessSliderValue = + stateObj.attributes.color_mode === LightColorModes.RGBWW + ? Math.round( + (Math.max(...stateObj.attributes.rgbww_color.slice(0, 3)) * 100) / + 255 + ) + : stateObj.attributes.color_mode === LightColorModes.RGBW + ? Math.round( + (Math.max(...stateObj.attributes.rgbw_color.slice(0, 3)) * 100) / + 255 + ) + : undefined; + + this._colorPickerColor = getLightRgbColor(stateObj)?.slice(0, 3) as + | [number, number, number] + | undefined; + } else { + this._brightnessSliderValue = 0; } } + private _modeChanged(ev: CustomEvent) { + this._mode = ev.detail.value; + } + private _effectChanged(ev: CustomEvent) { const newVal = ev.detail.item.itemName; @@ -193,12 +328,29 @@ class MoreInfoLight extends LitElement { } private _brightnessSliderChanged(ev: CustomEvent) { - const bri = parseInt((ev.target as any).value, 10); + const bri = Number((ev.target as any).value); if (isNaN(bri)) { return; } + if (this._brightnessAdjusted) { + const rgb = + this.stateObj!.attributes.rgb_color || + ([0, 0, 0] as [number, number, number]); + + this.hass.callService("light", "turn_on", { + entity_id: this.stateObj!.entity_id, + brightness_pct: bri, + rgb_color: this._adjustColorBrightness( + rgb, + this._brightnessAdjusted, + true + ), + }); + return; + } + this.hass.callService("light", "turn_on", { entity_id: this.stateObj!.entity_id, brightness_pct: bri, @@ -206,7 +358,7 @@ class MoreInfoLight extends LitElement { } private _ctSliderChanged(ev: CustomEvent) { - const ct = parseInt((ev.target as any).value, 10); + const ct = Number((ev.target as any).value); if (isNaN(ct)) { return; @@ -219,18 +371,64 @@ class MoreInfoLight extends LitElement { } private _wvSliderChanged(ev: CustomEvent) { - const wv = parseInt((ev.target as any).value, 10); + const target = ev.target as any; + let wv = Number(target.value); + const name = target.name; if (isNaN(wv)) { return; } + wv = (wv * 255) / 100; + + const rgb = getLightRgbColor(this.stateObj!); + + if (name === "wv") { + const rgbw_color = rgb || [0, 0, 0, 0]; + rgbw_color[3] = wv; + this.hass.callService("light", "turn_on", { + entity_id: this.stateObj!.entity_id, + rgbw_color, + }); + return; + } + + const rgbww_color = rgb || [0, 0, 0, 0, 0]; + while (rgbww_color.length < 5) { + rgbww_color.push(0); + } + rgbww_color[name === "cw" ? 3 : 4] = wv; this.hass.callService("light", "turn_on", { entity_id: this.stateObj!.entity_id, - white_value: wv, + rgbww_color, }); } + private _colorBrightnessSliderChanged(ev: CustomEvent) { + const target = ev.target as any; + const value = Number(target.value); + + const rgb = (getLightRgbColor(this.stateObj!)?.slice(0, 3) || [ + 255, + 255, + 255, + ]) as [number, number, number]; + + this._setRgbColor( + this._adjustColorBrightness( + // first normalize the value + this._colorBrightnessSliderValue + ? this._adjustColorBrightness( + rgb, + this._colorBrightnessSliderValue, + true + ) + : rgb, + value + ) + ); + } + private _segmentClick() { if (this._hueSegments === 24 && this._saturationSegments === 8) { this._hueSegments = 0; @@ -241,15 +439,90 @@ class MoreInfoLight extends LitElement { } } + private _adjustColorBrightness( + rgbColor: [number, number, number], + value?: number, + invert = false + ) { + if (value !== undefined && value !== 255) { + let ratio = value / 255; + if (invert) { + ratio = 1 / ratio; + } + rgbColor[0] *= ratio; + rgbColor[1] *= ratio; + rgbColor[2] *= ratio; + } + return rgbColor; + } + + private _setRgbColor(rgbColor: [number, number, number]) { + if (lightSupportsColorMode(this.stateObj!, LightColorModes.RGBWW)) { + const rgbww_color: [number, number, number, number, number] = this + .stateObj!.attributes.rgbww_color + ? [...this.stateObj!.attributes.rgbww_color] + : [0, 0, 0, 0, 0]; + this.hass.callService("light", "turn_on", { + entity_id: this.stateObj!.entity_id, + rgbww_color: rgbColor.concat(rgbww_color.slice(3)), + }); + } else if (lightSupportsColorMode(this.stateObj!, LightColorModes.RGBW)) { + const rgbw_color: [number, number, number, number] = this.stateObj! + .attributes.rgbw_color + ? [...this.stateObj!.attributes.rgbw_color] + : [0, 0, 0, 0]; + this.hass.callService("light", "turn_on", { + entity_id: this.stateObj!.entity_id, + rgbw_color: rgbColor.concat(rgbw_color.slice(3)), + }); + } + } + /** * Called when a new color has been picked. * should be throttled with the 'throttle=' attribute of the color picker */ private _colorPicked(ev: CustomEvent) { - this.hass.callService("light", "turn_on", { - entity_id: this.stateObj!.entity_id, - hs_color: [ev.detail.hs.h, ev.detail.hs.s * 100], - }); + if ( + lightSupportsColorMode(this.stateObj!, LightColorModes.RGBWW) || + lightSupportsColorMode(this.stateObj!, LightColorModes.RGBW) + ) { + this._setRgbColor( + this._colorBrightnessSliderValue + ? this._adjustColorBrightness( + [ev.detail.rgb.r, ev.detail.rgb.g, ev.detail.rgb.b], + this._colorBrightnessSliderValue + ) + : [ev.detail.rgb.r, ev.detail.rgb.g, ev.detail.rgb.b] + ); + } else if (lightSupportsColorMode(this.stateObj!, LightColorModes.RGB)) { + const rgb_color = [ev.detail.rgb.r, ev.detail.rgb.g, ev.detail.rgb.b] as [ + number, + number, + number + ]; + if (this._brightnessAdjusted) { + this.hass.callService("light", "turn_on", { + entity_id: this.stateObj!.entity_id, + brightness_pct: this._brightnessSliderValue, + rgb_color: this._adjustColorBrightness( + rgb_color, + this._brightnessAdjusted, + true + ), + }); + } else { + this.hass.callService("light", "turn_on", { + entity_id: this.stateObj!.entity_id, + rgb_color, + }); + } + } else { + this.hass.callService("light", "turn_on", { + entity_id: this.stateObj!.entity_id, + hs_color: [ev.detail.hs.h, ev.detail.hs.s * 100], + }); + } } static get styles(): CSSResult { @@ -275,11 +548,18 @@ class MoreInfoLight extends LitElement { ); /* The color temp minimum value shouldn't be rendered differently. It's not "off". */ --paper-slider-knob-start-border-color: var(--primary-color); + margin-bottom: 4px; } .segmentationContainer { position: relative; max-height: 500px; + display: flex; + justify-content: center; + } + + ha-button-toggle-group { + margin: 8px 0px; } ha-color-picker { @@ -293,12 +573,19 @@ class MoreInfoLight extends LitElement { .segmentationButton { position: absolute; top: 5%; + left: 0; color: var(--secondary-text-color); } paper-item { cursor: pointer; } + + hr { + border-color: var(--divider-color); + border-bottom: none; + margin: 8px 0; + } `; } } diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index 443b8b8470..2e6147f7df 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -66,10 +66,11 @@ interface CommandItem extends QuickBarItem { } interface EntityItem extends QuickBarItem { + altText: string; icon?: string; } -const isCommandItem = (item: EntityItem | CommandItem): item is CommandItem => { +const isCommandItem = (item: QuickBarItem): item is CommandItem => { return (item as CommandItem).categoryKey !== undefined; }; @@ -230,7 +231,7 @@ export class QuickBar extends LitElement { private _renderItem(item: QuickBarItem, index?: number) { return isCommandItem(item) ? this._renderCommandItem(item, index) - : this._renderEntityItem(item, index); + : this._renderEntityItem(item as EntityItem, index); } private _renderEntityItem(item: EntityItem, index?: number) { @@ -289,13 +290,6 @@ export class QuickBar extends LitElement { ${item.primaryText} - ${item.altText - ? html` - ${item.altText} - ` - : null} `; } @@ -389,17 +383,20 @@ export class QuickBar extends LitElement { } } - private _generateEntityItems(): QuickBarItem[] { + private _generateEntityItems(): EntityItem[] { return Object.keys(this.hass.states) .map((entityId) => { - const primaryText = computeStateName(this.hass.states[entityId]); - return { - primaryText, - filterText: primaryText, + const entityItem = { + primaryText: computeStateName(this.hass.states[entityId]), altText: entityId, icon: domainIcon(computeDomain(entityId), this.hass.states[entityId]), action: () => fireEvent(this, "hass-more-info", { entityId }), }; + + return { + ...entityItem, + strings: [entityItem.primaryText, entityItem.altText], + }; }) .sort((a, b) => compare(a.primaryText.toLowerCase(), b.primaryText.toLowerCase()) @@ -412,7 +409,10 @@ export class QuickBar extends LitElement { ...this._generateServerControlCommands(), ...this._generateNavigationCommands(), ].sort((a, b) => - compare(a.filterText.toLowerCase(), b.filterText.toLowerCase()) + compare( + a.strings.join(" ").toLowerCase(), + b.strings.join(" ").toLowerCase() + ) ); } @@ -420,24 +420,27 @@ export class QuickBar extends LitElement { const reloadableDomains = componentsWithService(this.hass, "reload").sort(); return reloadableDomains.map((domain) => { - const categoryText = this.hass.localize( - `ui.dialogs.quick-bar.commands.types.reload` - ); - const primaryText = - this.hass.localize(`ui.dialogs.quick-bar.commands.reload.${domain}`) || - this.hass.localize( - "ui.dialogs.quick-bar.commands.reload.reload", - "domain", - domainToName(this.hass.localize, domain) - ); + const commandItem = { + primaryText: + this.hass.localize( + `ui.dialogs.quick-bar.commands.reload.${domain}` + ) || + this.hass.localize( + "ui.dialogs.quick-bar.commands.reload.reload", + "domain", + domainToName(this.hass.localize, domain) + ), + action: () => this.hass.callService(domain, "reload"), + iconPath: mdiReload, + categoryText: this.hass.localize( + `ui.dialogs.quick-bar.commands.types.reload` + ), + }; return { - primaryText, - filterText: `${categoryText} ${primaryText}`, - action: () => this.hass.callService(domain, "reload"), + ...commandItem, categoryKey: "reload", - iconPath: mdiReload, - categoryText, + strings: [`${commandItem.categoryText} ${commandItem.primaryText}`], }; }); } @@ -446,26 +449,28 @@ export class QuickBar extends LitElement { const serverActions = ["restart", "stop"]; return serverActions.map((action) => { - const categoryKey = "server_control"; - const categoryText = this.hass.localize( - `ui.dialogs.quick-bar.commands.types.${categoryKey}` - ); - const primaryText = this.hass.localize( - "ui.dialogs.quick-bar.commands.server_control.perform_action", - "action", - this.hass.localize( - `ui.dialogs.quick-bar.commands.server_control.${action}` - ) - ); + const categoryKey: CommandItem["categoryKey"] = "server_control"; + + const item = { + primaryText: this.hass.localize( + "ui.dialogs.quick-bar.commands.server_control.perform_action", + "action", + this.hass.localize( + `ui.dialogs.quick-bar.commands.server_control.${action}` + ) + ), + iconPath: mdiServerNetwork, + categoryText: this.hass.localize( + `ui.dialogs.quick-bar.commands.types.${categoryKey}` + ), + categoryKey, + action: () => this.hass.callService("homeassistant", action), + }; return this._generateConfirmationCommand( { - primaryText, - filterText: `${categoryText} ${primaryText}`, - categoryKey, - iconPath: mdiServerNetwork, - categoryText, - action: () => this.hass.callService("homeassistant", action), + ...item, + strings: [`${item.categoryText} ${item.primaryText}`], }, this.hass.localize("ui.dialogs.generic.ok") ); @@ -550,18 +555,21 @@ export class QuickBar extends LitElement { items: BaseNavigationCommand[] ): CommandItem[] { return items.map((item) => { - const categoryKey = "navigation"; - const categoryText = this.hass.localize( - `ui.dialogs.quick-bar.commands.types.${categoryKey}` - ); + const categoryKey: CommandItem["categoryKey"] = "navigation"; + + const navItem = { + ...item, + iconPath: mdiEarth, + categoryText: this.hass.localize( + `ui.dialogs.quick-bar.commands.types.${categoryKey}` + ), + action: () => navigate(this, item.path), + }; return { - ...item, + ...navItem, + strings: [`${navItem.categoryText} ${navItem.primaryText}`], categoryKey, - iconPath: mdiEarth, - categoryText, - filterText: `${categoryText} ${item.primaryText}`, - action: () => navigate(this, item.path), }; }); } diff --git a/src/entrypoints/service_worker.ts b/src/entrypoints/service_worker.ts index 29af95cf92..0748a15e1f 100644 --- a/src/entrypoints/service_worker.ts +++ b/src/entrypoints/service_worker.ts @@ -10,9 +10,11 @@ import { NetworkOnly, StaleWhileRevalidate, } from "workbox-strategies"; +import { CacheableResponsePlugin } from "workbox-cacheable-response"; +import { ExpirationPlugin } from "workbox-expiration"; const noFallBackRegEx = new RegExp( - `${location.host}/(api|static|auth|frontend_latest|frontend_es5|local)/.*` + "/(api|static|auth|frontend_latest|frontend_es5|local)/.*" ); // Clean up caches from older workboxes and old service workers. @@ -31,27 +33,22 @@ function initRouting() { // Cache static content (including translations) on first access. registerRoute( - new RegExp(`${location.host}/(static|frontend_latest|frontend_es5)/.+`), + new RegExp("/(static|frontend_latest|frontend_es5)/.+"), new CacheFirst({ matchOptions: { ignoreSearch: true } }) ); // Get api from network. - registerRoute( - new RegExp(`${location.host}/(api|auth)/.*`), - new NetworkOnly() - ); + registerRoute(new RegExp("/(api|auth)/.*"), new NetworkOnly()); // Get manifest, service worker, onboarding from network. registerRoute( - new RegExp( - `${location.host}/(service_worker.js|manifest.json|onboarding.html)` - ), + new RegExp("/(service_worker.js|manifest.json|onboarding.html)"), new NetworkOnly() ); // For the root "/" we ignore search registerRoute( - new RegExp(`^${location.host}/(\\?.*)?$`), + new RegExp(/\/(\?.*)?$/), new StaleWhileRevalidate({ matchOptions: { ignoreSearch: true } }) ); @@ -59,7 +56,20 @@ function initRouting() { // This includes "/states" response and user files from "/local". // First access might bring stale data from cache, but a single refresh will bring updated // file. - registerRoute(new RegExp(`${location.host}/.*`), new StaleWhileRevalidate()); + registerRoute( + new RegExp(/\/.*/), + new StaleWhileRevalidate({ + cacheName: "file-cache", + plugins: [ + new CacheableResponsePlugin({ + statuses: [0, 200], + }), + new ExpirationPlugin({ + maxAgeSeconds: 60 * 60 * 24, + }), + ], + }) + ); } function initPushNotifications() { diff --git a/src/fake_data/entity.ts b/src/fake_data/entity.ts index 78913353cb..89bdbfb5b0 100644 --- a/src/fake_data/entity.ts +++ b/src/fake_data/entity.ts @@ -81,9 +81,27 @@ class LightEntity extends Entity { if (service === "turn_on") { // eslint-disable-next-line - let { brightness, hs_color, brightness_pct } = data; - brightness = (255 * brightness_pct) / 100; - this.update("on", { ...this.attributes, brightness, hs_color }); + let { hs_color, brightness_pct, rgb_color, color_temp } = data; + const attrs = { ...this.attributes }; + if (brightness_pct) { + attrs.brightness = (255 * brightness_pct) / 100; + } else if (!attrs.brightness) { + attrs.brightness = 255; + } + if (hs_color) { + attrs.color_mode = "hs"; + attrs.hs_color = hs_color; + } + if (rgb_color) { + attrs.color_mode = "rgb"; + attrs.rgb_color = rgb_color; + } + if (color_temp) { + attrs.color_mode = "color_temp"; + attrs.color_temp = color_temp; + delete attrs.rgb_color; + } + this.update("on", attrs); } else if (service === "turn_off") { this.update("off"); } else if (service === "toggle") { diff --git a/src/fake_data/provide_hass.ts b/src/fake_data/provide_hass.ts index dbbe930314..e74cb69152 100644 --- a/src/fake_data/provide_hass.ts +++ b/src/fake_data/provide_hass.ts @@ -30,6 +30,7 @@ export interface MockHomeAssistant extends HomeAssistant { updateStates(newStates: HassEntities); addEntities(entites: Entity | Entity[], replace?: boolean); updateTranslations(fragment: null | string, language?: string); + addTranslations(translations: Record, language?: string); mockWS( type: string, callback: (msg: any, onChange?: (response: any) => void) => any @@ -60,15 +61,25 @@ export const provideHass = ( ) { const lang = language || getLocalLanguage(); const translation = await getTranslation(fragment, lang); + await addTranslations(translation.data, lang); + } + + async function addTranslations( + translations: Record, + language?: string + ) { + const lang = language || getLocalLanguage(); const resources = { [lang]: { ...(hass().resources && hass().resources[lang]), - ...translation.data, + ...translations, }, }; hass().updateHass({ resources, - localize: await computeLocalize(elements[0], lang, resources), + }); + hass().updateHass({ + localize: await computeLocalize(elements[0], lang, hass().resources), }); } @@ -209,6 +220,9 @@ export const provideHass = ( localize: () => "", translationMetadata: translationMetadata as any, + async loadBackendTranslation() { + return hass().localize; + }, dockedSidebar: "auto", vibrate: true, suspendWhenHidden: false, @@ -250,6 +264,7 @@ export const provideHass = ( }, updateStates, updateTranslations, + addTranslations, addEntities, mockWS(type, callback) { wsCommands[type] = callback; diff --git a/src/html/authorize.html.template b/src/html/authorize.html.template index 43d63fd024..1cf1c7bba4 100644 --- a/src/html/authorize.html.template +++ b/src/html/authorize.html.template @@ -23,11 +23,9 @@ margin-right: 16px; } @media (prefers-color-scheme: dark) { - body { + html { background-color: #111111; color: #e1e1e1; - --primary-text-color: #e1e1e1; - --secondary-text-color: #9b9b9b; } } diff --git a/src/html/index.html.template b/src/html/index.html.template index 747c8c102c..dfad967ad1 100644 --- a/src/html/index.html.template +++ b/src/html/index.html.template @@ -51,6 +51,7 @@ @media (prefers-color-scheme: dark) { html { background-color: #111111; + color: #e1e1e1; } #ha-init-skeleton::before { background-color: #1c1c1c; diff --git a/src/html/onboarding.html.template b/src/html/onboarding.html.template index 55b4144b6c..8ab6264be1 100644 --- a/src/html/onboarding.html.template +++ b/src/html/onboarding.html.template @@ -34,17 +34,8 @@ @media (prefers-color-scheme: dark) { html { - color: #e1e1e1; - } - ha-onboarding { - --primary-text-color: #e1e1e1; - --secondary-text-color: #9b9b9b; - --disabled-text-color: #6f6f6f; - --mdc-theme-surface: #1e1e1e; - --ha-card-background: #1e1e1e; - } - .content { background-color: #111111; + color: #e1e1e1; } } diff --git a/src/onboarding/ha-onboarding.ts b/src/onboarding/ha-onboarding.ts index a6177099be..64ff66bf5a 100644 --- a/src/onboarding/ha-onboarding.ts +++ b/src/onboarding/ha-onboarding.ts @@ -32,6 +32,7 @@ import { registerServiceWorker } from "../util/register-service-worker"; import "./onboarding-create-user"; import "./onboarding-loading"; import "./onboarding-analytics"; +import { applyThemesOnElement } from "../common/dom/apply_themes_on_element"; type OnboardingEvent = | { @@ -137,6 +138,19 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) { if (window.innerWidth > 450) { import("./particles"); } + if (matchMedia("(prefers-color-scheme: dark)").matches) { + applyThemesOnElement( + document.documentElement, + { + default_theme: "default", + default_dark_theme: null, + themes: {}, + darkMode: false, + }, + "default", + { dark: true } + ); + } } protected updated(changedProps: PropertyValues) { diff --git a/src/onboarding/onboarding-analytics.ts b/src/onboarding/onboarding-analytics.ts index d8615b2408..78c20b485f 100644 --- a/src/onboarding/onboarding-analytics.ts +++ b/src/onboarding/onboarding-analytics.ts @@ -32,13 +32,9 @@ class OnboardingAnalytics extends LitElement { protected render(): TemplateResult { return html`

    - ${this.hass.localize( - "ui.panel.config.core.section.core.analytics.introduction", - "link", - html`analytics.home-assistant.io` - )} + Share anonymized information from your installation to help make Home + Assistant better and help us convince manufacturers to add local control + and privacy-focused features.

    areas.find((area) => area.area_id === areaId)); - private _devices = memoizeOne( - (areaId: string, devices: DeviceRegistryEntry[]): DeviceRegistryEntry[] => - devicesInArea(devices, areaId) + private _memberships = memoizeOne( + ( + areaId: string, + registryDevices: DeviceRegistryEntry[], + registryEntities: EntityRegistryEntry[] + ) => { + const devices = new Map(); + + for (const device of registryDevices) { + if (device.area_id === areaId) { + devices.set(device.id, device); + } + } + + const entities: EntityRegistryEntry[] = []; + const indirectEntities: EntityRegistryEntry[] = []; + + for (const entity of registryEntities) { + if (entity.area_id) { + if (entity.area_id === areaId) { + entities.push(entity); + } + } else if (devices.has(entity.device_id)) { + indirectEntities.push(entity); + } + } + + return { + devices: Array.from(devices.values()), + entities, + indirectEntities, + }; + } ); protected firstUpdated(changedProps) { @@ -87,7 +123,11 @@ class HaConfigAreaPage extends LitElement { `; } - const devices = this._devices(this.areaId, this.devices); + const { devices, entities } = this._memberships( + this.areaId, + this.devices, + this.entities + ); return html` `} + ${entities.length + ? entities.map( + (entity) => + html` + + + ${computeEntityRegistryName(this.hass, entity)} + + + + ` + ) + : html` + ${this.hass.localize( + "ui.panel.config.areas.editor.no_linked_entities" + )} + `} +
    ${isComponentLoaded(this.hass, "automation") @@ -299,6 +366,14 @@ class HaConfigAreaPage extends LitElement { this._openDialog(entry); } + private _openEntity(ev) { + const entry: EntityRegistryEntry = (ev.currentTarget as any).entity; + showEntityEditorDialog(this, { + entity_id: entry.entity_id, + entry, + }); + } + private _openDialog(entry?: AreaRegistryEntry) { showAreaRegistryDetailDialog(this, { entry, diff --git a/src/panels/config/areas/ha-config-areas-dashboard.ts b/src/panels/config/areas/ha-config-areas-dashboard.ts index ad00f8e871..e07661054a 100644 --- a/src/panels/config/areas/ha-config-areas-dashboard.ts +++ b/src/panels/config/areas/ha-config-areas-dashboard.ts @@ -24,10 +24,8 @@ import { AreaRegistryEntry, createAreaRegistryEntry, } from "../../../data/area_registry"; -import { - DeviceRegistryEntry, - devicesInArea, -} from "../../../data/device_registry"; +import type { DeviceRegistryEntry } from "../../../data/device_registry"; +import type { EntityRegistryEntry } from "../../../data/entity_registry"; import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; import "../../../layouts/hass-loading-screen"; import "../../../layouts/hass-tabs-subpage-data-table"; @@ -53,12 +51,39 @@ export class HaConfigAreasDashboard extends LitElement { @property() public devices!: DeviceRegistryEntry[]; + @property() public entities!: EntityRegistryEntry[]; + private _areas = memoizeOne( - (areas: AreaRegistryEntry[], devices: DeviceRegistryEntry[]) => { + ( + areas: AreaRegistryEntry[], + devices: DeviceRegistryEntry[], + entities: EntityRegistryEntry[] + ) => { return areas.map((area) => { + const devicesInArea = new Set(); + + for (const device of devices) { + if (device.area_id === area.area_id) { + devicesInArea.add(device.id); + } + } + + let entitiesInArea = 0; + + for (const entity of entities) { + if ( + entity.area_id + ? entity.area_id === area.area_id + : devicesInArea.has(entity.device_id) + ) { + entitiesInArea++; + } + } + return { ...area, - devices: devicesInArea(devices, area.area_id).length, + devices: devicesInArea.size, + entities: entitiesInArea, }; }); } @@ -97,6 +122,15 @@ export class HaConfigAreasDashboard extends LitElement { width: "20%", direction: "asc", }, + entities: { + title: this.hass.localize( + "ui.panel.config.areas.data_table.entities" + ), + sortable: true, + type: "numeric", + width: "20%", + direction: "asc", + }, } ); @@ -110,7 +144,7 @@ export class HaConfigAreasDashboard extends LitElement { .tabs=${configSections.integrations} .route=${this.route} .columns=${this._columns(this.narrow)} - .data=${this._areas(this.areas, this.devices)} + .data=${this._areas(this.areas, this.devices, this.entities)} @row-click=${this._handleRowClicked} .noDataText=${this.hass.localize( "ui.panel.config.areas.picker.no_areas" diff --git a/src/panels/config/areas/ha-config-areas.ts b/src/panels/config/areas/ha-config-areas.ts index fc9ead4062..c742b304d0 100644 --- a/src/panels/config/areas/ha-config-areas.ts +++ b/src/panels/config/areas/ha-config-areas.ts @@ -15,6 +15,10 @@ import { DeviceRegistryEntry, subscribeDeviceRegistry, } from "../../../data/device_registry"; +import { + EntityRegistryEntry, + subscribeEntityRegistry, +} from "../../../data/entity_registry"; import { HassRouterPage, RouterOptions, @@ -51,6 +55,9 @@ class HaConfigAreas extends HassRouterPage { @internalProperty() private _deviceRegistryEntries: DeviceRegistryEntry[] = []; + @internalProperty() + private _entityRegistryEntries: EntityRegistryEntry[] = []; + @internalProperty() private _areas: AreaRegistryEntry[] = []; private _unsubs?: UnsubscribeFunc[]; @@ -90,6 +97,7 @@ class HaConfigAreas extends HassRouterPage { pageEl.entries = this._configEntries; pageEl.devices = this._deviceRegistryEntries; + pageEl.entities = this._entityRegistryEntries; pageEl.areas = this._areas; pageEl.narrow = this.narrow; pageEl.isWide = this.isWide; @@ -113,6 +121,9 @@ class HaConfigAreas extends HassRouterPage { subscribeDeviceRegistry(this.hass.connection, (entries) => { this._deviceRegistryEntries = entries; }), + subscribeEntityRegistry(this.hass.connection, (entries) => { + this._entityRegistryEntries = entries; + }), ]; } } diff --git a/src/panels/config/automation/trace/ha-automation-trace-path-details.ts b/src/panels/config/automation/trace/ha-automation-trace-path-details.ts index 2416ea2ad6..51ebbdab7f 100644 --- a/src/panels/config/automation/trace/ha-automation-trace-path-details.ts +++ b/src/panels/config/automation/trace/ha-automation-trace-path-details.ts @@ -99,33 +99,63 @@ export class HaAutomationTracePathDetails extends LitElement { return "This node was not executed and so no further trace information is available."; } - const data: ActionTraceStep[] = paths[this.selected.path]; + const parts: TemplateResult[][] = []; - return data.map((trace, idx) => { - const { - path, - timestamp, - result, - error, - changed_variables, - ...rest - } = trace as any; + let active = false; + const childConditionsPrefix = `${this.selected.path}/conditions/`; - return html` - ${data.length === 1 ? "" : html`

    Iteration ${idx + 1}

    `} - Executed: - ${formatDateTimeWithSeconds(new Date(timestamp), this.hass.locale)}
    - ${result - ? html`Result: -
    ${safeDump(result)}
    ` - : error - ? html`
    Error: ${error}
    ` - : ""} - ${Object.keys(rest).length === 0 - ? "" - : html`
    ${safeDump(rest)}
    `} - `; - }); + for (const curPath of Object.keys(this.trace.trace)) { + // Include all child conditions too + if (active) { + if (!curPath.startsWith(childConditionsPrefix)) { + break; + } + } else if (curPath === this.selected.path) { + active = true; + } else { + continue; + } + + const data: ActionTraceStep[] = paths[curPath]; + + parts.push( + data.map((trace, idx) => { + const { + path, + timestamp, + result, + error, + changed_variables, + ...rest + } = trace as any; + + return html` + ${curPath === this.selected.path + ? "" + : html`

    + Condition ${curPath.substr(childConditionsPrefix.length)} +

    `} + ${data.length === 1 ? "" : html`

    Iteration ${idx + 1}

    `} + Executed: + ${formatDateTimeWithSeconds( + new Date(timestamp), + this.hass.locale + )}
    + ${result + ? html`Result: +
    ${safeDump(result)}
    ` + : error + ? html`
    Error: ${error}
    ` + : ""} + ${Object.keys(rest).length === 0 + ? "" + : html`
    ${safeDump(rest)}
    `} + `; + }) + ); + } + + return parts; } private _renderSelectedConfig() { diff --git a/src/panels/config/automation/trace/ha-automation-trace.ts b/src/panels/config/automation/trace/ha-automation-trace.ts index 81522f4876..b8e480d7a9 100644 --- a/src/panels/config/automation/trace/ha-automation-trace.ts +++ b/src/panels/config/automation/trace/ha-automation-trace.ts @@ -87,12 +87,24 @@ export class HaAutomationTrace extends LitElement { const title = stateObj?.attributes.friendly_name || this._entityId; + let devButtons: TemplateResult | string = ""; + if (__DEV__) { + devButtons = html`
    + + +
    `; + } + const actionButtons = html` this._loadTraces()}> @@ -101,6 +113,7 @@ export class HaAutomationTrace extends LitElement { `; return html` + ${devButtons} void; - private _cloudStatusLoaded = new Promise((resolve) => { + private _cloudStatusLoaded = new Promise((resolve) => { this._resolveCloudStatusLoaded = resolve; }); diff --git a/src/panels/config/core/ha-config-analytics.ts b/src/panels/config/core/ha-config-analytics.ts index 26a2515e2f..ee145e9f64 100644 --- a/src/panels/config/core/ha-config-analytics.ts +++ b/src/panels/config/core/ha-config-analytics.ts @@ -40,21 +40,13 @@ class ConfigAnalytics extends LitElement { : undefined; return html` - +
    ${error ? html`
    ${error}
    ` : ""}

    - ${this.hass.localize( - "ui.panel.config.core.section.core.analytics.introduction", - "link", - html`analytics.home-assistant.io` - )} + Share anonymized information from your installation to help make + Home Assistant better and help us convince manufacturers to add + local control and privacy-focused features.

    ` )} - ${isComponentLoaded(this.hass, "zha") - ? html` -
    - ${this.hass.localize( - "ui.panel.config.integration_panel_move.missing_zha", - "integrations_page", - html` - ${this.hass.localize( - "ui.panel.config.integration_panel_move.link_integration_page" - )} - ` - )} -
    - ` - : ""} - ${isComponentLoaded(this.hass, "zwave") - ? html` -
    - ${this.hass.localize( - "ui.panel.config.integration_panel_move.missing_zwave", - "integrations_page", - html` - ${this.hass.localize( - "ui.panel.config.integration_panel_move.link_integration_page" - )} - ` - )} -
    - ` - : ""} ${!this.showAdvanced ? html`
    diff --git a/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts index 13c7995243..a43d8126f3 100644 --- a/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts +++ b/src/panels/config/devices/device-detail/integration-elements/zwave_js/ha-device-actions-zwave_js.ts @@ -11,9 +11,13 @@ import { TemplateResult, } from "lit-element"; import { DeviceRegistryEntry } from "../../../../../../data/device_registry"; +import { + getIdentifiersFromDevice, + ZWaveJSNodeIdentifiers, +} from "../../../../../../data/zwave_js"; import { haStyle } from "../../../../../../resources/styles"; - import { HomeAssistant } from "../../../../../../types"; +import { showZWaveJSReinterviewNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node"; @customElement("ha-device-actions-zwave_js") export class HaDeviceActionsZWaveJS extends LitElement { @@ -23,9 +27,19 @@ export class HaDeviceActionsZWaveJS extends LitElement { @internalProperty() private _entryId?: string; + @internalProperty() private _nodeId?: number; + protected updated(changedProperties: PropertyValues) { if (changedProperties.has("device")) { this._entryId = this.device.config_entries[0]; + + const identifiers: + | ZWaveJSNodeIdentifiers + | undefined = getIdentifiersFromDevice(this.device); + if (!identifiers) { + return; + } + this._nodeId = identifiers.node_id; } } @@ -40,9 +54,22 @@ export class HaDeviceActionsZWaveJS extends LitElement { )} + Re-interview Device `; } + private async _reinterviewClicked() { + if (!this._nodeId || !this._entryId) { + return; + } + showZWaveJSReinterviewNodeDialog(this, { + entry_id: this._entryId, + node_id: this._nodeId, + }); + } + static get styles(): CSSResult[] { return [ haStyle, diff --git a/src/panels/config/devices/device-registry-detail/dialog-device-registry-detail.ts b/src/panels/config/devices/device-registry-detail/dialog-device-registry-detail.ts index e60439f2de..99bd51dcb7 100644 --- a/src/panels/config/devices/device-registry-detail/dialog-device-registry-detail.ts +++ b/src/panels/config/devices/device-registry-detail/dialog-device-registry-detail.ts @@ -33,7 +33,7 @@ class DialogDeviceRegistryDetail extends LitElement { @internalProperty() private _params?: DeviceRegistryDetailDialogParams; - @internalProperty() private _areaId?: string; + @internalProperty() private _areaId?: string | null; @internalProperty() private _disabledBy!: string | null; diff --git a/src/panels/config/devices/ha-config-device-page.ts b/src/panels/config/devices/ha-config-device-page.ts index 8b04716baf..187a05f875 100644 --- a/src/panels/config/devices/ha-config-device-page.ts +++ b/src/panels/config/devices/ha-config-device-page.ts @@ -728,7 +728,7 @@ export class HaConfigDevicePage extends LitElement { } if (!newName && !newEntityId) { - return new Promise((resolve) => resolve()); + return undefined; } return updateEntityRegistryEntry(this.hass!, entity.entity_id, { diff --git a/src/panels/config/entities/entity-registry-basic-editor.ts b/src/panels/config/entities/entity-registry-basic-editor.ts index 1b1690394c..1063639aa4 100644 --- a/src/panels/config/entities/entity-registry-basic-editor.ts +++ b/src/panels/config/entities/entity-registry-basic-editor.ts @@ -38,7 +38,7 @@ export class HaEntityRegistryBasicEditor extends SubscribeMixin(LitElement) { @internalProperty() private _entityId!: string; - @internalProperty() private _areaId?: string; + @internalProperty() private _areaId?: string | null; @internalProperty() private _disabledBy!: string | null; diff --git a/src/panels/config/entities/ha-config-entities.ts b/src/panels/config/entities/ha-config-entities.ts index be295a57a4..c1277e7b8a 100644 --- a/src/panels/config/entities/ha-config-entities.ts +++ b/src/panels/config/entities/ha-config-entities.ts @@ -663,6 +663,10 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { entity_id: entityId, platform: computeDomain(entityId), disabled_by: null, + area_id: null, + config_entry_id: null, + device_id: null, + icon: null, readonly: true, selectable: false, }); diff --git a/src/panels/config/info/ha-config-info.ts b/src/panels/config/info/ha-config-info.ts index c1dd606e57..b3ffe78557 100644 --- a/src/panels/config/info/ha-config-info.ts +++ b/src/panels/config/info/ha-config-info.ts @@ -140,7 +140,10 @@ class HaConfigInfo extends LitElement {
    - +
    `; diff --git a/src/panels/config/info/integrations-card.ts b/src/panels/config/info/integrations-card.ts index d078524f1e..a698b7e634 100644 --- a/src/panels/config/info/integrations-card.ts +++ b/src/panels/config/info/integrations-card.ts @@ -13,8 +13,10 @@ import "../../../components/ha-card"; import { domainToName, fetchIntegrationManifests, + fetchIntegrationSetups, integrationIssuesUrl, IntegrationManifest, + IntegrationSetup, } from "../../../data/integration"; import { HomeAssistant } from "../../../types"; import { brandsUrl } from "../../../util/brands-url"; @@ -23,15 +25,22 @@ import { brandsUrl } from "../../../util/brands-url"; class IntegrationsCard extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; + @property({ type: Boolean }) public narrow = false; + @internalProperty() private _manifests?: { [domain: string]: IntegrationManifest; }; + @internalProperty() private _setups?: { + [domain: string]: IntegrationSetup; + }; + private _sortedIntegrations = memoizeOne((components: string[]) => { return Array.from( new Set( - components - .map((comp) => (comp.includes(".") ? comp.split(".")[1] : comp)) + components.map((comp) => + comp.includes(".") ? comp.split(".")[1] : comp + ) ) ).sort(); }); @@ -39,6 +48,7 @@ class IntegrationsCard extends LitElement { firstUpdated(changedProps) { super.firstUpdated(changedProps); this._fetchManifests(); + this._fetchSetups(); } protected render(): TemplateResult { @@ -47,10 +57,47 @@ class IntegrationsCard extends LitElement { .header=${this.hass.localize("ui.panel.config.info.integrations")} > + + + + ${!this.narrow + ? html` + + ` + : ""} + + + ${this._sortedIntegrations(this.hass!.config.components).map( (domain) => { const manifest = this._manifests && this._manifests[domain]; + const docLink = manifest + ? html`${this.hass.localize( + "ui.panel.config.info.documentation" + )}` + : ""; + const issueLink = + manifest && (manifest.is_built_in || manifest.issue_tracker) + ? html` + ${this.hass.localize( + "ui.panel.config.info.issues" + )} + ` + : ""; + const setupSeconds = this._setups?.[domain]?.seconds?.toFixed( + 2 + ); return html` - ${!manifest + ${this.narrow ? "" : html` + + - ${manifest.is_built_in || manifest.issue_tracker - ? html` - - ` - : ""} `} `; @@ -115,9 +148,21 @@ class IntegrationsCard extends LitElement { this._manifests = manifests; } + private async _fetchSetups() { + const setups = {}; + for (const setup of await fetchIntegrationSetups(this.hass)) { + setups[setup.domain] = setup; + } + this._setups = setups; + } + static get styles(): CSSResult { return css` - td { + table { + width: 100%; + } + td, + th { padding: 0 8px; } td:first-child { @@ -126,9 +171,22 @@ class IntegrationsCard extends LitElement { td.name { padding: 8px; } + td.setup { + text-align: right; + } + th { + text-align: right; + } .domain { color: var(--secondary-text-color); } + .mobile-row { + display: flex; + justify-content: space-between; + } + .mobile-row a:not(:last-of-type) { + margin-right: 4px; + } img { display: block; max-height: 40px; diff --git a/src/panels/config/integrations/ha-config-flow-card.ts b/src/panels/config/integrations/ha-config-flow-card.ts new file mode 100644 index 0000000000..fd6769b22c --- /dev/null +++ b/src/panels/config/integrations/ha-config-flow-card.ts @@ -0,0 +1,130 @@ +import { + customElement, + LitElement, + property, + css, + html, + TemplateResult, +} from "lit-element"; +import { classMap } from "lit-html/directives/class-map"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { + ATTENTION_SOURCES, + DISCOVERY_SOURCES, + ignoreConfigFlow, + localizeConfigFlowTitle, +} from "../../../data/config_flow"; +import type { IntegrationManifest } from "../../../data/integration"; +import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; +import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; +import type { HomeAssistant } from "../../../types"; +import type { DataEntryFlowProgressExtended } from "./ha-config-integrations"; +import "./ha-integration-action-card"; + +@customElement("ha-config-flow-card") +export class HaConfigFlowCard extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public flow!: DataEntryFlowProgressExtended; + + @property() public manifest?: IntegrationManifest; + + protected render(): TemplateResult { + const attention = ATTENTION_SOURCES.includes(this.flow.context.source); + return html` + + + ${DISCOVERY_SOURCES.includes(this.flow.context.source) && + this.flow.context.unique_id + ? html` + + ` + : ""} + + `; + } + + private _continueFlow() { + showConfigFlowDialog(this, { + continueFlowId: this.flow.flow_id, + dialogClosedCallback: () => { + this._handleFlowUpdated(); + }, + }); + } + + private async _ignoreFlow() { + const confirmed = await showConfirmationDialog(this, { + title: this.hass!.localize( + "ui.panel.config.integrations.ignore.confirm_ignore_title", + "name", + localizeConfigFlowTitle(this.hass.localize, this.flow) + ), + text: this.hass!.localize( + "ui.panel.config.integrations.ignore.confirm_ignore" + ), + confirmText: this.hass!.localize( + "ui.panel.config.integrations.ignore.ignore" + ), + }); + if (!confirmed) { + return; + } + await ignoreConfigFlow( + this.hass, + this.flow.flow_id, + localizeConfigFlowTitle(this.hass.localize, this.flow) + ); + this._handleFlowUpdated(); + } + + private _handleFlowUpdated() { + fireEvent(this, "change", undefined, { + bubbles: false, + }); + } + + static styles = css` + .attention { + --state-color: var(--error-color); + --text-on-state-color: var(--text-primary-color); + } + .discovered { + --state-color: var(--primary-color); + --text-on-state-color: var(--text-primary-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-flow-card": HaConfigFlowCard; + } +} diff --git a/src/panels/config/integrations/ha-config-integrations.ts b/src/panels/config/integrations/ha-config-integrations.ts index b0823087f0..3bfe8bb69b 100644 --- a/src/panels/config/integrations/ha-config-integrations.ts +++ b/src/panels/config/integrations/ha-config-integrations.ts @@ -2,9 +2,8 @@ import "@material/mwc-icon-button"; import { ActionDetail } from "@material/mwc-list"; import "@material/mwc-list/mwc-list-item"; import { mdiFilterVariant, mdiPlus } from "@mdi/js"; -import "@polymer/app-route/app-route"; import Fuse from "fuse.js"; -import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import { css, CSSResult, @@ -16,31 +15,15 @@ import { PropertyValues, TemplateResult, } from "lit-element"; -import { classMap } from "lit-html/directives/class-map"; import { ifDefined } from "lit-html/directives/if-defined"; import memoizeOne from "memoize-one"; -import { HASSDomEvent } from "../../../common/dom/fire_event"; import { navigate } from "../../../common/navigate"; -import "../../../common/search/search-input"; import { caseInsensitiveCompare } from "../../../common/string/compare"; -import { LocalizeFunc } from "../../../common/translations/localize"; import { extractSearchParam } from "../../../common/url/search-params"; import { nextRender } from "../../../common/util/render-status"; -import "../../../components/ha-button-menu"; -import "../../../components/ha-card"; -import "../../../components/ha-fab"; -import "../../../components/ha-checkbox"; -import "../../../components/ha-svg-icon"; +import { ConfigEntry, getConfigEntries } from "../../../data/config_entries"; import { - ConfigEntry, - deleteConfigEntry, - getConfigEntries, -} from "../../../data/config_entries"; -import { - ATTENTION_SOURCES, - DISCOVERY_SOURCES, getConfigFlowInProgressCollection, - ignoreConfigFlow, localizeConfigFlowTitle, subscribeConfigFlowInProgress, } from "../../../data/config_flow"; @@ -55,26 +38,49 @@ import { } from "../../../data/entity_registry"; import { domainToName, + fetchIntegrationManifest, fetchIntegrationManifests, IntegrationManifest, } from "../../../data/integration"; import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; -import "../../../layouts/hass-loading-screen"; -import "../../../layouts/hass-tabs-subpage"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { haStyle } from "../../../resources/styles"; -import { HomeAssistant, Route } from "../../../types"; -import { brandsUrl } from "../../../util/brands-url"; import { configSections } from "../ha-panel-config"; -import "./ha-integration-card"; -import type { - ConfigEntryRemovedEvent, - ConfigEntryUpdatedEvent, - HaIntegrationCard, -} from "./ha-integration-card"; -interface DataEntryFlowProgressExtended extends DataEntryFlowProgress { +import type { HomeAssistant, Route } from "../../../types"; +import type { HASSDomEvent } from "../../../common/dom/fire_event"; +import type { LocalizeFunc } from "../../../common/translations/localize"; +import type { HaIntegrationCard } from "./ha-integration-card"; + +import "../../../common/search/search-input"; +import "../../../components/ha-button-menu"; +import "../../../components/ha-fab"; +import "../../../components/ha-checkbox"; +import "../../../components/ha-svg-icon"; +import "../../../layouts/hass-loading-screen"; +import "../../../layouts/hass-tabs-subpage"; +import "./ha-integration-card"; +import "./ha-config-flow-card"; +import "./ha-ignored-config-entry-card"; + +export interface ConfigEntryUpdatedEvent { + entry: ConfigEntry; +} + +export interface ConfigEntryRemovedEvent { + entryId: string; +} + +declare global { + // for fire event + interface HASSDomEvents { + "entry-updated": ConfigEntryUpdatedEvent; + "entry-removed": ConfigEntryRemovedEvent; + } +} + +export interface DataEntryFlowProgressExtended extends DataEntryFlowProgress { localized_title?: string; } @@ -119,9 +125,10 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { @internalProperty() private _deviceRegistryEntries: DeviceRegistryEntry[] = []; - @internalProperty() private _manifests!: { - [domain: string]: IntegrationManifest; - }; + @internalProperty() + private _manifests: Record = {}; + + private _extraFetchedManifests?: Set; @internalProperty() private _showIgnored = false; @@ -150,15 +157,14 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { this.hass.loadBackendTranslation("config", flow.handler) ); } + this._fetchManifest(flow.handler); }); await Promise.all(translationsPromisses); await nextRender(); - this._configEntriesInProgress = flowsInProgress.map((flow) => { - return { - ...flow, - localized_title: localizeConfigFlowTitle(this.hass.localize, flow), - }; - }); + this._configEntriesInProgress = flowsInProgress.map((flow) => ({ + ...flow, + localized_title: localizeConfigFlowTitle(this.hass.localize, flow), + })); }), ]; } @@ -217,12 +223,6 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { configEntriesInProgress: DataEntryFlowProgressExtended[], filter?: string ): DataEntryFlowProgressExtended[] => { - configEntriesInProgress = configEntriesInProgress.map( - (flow: DataEntryFlowProgressExtended) => ({ - ...flow, - title: localizeConfigFlowTitle(this.hass.localize, flow), - }) - ); if (!filter) { return configEntriesInProgress; } @@ -349,11 +349,12 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { "number", disabledConfigEntries.size )} - - ${this.hass.localize( + + > ` : ""} ${filterMenu} @@ -362,112 +363,31 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
    ${this._showIgnored ? ignoredConfigEntries.map( - (item: ConfigEntryExtended) => html` - -
    - ${this.hass.localize( - "ui.panel.config.integrations.ignore.ignored" - )} -
    -
    -
    - -
    -

    - ${// In 2020.2 we added support for item.title. All ignored entries before - // that have title "Ignored" so we fallback to localized domain name. - item.title === "Ignored" - ? item.localized_domain_name - : item.title} -

    - ${this.hass.localize( - "ui.panel.config.integrations.ignore.stop_ignore" - )} -
    -
    + (entry: ConfigEntryExtended) => html` + ` ) : ""} ${configEntriesInProgress.length ? configEntriesInProgress.map( - (flow: DataEntryFlowProgressExtended) => { - const attention = ATTENTION_SOURCES.includes( - flow.context.source - ); - return html` - -
    - ${this.hass.localize( - `ui.panel.config.integrations.${ - attention ? "attention" : "discovered" - }` - )} -
    -
    -
    - -
    -

    - ${flow.localized_title} -

    -
    - - ${this.hass.localize( - `ui.panel.config.integrations.${ - attention ? "reconfigure" : "configure" - }` - )} - - ${DISCOVERY_SOURCES.includes(flow.context.source) && - flow.context.unique_id - ? html` - - ${this.hass.localize( - "ui.panel.config.integrations.ignore.ignore" - )} - - ` - : ""} -
    -
    -
    - `; - } + (flow: DataEntryFlowProgressExtended) => html` + + ` ) : ""} ${this._showDisabled @@ -498,25 +418,28 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { .deviceRegistryEntries=${this._deviceRegistryEntries} >` ) - : !this._configEntries.length + : // If we're showing 0 cards, show empty state text + (!this._showIgnored || ignoredConfigEntries.length === 0) && + (!this._showDisabled || disabledConfigEntries.size === 0) && + groupedConfigEntries.size === 0 ? html` - -
    -

    - ${this.hass.localize("ui.panel.config.integrations.none")} -

    -

    - ${this.hass.localize( - "ui.panel.config.integrations.no_integrations" - )} -

    - ${this.hass.localize( - "ui.panel.config.integrations.add_integration" - )} -
    -
    +
    +

    + ${this.hass.localize("ui.panel.config.integrations.none")} +

    +

    + ${this.hass.localize( + "ui.panel.config.integrations.no_integrations" + )} +

    + +
    ` : ""} ${this._filter && @@ -524,7 +447,7 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { !groupedConfigEntries.size && this._configEntries.length ? html` -
    +

    ${this.hass.localize( "ui.panel.config.integrations.none_found" @@ -575,19 +498,40 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { } private async _fetchManifests() { - const manifests = {}; const fetched = await fetchIntegrationManifests(this.hass); + // Make a copy so we can keep track of previously loaded manifests + // for discovered flows (which are not part of these results) + const manifests = { ...this._manifests }; for (const manifest of fetched) manifests[manifest.domain] = manifest; this._manifests = manifests; } - private _handleRemoved(ev: HASSDomEvent) { + private async _fetchManifest(domain: string) { + if (domain in this._manifests) { + return; + } + if (this._extraFetchedManifests) { + if (this._extraFetchedManifests.has(domain)) { + return; + } + } else { + this._extraFetchedManifests = new Set(); + } + this._extraFetchedManifests.add(domain); + const manifest = await fetchIntegrationManifest(this.hass, domain); + this._manifests = { + ...this._manifests, + [domain]: manifest, + }; + } + + private _handleEntryRemoved(ev: HASSDomEvent) { this._configEntries = this._configEntries!.filter( (entry) => entry.entry_id !== ev.detail.entryId ); } - private _handleUpdated(ev: HASSDomEvent) { + private _handleEntryUpdated(ev: HASSDomEvent) { const newEntry = ev.detail.entry; this._configEntries = this._configEntries!.map((entry) => entry.entry_id === newEntry.entry_id @@ -599,6 +543,7 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { private _handleFlowUpdated() { this._loadConfigEntries(); getConfigFlowInProgressCollection(this.hass.connection).refresh(); + this._fetchManifests(); } private _createFlow() { @@ -608,50 +553,14 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { }, showAdvanced: this.showAdvanced, }); - // For config entries. Also loading config flow ones for add integration + // For config entries. Also loading config flow ones for added integration this.hass.loadBackendTranslation("title", undefined, true); } - private _continueFlow(ev: Event) { - showConfigFlowDialog(this, { - continueFlowId: (ev.target! as any).flowId, - dialogClosedCallback: () => { - this._handleFlowUpdated(); - }, - }); - } - - private async _ignoreFlow(ev: Event) { - const flow = (ev.target! as any).flow; - const confirmed = await showConfirmationDialog(this, { - title: this.hass!.localize( - "ui.panel.config.integrations.ignore.confirm_ignore_title", - "name", - localizeConfigFlowTitle(this.hass.localize, flow) - ), - text: this.hass!.localize( - "ui.panel.config.integrations.ignore.confirm_ignore" - ), - confirmText: this.hass!.localize( - "ui.panel.config.integrations.ignore.ignore" - ), - }); - if (!confirmed) { - return; - } - await ignoreConfigFlow( - this.hass, - flow.flow_id, - localizeConfigFlowTitle(this.hass.localize, flow) - ); - this._loadConfigEntries(); - getConfigFlowInProgressCollection(this.hass.connection).refresh(); - } - private _handleMenuAction(ev: CustomEvent) { switch (ev.detail.index) { case 0: - this._toggleShowIgnored(); + this._showIgnored = !this._showIgnored; break; case 1: this._toggleShowDisabled(); @@ -659,54 +568,14 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { } } - private _toggleShowIgnored() { - this._showIgnored = !this._showIgnored; - } - private _toggleShowDisabled() { this._showDisabled = !this._showDisabled; } - private async _removeIgnoredIntegration(ev: Event) { - const entry = (ev.target! as any).entry; - showConfirmationDialog(this, { - title: this.hass!.localize( - "ui.panel.config.integrations.ignore.confirm_delete_ignore_title", - "name", - this.hass.localize(`component.${entry.domain}.title`) - ), - text: this.hass!.localize( - "ui.panel.config.integrations.ignore.confirm_delete_ignore" - ), - confirmText: this.hass!.localize( - "ui.panel.config.integrations.ignore.stop_ignore" - ), - confirm: async () => { - const result = await deleteConfigEntry(this.hass, entry.entry_id); - if (result.require_restart) { - alert( - this.hass.localize( - "ui.panel.config.integrations.config_entry.restart_confirm" - ) - ); - } - this._loadConfigEntries(); - }, - }); - } - private _handleSearchChange(ev: CustomEvent) { this._filter = ev.detail.value; } - private _onImageLoad(ev) { - ev.target.style.visibility = "initial"; - } - - private _onImageError(ev) { - ev.target.style.visibility = "hidden"; - } - private async _highlightEntry() { await nextRender(); const entryId = this._searchParms.get("config_entry")!; @@ -769,66 +638,18 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { padding: 8px 16px 16px; margin-bottom: 64px; } - ha-card { + .container > * { max-width: 500px; - display: flex; - flex-direction: column; - justify-content: space-between; } - .attention { - --ha-card-border-color: var(--error-color); - } - .attention .header { - background: var(--error-color); - color: var(--text-primary-color); - padding: 8px; - text-align: center; - } - .attention mwc-button { - --mdc-theme-primary: var(--error-color); - } - .discovered { - --ha-card-border-color: var(--primary-color); - } - .discovered .header { - background: var(--primary-color); - color: var(--text-primary-color); - padding: 8px; - text-align: center; - } - .ignored { - --ha-card-border-color: var(--light-theme-disabled-color); - } - .ignored img { - filter: grayscale(1); - } - .ignored .header { - background: var(--light-theme-disabled-color); - color: var(--text-primary-color); - padding: 8px; - text-align: center; - } - .card-content { - display: flex; - height: 100%; - margin-top: 0; - padding: 16px; - text-align: center; - flex-direction: column; - justify-content: space-between; - } - .image { - display: flex; - align-items: center; - justify-content: center; - height: 60px; - margin-bottom: 16px; - vertical-align: middle; - } - .none-found { + + .empty-message { margin: auto; text-align: center; } + .empty-message h1 { + margin-bottom: 0; + } + search-input.header { display: block; position: relative; @@ -848,27 +669,7 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) { position: relative; top: 2px; } - img { - max-height: 100%; - max-width: 90%; - } - .none-found { - margin: auto; - text-align: center; - } - h1 { - margin-bottom: 0; - } - h2 { - margin-top: 0; - word-wrap: break-word; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - overflow: hidden; - text-overflow: ellipsis; - white-space: normal; - } + .active-filters { color: var(--primary-text-color); position: relative; diff --git a/src/panels/config/integrations/ha-ignored-config-entry-card.ts b/src/panels/config/integrations/ha-ignored-config-entry-card.ts new file mode 100644 index 0000000000..cbd6c476b7 --- /dev/null +++ b/src/panels/config/integrations/ha-ignored-config-entry-card.ts @@ -0,0 +1,95 @@ +import { + customElement, + LitElement, + property, + css, + html, + TemplateResult, +} from "lit-element"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { deleteConfigEntry } from "../../../data/config_entries"; +import type { IntegrationManifest } from "../../../data/integration"; +import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; +import type { HomeAssistant } from "../../../types"; +import type { ConfigEntryExtended } from "./ha-config-integrations"; +import "./ha-integration-action-card"; + +@customElement("ha-ignored-config-entry-card") +export class HaIgnoredConfigEntryCard extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public entry!: ConfigEntryExtended; + + @property() public manifest?: IntegrationManifest; + + protected render(): TemplateResult { + return html` + + + + `; + } + + private async _removeIgnoredIntegration() { + showConfirmationDialog(this, { + title: this.hass!.localize( + "ui.panel.config.integrations.ignore.confirm_delete_ignore_title", + "name", + this.hass.localize(`component.${this.entry.domain}.title`) + ), + text: this.hass!.localize( + "ui.panel.config.integrations.ignore.confirm_delete_ignore" + ), + confirmText: this.hass!.localize( + "ui.panel.config.integrations.ignore.stop_ignore" + ), + confirm: async () => { + const result = await deleteConfigEntry(this.hass, this.entry.entry_id); + if (result.require_restart) { + alert( + this.hass.localize( + "ui.panel.config.integrations.config_entry.restart_confirm" + ) + ); + } + fireEvent(this, "change", undefined, { + bubbles: false, + }); + }, + }); + } + + static styles = css` + :host { + --state-color: var(--divider-color, #e0e0e0); + } + + mwc-button { + --mdc-theme-primary: var(--primary-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-ignored-config-entry-card": HaIgnoredConfigEntryCard; + } +} diff --git a/src/panels/config/integrations/ha-integration-action-card.ts b/src/panels/config/integrations/ha-integration-action-card.ts new file mode 100644 index 0000000000..2470067684 --- /dev/null +++ b/src/panels/config/integrations/ha-integration-action-card.ts @@ -0,0 +1,77 @@ +import { + TemplateResult, + html, + customElement, + LitElement, + property, + css, +} from "lit-element"; +import type { IntegrationManifest } from "../../../data/integration"; +import type { HomeAssistant } from "../../../types"; +import "./ha-integration-header"; + +@customElement("ha-integration-action-card") +export class HaIntegrationActionCard extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public banner!: string; + + @property() public localizedDomainName?: string; + + @property() public domain!: string; + + @property() public label!: string; + + @property() public manifest?: IntegrationManifest; + + protected render(): TemplateResult { + return html` + + +
    +
    +
    + `; + } + + static styles = css` + ha-card { + display: flex; + flex-direction: column; + height: 100%; + --ha-card-border-color: var(--state-color); + --mdc-theme-primary: var(--state-color); + } + .filler { + flex: 1; + } + .attention { + --state-color: var(--error-color); + --text-on-state-color: var(--text-primary-color); + } + .discovered { + --state-color: var(--primary-color); + --text-on-state-color: var(--text-primary-color); + } + .actions { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 6px 0; + height: 48px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-integration-action-card": HaIntegrationActionCard; + } +} diff --git a/src/panels/config/integrations/ha-integration-card.ts b/src/panels/config/integrations/ha-integration-card.ts index be687830cc..44a4b8cc83 100644 --- a/src/panels/config/integrations/ha-integration-card.ts +++ b/src/panels/config/integrations/ha-integration-card.ts @@ -1,4 +1,8 @@ import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item"; +import "@material/mwc-list/mwc-list-item"; +import "@polymer/paper-listbox"; +import "@material/mwc-button"; +import "@polymer/paper-item"; import "@polymer/paper-tooltip/paper-tooltip"; import { mdiAlertCircle, mdiDotsVertical, mdiOpenInNew } from "@mdi/js"; import { @@ -14,7 +18,9 @@ import { classMap } from "lit-html/directives/class-map"; import { fireEvent } from "../../../common/dom/fire_event"; import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event"; import "../../../components/ha-icon-next"; +import "../../../components/ha-button-menu"; import "../../../components/ha-svg-icon"; +import "../../../components/ha-card"; import { ConfigEntry, deleteConfigEntry, @@ -23,9 +29,9 @@ import { reloadConfigEntry, updateConfigEntry, } from "../../../data/config_entries"; -import { DeviceRegistryEntry } from "../../../data/device_registry"; -import { EntityRegistryEntry } from "../../../data/entity_registry"; -import { domainToName, IntegrationManifest } from "../../../data/integration"; +import type { DeviceRegistryEntry } from "../../../data/device_registry"; +import type { EntityRegistryEntry } from "../../../data/entity_registry"; +import type { IntegrationManifest } from "../../../data/integration"; import { showConfigEntrySystemOptionsDialog } from "../../../dialogs/config-entry-system-options/show-dialog-config-entry-system-options"; import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow"; import { @@ -34,51 +40,23 @@ import { showPromptDialog, } from "../../../dialogs/generic/show-dialog-box"; import { haStyle } from "../../../resources/styles"; -import { HomeAssistant } from "../../../types"; -import { brandsUrl } from "../../../util/brands-url"; -import { ConfigEntryExtended } from "./ha-config-integrations"; +import type { HomeAssistant } from "../../../types"; +import type { ConfigEntryExtended } from "./ha-config-integrations"; +import "./ha-integration-header"; -export interface ConfigEntryUpdatedEvent { - entry: ConfigEntry; -} - -export interface ConfigEntryRemovedEvent { - entryId: string; -} - -declare global { - // for fire event - interface HASSDomEvents { - "entry-updated": ConfigEntryUpdatedEvent; - "entry-removed": ConfigEntryRemovedEvent; - } -} +const ERROR_STATES: ConfigEntry["state"][] = [ + "migration_error", + "setup_error", + "setup_retry", +]; const integrationsWithPanel = { - hassio: { - buttonLocalizeKey: "ui.panel.config.hassio.button", - path: "/hassio/dashboard", - }, - mqtt: { - buttonLocalizeKey: "ui.panel.config.mqtt.button", - path: "/config/mqtt", - }, - zha: { - buttonLocalizeKey: "ui.panel.config.zha.button", - path: "/config/zha/dashboard", - }, - ozw: { - buttonLocalizeKey: "ui.panel.config.ozw.button", - path: "/config/ozw/dashboard", - }, - zwave: { - buttonLocalizeKey: "ui.panel.config.zwave.button", - path: "/config/zwave", - }, - zwave_js: { - buttonLocalizeKey: "ui.panel.config.zwave_js.button", - path: "/config/zwave_js/dashboard", - }, + hassio: "/hassio/dashboard", + mqtt: "/config/mqtt", + zha: "/config/zha/dashboard", + ozw: "/config/ozw/dashboard", + zwave: "/config/zwave", + zwave_js: "/config/zwave_js/dashboard", }; @customElement("ha-integration-card") @@ -89,7 +67,7 @@ export class HaIntegrationCard extends LitElement { @property() public items!: ConfigEntryExtended[]; - @property() public manifest!: IntegrationManifest; + @property() public manifest?: IntegrationManifest; @property() public entityRegistryEntries!: EntityRegistryEntry[]; @@ -99,80 +77,97 @@ export class HaIntegrationCard extends LitElement { @property({ type: Boolean }) public disabled = false; - firstUpdated(changedProps) { - super.firstUpdated(changedProps); - } - protected render(): TemplateResult { + let item = this._selectededConfigEntry; + if (this.items.length === 1) { - return this._renderSingleEntry(this.items[0]); - } - if (this.selectedConfigEntryId) { - const configEntry = this.items.find( + item = this.items[0]; + } else if (this.selectedConfigEntryId) { + item = this.items.find( (entry) => entry.entry_id === this.selectedConfigEntryId ); - if (configEntry) { - return this._renderSingleEntry(configEntry); - } } - return this._renderGroupedIntegration(); + + const hasItem = item !== undefined; + + return html` + + + ${this.items.length > 1 + ? html` +
    + +
    + ` + : ""} +
    + + ${item + ? this._renderSingleEntry(item) + : this._renderGroupedIntegration()} +
    + `; } private _renderGroupedIntegration(): TemplateResult { return html` - - ${this.disabled - ? html`
    - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.disable.disabled" - )} -
    ` - : ""} -
    - -

    - ${domainToName(this.hass.localize, this.domain)} -

    -
    - - ${this.items.map( - (item) => - html`${item.title || - this.hass.localize( - "ui.panel.config.integrations.config_entry.unnamed_entry" - )} - ${item.state === "not_loaded" - ? html` - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.not_loaded", - "logs_link", - this.hass.localize( - "ui.panel.config.integrations.config_entry.logs" - ) - )} - - ` - : ""} - - ` - )} - -
    + + ${this.items.map( + (item) => + html`${item.title || + this.hass.localize( + "ui.panel.config.integrations.config_entry.unnamed_entry" + )} + ${ERROR_STATES.includes(item.state) + ? html` + + ${this.hass.localize( + `ui.panel.config.integrations.config_entry.state.${item.state}` + )} + + ` + : ""} + + ` + )} + `; } @@ -181,209 +176,220 @@ export class HaIntegrationCard extends LitElement { const services = this._getServices(item); const entities = this._getEntities(item); + let stateText: [string, ...unknown[]] | undefined; + let stateTextExtra: TemplateResult | string | undefined; + + if (item.disabled_by) { + stateText = [ + "ui.panel.config.integrations.config_entry.disable.disabled_cause", + "cause", + this.hass.localize( + `ui.panel.config.integrations.config_entry.disable.disabled_by.${item.disabled_by}` + ) || item.disabled_by, + ]; + if (item.state === "failed_unload") { + stateTextExtra = html`. + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.disable_restart_confirm" + )}.`; + } + } else if (item.state === "not_loaded") { + stateText = ["ui.panel.config.integrations.config_entry.not_loaded"]; + } else if (ERROR_STATES.includes(item.state)) { + stateText = [ + `ui.panel.config.integrations.config_entry.state.${item.state}`, + ]; + if (item.reason) { + this.hass.loadBackendTranslation("config", item.domain); + stateTextExtra = html`: + ${this.hass.localize( + `component.${item.domain}.config.error.${item.reason}` + ) || item.reason}`; + } else { + stateTextExtra = html` +
    + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.check_the_logs" + )} + `; + } + } + return html` - - ${this.items.length > 1 - ? html`` + ${stateText + ? html` +
    + +
    + ${this.hass.localize(...stateText)}${stateTextExtra} +
    +
    + ` + : ""} +
    + ${devices.length || services.length || entities.length + ? html` +
    + ${devices.length + ? html` + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.devices", + "count", + devices.length + )}${services.length ? "," : ""} + ` + : ""} + ${services.length + ? html` + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.services", + "count", + services.length + )} + ` + : ""} + ${(devices.length || services.length) && entities.length + ? this.hass.localize("ui.common.and") + : ""} + ${entities.length + ? html` + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.entities", + "count", + entities.length + )} + ` + : ""} +
    + ` : ""} - ${item.disabled_by - ? html`
    - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.disable.disabled_cause", - "cause", - this.hass.localize( - `ui.panel.config.integrations.config_entry.disable.disabled_by.${item.disabled_by}` - ) || item.disabled_by - )} -
    ` - : item.state === "not_loaded" - ? html`
    - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.not_loaded", - "logs_link", - html`${this.hass.localize( - "ui.panel.config.integrations.config_entry.logs" - )}` - )} -
    ` - : ""} -
    -
    - -
    -

    - ${item.localized_domain_name} -

    -

    - ${item.localized_domain_name === item.title ? "" : item.title} -

    - ${devices.length || services.length || entities.length +
    +
    +
    + ${item.disabled_by === "user" + ? html` + ${this.hass.localize("ui.common.enable")} + ` + : item.domain in integrationsWithPanel + ? html` + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.configure" + )} + ` + : item.supports_options ? html` -
    - ${devices.length - ? html` - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.devices", - "count", - devices.length - )}${services.length ? "," : ""} - ` - : ""} - ${services.length - ? html` - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.services", - "count", - services.length - )} - ` - : ""} - ${(devices.length || services.length) && entities.length - ? this.hass.localize("ui.common.and") - : ""} - ${entities.length - ? html` - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.entities", - "count", - entities.length - )} - ` - : ""} -
    + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.configure" + )} + ` : ""}
    -
    -
    - ${item.disabled_by === "user" - ? html` - ${this.hass.localize("ui.common.enable")} - ` - : ""} - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.rename" - )} - - ${item.domain in integrationsWithPanel - ? html` + ${!this.manifest + ? "" + : html` + + + + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.rename" + )} + + + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.system_options" + )} + + + + ${this.hass.localize( - integrationsWithPanel[item.domain].buttonLocalizeKey - )} - ` - : item.supports_options - ? html` - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.options" - )} - - ` - : ""} -
    - - - - - - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.system_options" - )} - - ${!this.manifest - ? "" - : html` - - + "ui.panel.config.integrations.config_entry.documentation" + )} + + + ${!item.disabled_by && + item.state === "loaded" && + item.supports_unload && + item.source !== "system" + ? html` ${this.hass.localize( - "ui.panel.config.integrations.config_entry.documentation" - )} - - - `} - ${!item.disabled_by && - item.state === "loaded" && - item.supports_unload && - item.source !== "system" - ? html` - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.reload" - )} - ` - : ""} - ${item.disabled_by === "user" - ? html` - ${this.hass.localize("ui.common.enable")} - ` - : item.source !== "system" - ? html` - ${this.hass.localize("ui.common.disable")} - ` - : ""} - ${item.source !== "system" - ? html` - ${this.hass.localize( - "ui.panel.config.integrations.config_entry.delete" - )} - ` - : ""} - -
    - + "ui.panel.config.integrations.config_entry.reload" + )} + ` + : ""} + ${item.disabled_by === "user" + ? html` + ${this.hass.localize("ui.common.enable")} + ` + : item.source !== "system" + ? html` + ${this.hass.localize("ui.common.disable")} + ` + : ""} + ${item.source !== "system" + ? html` + ${this.hass.localize( + "ui.panel.config.integrations.config_entry.delete" + )} + ` + : ""} + + `} +
    `; } + private get _selectededConfigEntry(): ConfigEntryExtended | undefined { + return this.items.length === 1 + ? this.items[0] + : this.selectedConfigEntryId + ? this.items.find( + (entry) => entry.entry_id === this.selectedConfigEntryId + ) + : undefined; + } + private _selectConfigEntry(ev: Event) { this.selectedConfigEntryId = (ev.currentTarget as any).entryId; } @@ -424,14 +430,6 @@ export class HaIntegrationCard extends LitElement { ); } - private _onImageLoad(ev) { - ev.target.style.visibility = "initial"; - } - - private _onImageError(ev) { - ev.target.style.visibility = "hidden"; - } - private _showOptions(ev) { showOptionsFlowDialog(this, ev.target.closest("ha-card").configEntry); } @@ -589,123 +587,115 @@ export class HaIntegrationCard extends LitElement { return [ haStyle, css` - :host { - max-width: 500px; - } ha-card { display: flex; flex-direction: column; height: 100%; + --state-color: var(--divider-color, #e0e0e0); + --ha-card-border-color: var(--state-color); + --state-message-color: var(--state-color); } - ha-card.single { - justify-content: space-between; + .state-error { + --state-color: var(--error-color); + --text-on-state-color: var(--text-primary-color); + } + .state-failed-unload { + --state-color: var(--warning-color); + --text-on-state-color: var(--primary-text-color); + } + .state-not-loaded { + --state-message-color: var(--primary-text-color); } :host(.highlight) ha-card { - border: 1px solid var(--accent-color); + --state-color: var(--primary-color); + --text-on-state-color: var(--text-primary-color); } - .disabled { - --ha-card-border-color: var(--warning-color); + + .back-btn { + background-color: var(--state-color); + color: var(--text-on-state-color); + --mdc-icon-button-size: 32px; + transition: height 0.1s; + overflow: hidden; } - .not-loaded { - --ha-card-border-color: var(--error-color); + .hasMultiple.single .back-btn { + height: 24px; + display: flex; + align-items: center; } - .header { - padding: 8px; - text-align: center; + .hasMultiple.group .back-btn { + height: 0px; } - .disabled .header { - background: var(--warning-color); - color: var(--text-primary-color); + + .message { + font-weight: bold; + padding-bottom: 16px; + display: flex; + margin-left: 40px; } - .not-loaded .header { - background: var(--error-color); - color: var(--text-primary-color); + .message ha-svg-icon { + color: var(--state-message-color); } - .not-loaded .header a { - color: var(--text-primary-color); + .message div { + flex: 1; + margin-left: 8px; + padding-top: 2px; } - .card-content { - padding: 16px; - text-align: center; + + .content { + flex: 1; + padding: 0px 16px 0 72px; } - ha-card.integration .card-content { - padding-bottom: 3px; - } - .card-actions { - border-top: none; + + .actions { display: flex; justify-content: space-between; align-items: center; - padding-right: 5px; + padding: 8px 0 0 8px; + height: 48px; } - .group-header { - display: flex; - align-items: center; - height: 40px; - padding: 16px 16px 8px 16px; - justify-content: center; - } - .group-header h1 { - margin: 0; - } - .group-header img { - margin-right: 8px; - } - .image { - display: flex; - align-items: center; - justify-content: center; - height: 60px; - margin-bottom: 16px; - vertical-align: middle; - } - img { - max-height: 100%; - max-width: 90%; - } - .none-found { - margin: auto; - text-align: center; + .actions a { + text-decoration: none; } a { color: var(--primary-color); } - h1 { - margin-bottom: 0; - } - h2 { - min-height: 24px; - } - h3 { - word-wrap: break-word; - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - overflow: hidden; - text-overflow: ellipsis; - } ha-button-menu { color: var(--secondary-text-color); --mdc-menu-min-width: 200px; } @media (min-width: 563px) { + ha-card.group { + position: relative; + min-height: 164px; + } paper-listbox { - max-height: 150px; + position: absolute; + top: 64px; + left: 0; + right: 0; + bottom: 0; overflow: auto; } + .disabled paper-listbox { + top: 88px; + } } paper-item { cursor: pointer; min-height: 35px; } + paper-item-body { + word-wrap: break-word; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + text-overflow: ellipsis; + } mwc-list-item ha-svg-icon { color: var(--secondary-text-color); } - .back-btn { - position: absolute; - background: rgba(var(--rgb-card-background-color), 0.6); - border-radius: 50%; - } `, ]; } diff --git a/src/panels/config/integrations/ha-integration-header.ts b/src/panels/config/integrations/ha-integration-header.ts new file mode 100644 index 0000000000..6ace567a6d --- /dev/null +++ b/src/panels/config/integrations/ha-integration-header.ts @@ -0,0 +1,176 @@ +import { mdiPackageVariant, mdiCloud } from "@mdi/js"; +import "@polymer/paper-tooltip/paper-tooltip"; +import { + css, + html, + customElement, + property, + LitElement, + TemplateResult, +} from "lit-element"; +import { domainToName, IntegrationManifest } from "../../../data/integration"; +import { HomeAssistant } from "../../../types"; +import { brandsUrl } from "../../../util/brands-url"; + +@customElement("ha-integration-header") +export class HaIntegrationHeader extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public banner!: string; + + @property() public localizedDomainName?: string; + + @property() public domain!: string; + + @property() public label!: string; + + @property() public manifest?: IntegrationManifest; + + protected render(): TemplateResult { + let primary: string; + let secondary: string | undefined; + + const domainName = + this.localizedDomainName || + domainToName(this.hass.localize, this.domain, this.manifest); + + if (this.label) { + primary = this.label; + secondary = primary === domainName ? undefined : domainName; + } else { + primary = domainName; + } + + const icons: [string, string][] = []; + + if (this.manifest) { + if (!this.manifest.is_built_in) { + icons.push([ + mdiPackageVariant, + this.hass.localize( + "ui.panel.config.integrations.config_entry.provided_by_custom_integration" + ), + ]); + } + + if ( + this.manifest.iot_class && + this.manifest.iot_class.startsWith("cloud_") + ) { + icons.push([ + mdiCloud, + this.hass.localize( + "ui.panel.config.integrations.config_entry.depends_on_cloud" + ), + ]); + } + } + + return html` + ${!this.banner + ? "" + : html``} + +
    + +
    +
    ${primary}
    + ${secondary ? html`
    ${secondary}
    ` : ""} +
    + ${icons.length === 0 + ? "" + : html` +
    + ${icons.map( + ([icon, description]) => html` + + + ${description} + + ` + )} +
    + `} +
    + `; + } + + private _onImageLoad(ev) { + ev.target.style.visibility = "initial"; + } + + private _onImageError(ev) { + ev.target.style.visibility = "hidden"; + } + + static styles = css` + .banner { + background-color: var(--state-color); + color: var(--text-on-state-color); + text-align: center; + padding: 2px; + } + .header { + display: flex; + position: relative; + padding: 16px 8px 8px 16px; + } + .header img { + margin-right: 16px; + width: 40px; + height: 40px; + } + .header .info { + align-self: center; + } + .header .info div { + word-wrap: break-word; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + text-overflow: ellipsis; + } + .primary { + font-size: 16px; + font-weight: 400; + color: var(--primary-text-color); + } + .secondary { + font-size: 14px; + color: var(--secondary-text-color); + } + .icons { + position: absolute; + top: 0px; + right: 16px; + color: var(--text-on-state-color, var(--secondary-text-color)); + background-color: var(--state-color, #e0e0e0); + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + padding: 1px 4px 2px; + } + .icons ha-svg-icon { + width: 20px; + height: 20px; + } + paper-tooltip { + white-space: nowrap; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-integration-header": HaIntegrationHeader; + } +} diff --git a/src/panels/config/integrations/integration-panels/zha/dialog-zha-device-children.ts b/src/panels/config/integrations/integration-panels/zha/dialog-zha-device-children.ts index 31869775cc..135776a74a 100644 --- a/src/panels/config/integrations/integration-panels/zha/dialog-zha-device-children.ts +++ b/src/panels/config/integrations/integration-panels/zha/dialog-zha-device-children.ts @@ -76,9 +76,7 @@ class DialogZHADeviceChildren extends LitElement { }, }; - public showDialog( - params: ZHADeviceChildrenDialogParams - ): void { + public showDialog(params: ZHADeviceChildrenDialogParams): void { this._device = params.device; this._fetchData(); } diff --git a/src/panels/config/integrations/integration-panels/zha/dialog-zha-reconfigure-device.ts b/src/panels/config/integrations/integration-panels/zha/dialog-zha-reconfigure-device.ts index 8e29d5af94..e32e984729 100644 --- a/src/panels/config/integrations/integration-panels/zha/dialog-zha-reconfigure-device.ts +++ b/src/panels/config/integrations/integration-panels/zha/dialog-zha-reconfigure-device.ts @@ -8,42 +8,62 @@ import { property, TemplateResult, } from "lit-element"; -import { createCloseHeading } from "../../../../../components/ha-dialog"; +import { mdiCheckCircle, mdiCloseCircle } from "@mdi/js"; +import "@material/mwc-button/mwc-button"; import { haStyleDialog } from "../../../../../resources/styles"; import { HomeAssistant } from "../../../../../types"; import { ZHAReconfigureDeviceDialogParams } from "./show-dialog-zha-reconfigure-device"; -import { IronAutogrowTextareaElement } from "@polymer/iron-autogrow-textarea"; -import "@polymer/paper-input/paper-textarea"; import "../../../../../components/ha-circular-progress"; -import { LOG_OUTPUT, reconfigureNode } from "../../../../../data/zha"; +import "../../../../../components/ha-svg-icon"; +import { + AttributeConfigurationStatus, + Cluster, + ClusterConfigurationEvent, + ClusterConfigurationStatus, + fetchClustersForZhaNode, + reconfigureNode, + ZHA_CHANNEL_CFG_DONE, + ZHA_CHANNEL_MSG_BIND, + ZHA_CHANNEL_MSG_CFG_RPT, +} from "../../../../../data/zha"; import { fireEvent } from "../../../../../common/dom/fire_event"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { createCloseHeading } from "../../../../../components/ha-dialog"; @customElement("dialog-zha-reconfigure-device") class DialogZHAReconfigureDevice extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @internalProperty() private _active = false; + @internalProperty() private _status?: string; - @internalProperty() private _formattedEvents = ""; + @internalProperty() private _stages?: string[]; - @internalProperty() - private _params: ZHAReconfigureDeviceDialogParams | undefined = undefined; + @internalProperty() private _clusterConfigurationStatuses?: Map< + number, + ClusterConfigurationStatus + > = new Map(); - private _subscribed?: Promise<() => Promise>; + @internalProperty() private _params: + | ZHAReconfigureDeviceDialogParams + | undefined = undefined; - private _reconfigureDeviceTimeoutHandle: any = undefined; + @internalProperty() private _allSuccessful = true; - public async showDialog( - params: ZHAReconfigureDeviceDialogParams - ): Promise { + @internalProperty() private _showDetails = false; + + private _subscribed?: Promise; + + public showDialog(params: ZHAReconfigureDeviceDialogParams): void { this._params = params; - this._subscribe(params); + this._stages = undefined; } public closeDialog(): void { this._unsubscribe(); - this._formattedEvents = ""; this._params = undefined; + this._status = undefined; + this._stages = undefined; + this._clusterConfigurationStatuses = undefined; fireEvent(this, "dialog-closed", { dialog: this.localName }); } @@ -51,58 +71,311 @@ class DialogZHAReconfigureDevice extends LitElement { if (!this._params) { return html``; } + return html` -
    - ${this._active - ? html` -

    - ${this._params?.device.user_given_name || - this._params?.device.name} -

    - - ` - : ""} -
    - - + ${!this._status + ? html` +

    + ${this.hass.localize( + "ui.dialogs.zha_reconfigure_device.introduction" + )} +

    +

    + + ${this.hass.localize( + "ui.dialogs.zha_reconfigure_device.battery_device_warning" + )} + +

    + + ${this.hass.localize( + "ui.dialogs.zha_reconfigure_device.start_reconfiguration" + )} + + ` + : ``} + ${this._status === "started" + ? html` +
    + +
    +

    + + ${this.hass.localize( + "ui.dialogs.zha_reconfigure_device.in_progress" + )} + +

    +

    + ${this.hass.localize( + "ui.dialogs.zha_reconfigure_device.run_in_background" + )} +

    +
    +
    + + ${this.hass.localize("ui.dialogs.generic.close")} + + + ${this._showDetails + ? this.hass.localize( + `ui.dialogs.zha_reconfigure_device.button_hide` + ) + : this.hass.localize( + `ui.dialogs.zha_reconfigure_device.button_show` + )} + + ` + : ``} + ${this._status === "failed" + ? html` +
    + +
    +

    + ${this.hass.localize( + "ui.dialogs.zha_reconfigure_device.configuration_failed" + )} +

    +
    +
    + + ${this.hass.localize("ui.dialogs.generic.close")} + + + ${this._showDetails + ? this.hass.localize( + `ui.dialogs.zha_reconfigure_device.button_hide` + ) + : this.hass.localize( + `ui.dialogs.zha_reconfigure_device.button_show` + )} + + ` + : ``} + ${this._status === "finished" + ? html` +
    + +
    +

    + ${this.hass.localize( + "ui.dialogs.zha_reconfigure_device.configuration_complete" + )} +

    +
    +
    + + ${this.hass.localize("ui.dialogs.generic.close")} + + + ${this._showDetails + ? this.hass.localize( + `ui.dialogs.zha_reconfigure_device.button_hide` + ) + : this.hass.localize( + `ui.dialogs.zha_reconfigure_device.button_show` + )} + + ` + : ``} + ${this._stages + ? html` +
    + ${this._stages.map( + (stage) => html` + + + ${stage} + + ` + )} +
    + ` + : ""} + ${this._showDetails + ? html` +
    +

    + ${this.hass.localize( + `ui.dialogs.zha_reconfigure_device.cluster_header` + )} +

    +

    + ${this.hass.localize( + `ui.dialogs.zha_reconfigure_device.bind_header` + )} +

    +

    + ${this.hass.localize( + `ui.dialogs.zha_reconfigure_device.reporting_header` + )} +

    + + ${this._clusterConfigurationStatuses!.size > 0 + ? html` + ${Array.from( + this._clusterConfigurationStatuses!.values() + ).map( + (clusterStatus) => html` +
    + ${clusterStatus.cluster.name} +
    +
    + ${clusterStatus.bindSuccess !== undefined + ? clusterStatus.bindSuccess + ? html` + + + + ` + : html` + + + + ` + : ""} +
    +
    + ${clusterStatus.attributes.size > 0 + ? html` +
    +
    + ${this.hass.localize( + `ui.dialogs.zha_reconfigure_device.attribute` + )} +
    +
    +
    + ${this.hass.localize( + `ui.dialogs.zha_reconfigure_device.min_max_change` + )} +
    +
    + ${Array.from( + clusterStatus.attributes.values() + ).map( + (attribute) => html` + + ${attribute.name}: + ${attribute.success + ? html` + + + + ` + : html` + + + + `} + +
    + ${attribute.min}/${attribute.max}/${attribute.change} +
    + ` + )} +
    + ` + : ""} +
    + ` + )} + ` + : ""} +
    + ` + : ""}
    `; } - private _handleMessage(message: any): void { - if (message.type === LOG_OUTPUT) { - this._formattedEvents += message.log_entry.message + "\n"; - const paperTextArea = this.shadowRoot!.querySelector("paper-textarea"); - if (paperTextArea) { - const textArea = (paperTextArea.inputElement as IronAutogrowTextareaElement) - .textarea; - textArea.scrollTop = textArea.scrollHeight; + private async _startReconfiguration(): Promise { + if (!this.hass || !this._params) { + return; + } + this._clusterConfigurationStatuses = new Map( + (await fetchClustersForZhaNode(this.hass, this._params.device.ieee)).map( + (cluster: Cluster) => [ + cluster.id, + { + cluster: cluster, + bindSuccess: undefined, + attributes: new Map(), + }, + ] + ) + ); + this._subscribe(this._params); + this._status = "started"; + } + + private _handleMessage(message: ClusterConfigurationEvent): void { + if (message.type === ZHA_CHANNEL_CFG_DONE) { + this._unsubscribe(); + this._status = this._allSuccessful ? "finished" : "failed"; + } else { + const clusterConfigurationStatus = this._clusterConfigurationStatuses!.get( + message.zha_channel_msg_data.cluster_id + ); + if (message.type === ZHA_CHANNEL_MSG_BIND) { + if (!this._stages) { + this._stages = ["binding"]; + } + const success = message.zha_channel_msg_data.success; + clusterConfigurationStatus!.bindSuccess = success; + this._allSuccessful = this._allSuccessful && success; } + if (message.type === ZHA_CHANNEL_MSG_CFG_RPT) { + if (this._stages && !this._stages.includes("reporting")) { + this._stages.push("reporting"); + } + const attributes = message.zha_channel_msg_data.attributes; + Object.keys(attributes).forEach((name) => { + const attribute = attributes[name]; + clusterConfigurationStatus!.attributes.set(attribute.id, attribute); + this._allSuccessful = this._allSuccessful && attribute.success; + }); + } + this.requestUpdate(); } } private _unsubscribe(): void { - this._active = false; - if (this._reconfigureDeviceTimeoutHandle) { - clearTimeout(this._reconfigureDeviceTimeoutHandle); - } if (this._subscribed) { this._subscribed.then((unsub) => unsub()); this._subscribed = undefined; @@ -113,33 +386,66 @@ class DialogZHAReconfigureDevice extends LitElement { if (!this.hass) { return; } - this._active = true; this._subscribed = reconfigureNode( this.hass, params.device.ieee, this._handleMessage.bind(this) ); - this._reconfigureDeviceTimeoutHandle = setTimeout( - () => this._unsubscribe(), - 60000 - ); + } + + private _toggleDetails() { + this._showDetails = !this._showDetails; } static get styles(): CSSResult[] { return [ haStyleDialog, css` - ha-circular-progress { - padding: 20px; + .wrapper { + display: grid; + grid-template-columns: 3fr 1fr 2fr; } - .searching { - margin-top: 20px; + .attributes { + display: grid; + grid-template-columns: 1fr 1fr; + } + .grid-item { + border: 1px solid; + padding: 7px; + } + .success { + color: var(--success-color); + } + + .failed { + color: var(--warning-color); + } + + .flex-container { display: flex; - flex-direction: column; align-items: center; } - .log { - padding: 16px; + + .stages { + margin-top: 16px; + } + + .stage ha-svg-icon { + width: 16px; + height: 16px; + } + .stage { + padding: 8px; + } + + ha-svg-icon { + width: 68px; + height: 48px; + } + + .flex-container ha-circular-progress, + .flex-container ha-svg-icon { + margin-right: 20px; } `, ]; diff --git a/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard.ts b/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard.ts index a1fcbf1bbf..5fc0a889f9 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-config-dashboard.ts @@ -9,6 +9,7 @@ import { html, LitElement, property, + PropertyValues, TemplateResult, } from "lit-element"; import { computeRTL } from "../../../../../common/util/compute_rtl"; @@ -20,6 +21,12 @@ import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage"; import { haStyle } from "../../../../../resources/styles"; import type { HomeAssistant, Route } from "../../../../../types"; import "../../../ha-config-section"; +import "../../../../../components/ha-form/ha-form"; +import { + fetchZHAConfiguration, + updateZHAConfiguration, + ZHAConfiguration, +} from "../../../../../data/zha"; export const zhaTabs: PageNavigation[] = [ { @@ -51,6 +58,15 @@ class ZHAConfigDashboard extends LitElement { @property() public configEntryId?: string; + @property() private _configuration?: ZHAConfiguration; + + protected firstUpdated(changedProperties: PropertyValues): void { + super.firstUpdated(changedProperties); + if (this.hass) { + this._fetchConfiguration(); + } + } + protected render(): TemplateResult { return html` - -
    - In the future you can change network settings for ZHA here. -
    + ${this.configEntryId ? html`
    ` : ""} + ${this._configuration + ? Object.entries(this._configuration.schemas).map( + ([section, schema]) => html` +
    + +
    +
    ` + ) + : ""} + +
    + + ${this.hass.localize( + "ui.panel.config.zha.configuration_page.update_button" + )} + +
    +
    +
    { + this._configuration = await fetchZHAConfiguration(this.hass!); + } + + private _dataChanged(ev) { + this._configuration!.data[ev.currentTarget!.section] = ev.detail.value; + } + + private async _updateConfiguration(): Promise { + await updateZHAConfiguration(this.hass!, this._configuration!.data); + } + + private _computeLabelCallback(localize, section: string) { + // Returns a callback for ha-form to calculate labels per schema object + return (schema) => + localize( + `ui.panel.config.zha.configuration_page.${section}.${schema.name}` + ) || schema.name; + } + static get styles(): CSSResultArray { return [ haStyle, diff --git a/src/panels/config/integrations/integration-panels/zha/zha-device-card.ts b/src/panels/config/integrations/integration-panels/zha/zha-device-card.ts index 0cf283011e..c810a397ee 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-device-card.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-device-card.ts @@ -159,7 +159,7 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) { } if (!newName && !newEntityId) { - return new Promise((resolve) => resolve()); + return undefined; } return updateEntityRegistryEntry(this.hass!, entity.entity_id, { @@ -177,7 +177,7 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) { }); } - private _computeEntityName(entity: EntityRegistryEntry): string { + private _computeEntityName(entity: EntityRegistryEntry): string | null { if (this.hass.states[entity.entity_id]) { return computeStateName(this.hass.states[entity.entity_id]); } diff --git a/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts b/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts index 382f53e0c4..c1937537c1 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts @@ -17,8 +17,8 @@ import { refreshTopology, ZHADevice, } from "../../../../../data/zha"; -import "../../../../../layouts/hass-subpage"; -import type { HomeAssistant } from "../../../../../types"; +import "../../../../../layouts/hass-tabs-subpage"; +import type { HomeAssistant, Route } from "../../../../../types"; import { Network, Edge, Node, EdgeOptions } from "vis-network"; import "../../../../../common/search/search-input"; import "../../../../../components/device/ha-device-picker"; @@ -29,12 +29,17 @@ import { formatAsPaddedHex } from "./functions"; import { DeviceRegistryEntry } from "../../../../../data/device_registry"; import "../../../../../components/ha-checkbox"; import type { HaCheckbox } from "../../../../../components/ha-checkbox"; +import { zhaTabs } from "./zha-config-dashboard"; @customElement("zha-network-visualization-page") export class ZHANetworkVisualizationPage extends LitElement { - @property({ type: Object }) public hass!: HomeAssistant; + @property({ attribute: false }) public hass!: HomeAssistant; - @property({ type: Boolean, reflect: true }) public narrow = false; + @property({ attribute: false }) public route!: Route; + + @property({ type: Boolean }) public narrow!: boolean; + + @property({ type: Boolean }) public isWide!: boolean; @property() public zoomedDeviceId?: string; @@ -133,9 +138,12 @@ export class ZHANetworkVisualizationPage extends LitElement { protected render() { return html` -
    - +
    `; } diff --git a/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-reinterview-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-reinterview-node.ts new file mode 100644 index 0000000000..7dfc9a999a --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/dialog-zwave_js-reinterview-node.ts @@ -0,0 +1,262 @@ +import "@material/mwc-button/mwc-button"; +import { mdiCheckCircle, mdiCloseCircle } from "@mdi/js"; +import { + CSSResult, + customElement, + html, + LitElement, + property, + internalProperty, + TemplateResult, + css, +} from "lit-element"; +import "../../../../../components/ha-circular-progress"; +import { createCloseHeading } from "../../../../../components/ha-dialog"; +import { haStyleDialog } from "../../../../../resources/styles"; +import { HomeAssistant } from "../../../../../types"; +import { ZWaveJSReinterviewNodeDialogParams } from "./show-dialog-zwave_js-reinterview-node"; +import { fireEvent } from "../../../../../common/dom/fire_event"; +import { UnsubscribeFunc } from "home-assistant-js-websocket"; +import { reinterviewNode } from "../../../../../data/zwave_js"; + +@customElement("dialog-zwave_js-reinterview-node") +class DialogZWaveJSReinterviewNode extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @internalProperty() private entry_id?: string; + + @internalProperty() private node_id?: number; + + @internalProperty() private _status?: string; + + @internalProperty() private _stages?: string[]; + + private _subscribed?: Promise; + + public async showDialog( + params: ZWaveJSReinterviewNodeDialogParams + ): Promise { + this._stages = undefined; + this.entry_id = params.entry_id; + this.node_id = params.node_id; + } + + protected render(): TemplateResult { + if (!this.entry_id) { + return html``; + } + + return html` + + ${!this._status + ? html` +

    + ${this.hass.localize( + "ui.panel.config.zwave_js.reinterview_node.introduction" + )} +

    +

    + + ${this.hass.localize( + "ui.panel.config.zwave_js.reinterview_node.battery_device_warning" + )} + +

    + + ${this.hass.localize( + "ui.panel.config.zwave_js.reinterview_node.start_reinterview" + )} + + ` + : ``} + ${this._status === "started" + ? html` +
    + +
    +

    + + ${this.hass.localize( + "ui.panel.config.zwave_js.reinterview_node.in_progress" + )} + +

    +

    + ${this.hass.localize( + "ui.panel.config.zwave_js.reinterview_node.run_in_background" + )} +

    +
    +
    + + ${this.hass.localize("ui.panel.config.zwave_js.common.close")} + + ` + : ``} + ${this._status === "failed" + ? html` +
    + +
    +

    + ${this.hass.localize( + "ui.panel.config.zwave_js.reinterview_node.interview_failed" + )} +

    +
    +
    + + ${this.hass.localize("ui.panel.config.zwave_js.common.close")} + + ` + : ``} + ${this._status === "finished" + ? html` +
    + +
    +

    + ${this.hass.localize( + "ui.panel.config.zwave_js.reinterview_node.interview_complete" + )} +

    +
    +
    + + ${this.hass.localize("ui.panel.config.zwave_js.common.close")} + + ` + : ``} + ${this._stages + ? html` +
    + ${this._stages.map( + (stage) => html` + + + ${stage} + + ` + )} +
    + ` + : ""} +
    + `; + } + + private _startReinterview(): void { + if (!this.hass) { + return; + } + this._subscribed = reinterviewNode( + this.hass, + this.entry_id!, + this.node_id!, + this._handleMessage.bind(this) + ); + } + + private _handleMessage(message: any): void { + if (message.event === "interview started") { + this._status = "started"; + } + if (message.event === "interview stage completed") { + if (this._stages === undefined) { + this._stages = [message.stage]; + } else { + this._stages = [...this._stages, message.stage]; + } + } + if (message.event === "interview failed") { + this._unsubscribe(); + this._status = "failed"; + } + if (message.event === "interview completed") { + this._unsubscribe(); + this._status = "finished"; + } + } + + private _unsubscribe(): void { + if (this._subscribed) { + this._subscribed.then((unsub) => unsub()); + this._subscribed = undefined; + } + } + + public closeDialog(): void { + this.entry_id = undefined; + this.node_id = undefined; + this._status = undefined; + this._stages = undefined; + + this._unsubscribe(); + + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + static get styles(): CSSResult[] { + return [ + haStyleDialog, + css` + .success { + color: var(--success-color); + } + + .failed { + color: var(--warning-color); + } + + .flex-container { + display: flex; + align-items: center; + } + + .stages { + margin-top: 16px; + } + + .stage ha-svg-icon { + width: 16px; + height: 16px; + } + .stage { + padding: 8px; + } + + ha-svg-icon { + width: 68px; + height: 48px; + } + + .flex-container ha-circular-progress, + .flex-container ha-svg-icon { + margin-right: 20px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-zwave_js-reinterview-node": DialogZWaveJSReinterviewNode; + } +} diff --git a/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node.ts b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node.ts new file mode 100644 index 0000000000..7755786750 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node.ts @@ -0,0 +1,20 @@ +import { fireEvent } from "../../../../../common/dom/fire_event"; + +export interface ZWaveJSReinterviewNodeDialogParams { + entry_id: string; + node_id: number; +} + +export const loadReinterviewNodeDialog = () => + import("./dialog-zwave_js-reinterview-node"); + +export const showZWaveJSReinterviewNodeDialog = ( + element: HTMLElement, + reinterviewNodeDialogParams: ZWaveJSReinterviewNodeDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-zwave_js-reinterview-node", + dialogImport: loadReinterviewNodeDialog, + dialogParams: reinterviewNodeDialogParams, + }); +}; diff --git a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts index bc219081a4..eb53eeafba 100644 --- a/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts +++ b/src/panels/config/integrations/integration-panels/zwave_js/zwave_js-config-dashboard.ts @@ -17,9 +17,11 @@ import "../../../../../components/ha-svg-icon"; import "../../../../../components/ha-icon-next"; import { getSignedPath } from "../../../../../data/auth"; import { + fetchDataCollectionStatus, fetchNetworkStatus, fetchNodeStatus, NodeStatus, + setDataCollectionPreference, ZWaveJSNetwork, ZWaveJSNode, } from "../../../../../data/zwave_js"; @@ -55,6 +57,8 @@ class ZWaveJSConfigDashboard extends LitElement { @internalProperty() private _icon = mdiCircle; + @internalProperty() private _dataCollectionOptIn?: boolean; + protected firstUpdated() { if (this.hass) { this._fetchData(); @@ -167,6 +171,39 @@ class ZWaveJSConfigDashboard extends LitElement {
    + +
    +

    Third-Party Data Reporting

    + ${this._dataCollectionOptIn !== undefined + ? html` + + ` + : html` + + `} +
    +
    + ` : ``}

    Setup time
    @@ -63,39 +110,25 @@ class IntegrationsCard extends LitElement { ${domainToName(this.hass.localize, domain, manifest)}
    ${domain} + ${this.narrow + ? html`
    +
    ${docLink} ${issueLink}
    + ${setupSeconds ? html`${setupSeconds}s` : ""} +
    ` + : ""}
    - - ${this.hass.localize( - "ui.panel.config.info.documentation" - )} - + ${docLink} + + ${issueLink} + + ${setupSeconds ? html`${setupSeconds}s` : ""} - - ${this.hass.localize( - "ui.panel.config.info.issues" - )} - -