Merge pull request #9024 from home-assistant/dev

This commit is contained in:
Paulus Schoutsen 2021-04-28 10:47:16 -07:00 committed by GitHub
commit 6b7e78320d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
161 changed files with 8212 additions and 3592 deletions

View File

@ -1,8 +1,6 @@
name: Report a bug with the UI, Frontend or Lovelace 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 labels: bug
title: ""
issue_body: true
body: body:
- type: markdown - type: markdown
attributes: attributes:
@ -97,11 +95,7 @@ body:
If your issue is about how an entity is shown in the UI, please add the 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 state and attributes for all situations. You can find this information
at Developer Tools -> States. at Developer Tools -> States.
value: | render: txt
```yaml
# Paste your state here.
```
- type: textarea - type: textarea
attributes: attributes:
label: Problem-relevant frontend configuration label: Problem-relevant frontend configuration
@ -110,29 +104,18 @@ body:
configuration of the used cards. Fill this out even if it seems configuration of the used cards. Fill this out even if it seems
unimportant to you. Please be sure to remove personal information like unimportant to you. Please be sure to remove personal information like
passwords, private URLs and other credentials. passwords, private URLs and other credentials.
value: | render: yaml
```yaml
# Paste your YAML here.
```
- type: textarea - type: textarea
attributes: attributes:
label: Javascript errors shown in your browser console/inspector label: Javascript errors shown in your browser console/inspector
description: > description: >
If you come across any Javascript or other error logs, e.g., in your If you come across any Javascript or other error logs, e.g., in your
browser console/inspector please provide them. browser console/inspector please provide them.
value: | render: txt
```txt - type: textarea
# Paste your logs here.
```
- type: markdown
attributes: attributes:
value: | label: Additional information
## Additional information description: >
- type: markdown
attributes:
value: |
If you have any additional information for us, use the field below. If you have any additional information for us, use the field below.
Please note, you can attach screenshots or screen recordings here, Please note, you can attach screenshots or screen recordings here, by
by dragging and dropping files in the field below. dragging and dropping files in the field below.

View File

@ -35,6 +35,7 @@ class HcLovelace extends LitElement {
} }
const lovelace: Lovelace = { const lovelace: Lovelace = {
config: this.lovelaceConfig, config: this.lovelaceConfig,
rawConfig: this.lovelaceConfig,
editMode: false, editMode: false,
urlPath: this.urlPath!, urlPath: this.urlPath!,
enableFullEditMode: () => undefined, enableFullEditMode: () => undefined,

View File

@ -221,11 +221,17 @@ export class HcMain extends HassElement {
} }
private async _generateLovelaceConfig() { private async _generateLovelaceConfig() {
const { generateLovelaceConfigFromHass } = await import( const { generateLovelaceDashboardStrategy } = await import(
"../../../../src/panels/lovelace/common/generate-lovelace-config" "../../../../src/panels/lovelace/strategies/get-strategy"
); );
this._handleNewLovelaceConfig( this._handleNewLovelaceConfig(
await generateLovelaceConfigFromHass(this.hass!) await generateLovelaceDashboardStrategy(
{
hass: this.hass!,
narrow: false,
},
"original-states"
)
); );
} }

View File

@ -246,11 +246,15 @@ export const demoEntitiesArsaboo: DemoConfig["entities"] = (localize) =>
"light.living_room_lights": { "light.living_room_lights": {
entity_id: "light.living_room_lights", entity_id: "light.living_room_lights",
state: "off", state: "on",
attributes: { attributes: {
min_mireds: 111, min_mireds: 111,
max_mireds: 400, max_mireds: 400,
brightness: 175,
color_temp: 300,
supported_color_modes: ["brightness", "color_temp"],
friendly_name: "Living Room Lights", friendly_name: "Living Room Lights",
color_mode: "color_temp",
supported_features: 55, supported_features: 55,
}, },
}, },
@ -263,13 +267,27 @@ export const demoEntitiesArsaboo: DemoConfig["entities"] = (localize) =>
}, },
"light.kitchen_lights": { "light.kitchen_lights": {
entity_id: "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", state: "off",
attributes: { attributes: {
friendly_name: "Kitchen Lights", supported_color_modes: ["brightness"],
friendly_name: "Garage Lights",
supported_features: 1, supported_features: 1,
}, },
}, },
"sensor.plexspy": { "sensor.plexspy": {
entity_id: "sensor.plexspy", entity_id: "sensor.plexspy",
state: "0", state: "0",
@ -482,16 +500,6 @@ export const demoEntitiesArsaboo: DemoConfig["entities"] = (localize) =>
icon: "hademo:history", 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": { "sensor.alok_to_home": {
entity_id: "sensor.alok_to_home", entity_id: "sensor.alok_to_home",
state: "41", state: "41",

View File

@ -1114,6 +1114,9 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
min_mireds: 153, min_mireds: 153,
max_mireds: 500, max_mireds: 500,
brightness: 63, brightness: 63,
color_temp: 200,
supported_color_modes: ["brightness", "color_temp", "rgb"],
color_mode: "color_temp",
friendly_name: "Upstairs lights", friendly_name: "Upstairs lights",
supported_features: 63, supported_features: 63,
custom_ui_state_card: "state-card-custom-ui", custom_ui_state_card: "state-card-custom-ui",
@ -1125,6 +1128,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
attributes: { attributes: {
friendly_name: "Walk in closet lights", friendly_name: "Walk in closet lights",
supported_features: 41, supported_features: 41,
supported_color_modes: ["brightness", "color_temp"],
custom_ui_state_card: "state-card-custom-ui", custom_ui_state_card: "state-card-custom-ui",
icon: "mdi:wall-sconce", icon: "mdi:wall-sconce",
}, },
@ -1136,6 +1140,8 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
brightness: 254, brightness: 254,
friendly_name: "Outdoor lights", friendly_name: "Outdoor lights",
supported_features: 41, supported_features: 41,
supported_color_modes: ["brightness"],
color_mode: "brightness",
custom_ui_state_card: "state-card-custom-ui", custom_ui_state_card: "state-card-custom-ui",
icon: "mdi:wall-sconce", icon: "mdi:wall-sconce",
}, },
@ -1148,6 +1154,8 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
max_mireds: 500, max_mireds: 500,
brightness: 128, brightness: 128,
color_temp: 366, color_temp: 366,
supported_color_modes: ["brightness", "color_temp", "rgb"],
color_mode: "color_temp",
effect_list: ["colorloop"], effect_list: ["colorloop"],
friendly_name: "Downstairs lights", friendly_name: "Downstairs lights",
supported_features: 63, supported_features: 63,
@ -1307,6 +1315,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
attributes: { attributes: {
min_mireds: 153, min_mireds: 153,
max_mireds: 500, max_mireds: 500,
supported_color_modes: ["brightness", "color_temp"],
is_deconz_group: false, is_deconz_group: false,
friendly_name: "Bedside Lamp", friendly_name: "Bedside Lamp",
supported_features: 63, supported_features: 63,
@ -1320,6 +1329,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
attributes: { attributes: {
min_mireds: 153, min_mireds: 153,
max_mireds: 500, max_mireds: 500,
supported_color_modes: ["brightness", "color_temp"],
is_deconz_group: false, is_deconz_group: false,
friendly_name: "Floorlamp Reading Light", friendly_name: "Floorlamp Reading Light",
supported_features: 43, supported_features: 43,
@ -1335,6 +1345,8 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
max_mireds: 500, max_mireds: 500,
brightness: 128, brightness: 128,
color_temp: 366, color_temp: 366,
supported_color_modes: ["brightness", "color_temp", "rgb"],
color_mode: "color_temp",
effect_list: ["colorloop"], effect_list: ["colorloop"],
is_deconz_group: false, is_deconz_group: false,
friendly_name: "Hallway window light", friendly_name: "Hallway window light",
@ -1349,6 +1361,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
attributes: { attributes: {
brightness: 77, brightness: 77,
is_deconz_group: false, is_deconz_group: false,
supported_color_modes: ["brightness"],
friendly_name: "Isa Ceiling Light", friendly_name: "Isa Ceiling Light",
supported_features: 41, supported_features: 41,
custom_ui_state_card: "state-card-custom-ui", custom_ui_state_card: "state-card-custom-ui",
@ -1363,6 +1376,8 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
max_mireds: 500, max_mireds: 500,
brightness: 150, brightness: 150,
color_temp: 366, color_temp: 366,
supported_color_modes: ["brightness", "color_temp"],
color_mode: "color_temp",
effect_list: ["colorloop"], effect_list: ["colorloop"],
is_deconz_group: false, is_deconz_group: false,
friendly_name: "Floorlamp", friendly_name: "Floorlamp",
@ -1377,6 +1392,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
attributes: { attributes: {
friendly_name: "Bedroom Ceiling Light", friendly_name: "Bedroom Ceiling Light",
supported_features: 41, supported_features: 41,
supported_color_modes: ["brightness"],
custom_ui_state_card: "state-card-custom-ui", custom_ui_state_card: "state-card-custom-ui",
icon: "mdi:ceiling-light", icon: "mdi:ceiling-light",
}, },
@ -1387,6 +1403,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
attributes: { attributes: {
friendly_name: "Nightlight", friendly_name: "Nightlight",
supported_features: 17, supported_features: 17,
supported_color_modes: ["brightness"],
custom_ui_state_card: "state-card-custom-ui", custom_ui_state_card: "state-card-custom-ui",
icon: "mdi:lamp", icon: "mdi:lamp",
}, },
@ -1753,6 +1770,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
power_consumption: 2.2, power_consumption: 2.2,
friendly_name: "Upstairs Hallway Light", friendly_name: "Upstairs Hallway Light",
supported_features: 33, supported_features: 33,
supported_color_modes: ["brightness"],
custom_ui_state_card: "state-card-custom-ui", custom_ui_state_card: "state-card-custom-ui",
icon: "mdi:ceiling-light", icon: "mdi:ceiling-light",
}, },
@ -1768,6 +1786,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
power_consumption: 0, power_consumption: 0,
friendly_name: "Dining Room Light", friendly_name: "Dining Room Light",
supported_features: 33, supported_features: 33,
supported_color_modes: ["brightness"],
custom_ui_state_card: "state-card-custom-ui", custom_ui_state_card: "state-card-custom-ui",
icon: "mdi:ceiling-light", icon: "mdi:ceiling-light",
}, },
@ -1783,6 +1802,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
power_consumption: 0, power_consumption: 0,
friendly_name: "Living room Spotlights", friendly_name: "Living room Spotlights",
supported_features: 33, supported_features: 33,
supported_color_modes: ["brightness"],
custom_ui_state_card: "state-card-custom-ui", custom_ui_state_card: "state-card-custom-ui",
icon: "mdi:track-light", icon: "mdi:track-light",
}, },
@ -1799,6 +1819,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
power_consumption: 2.5, power_consumption: 2.5,
friendly_name: "Passage Lights", friendly_name: "Passage Lights",
supported_features: 33, supported_features: 33,
supported_color_modes: ["brightness"],
custom_ui_state_card: "state-card-custom-ui", custom_ui_state_card: "state-card-custom-ui",
icon: "mdi:track-light", icon: "mdi:track-light",
}, },
@ -1843,6 +1864,7 @@ export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
power_consumption: 37.4, power_consumption: 37.4,
friendly_name: "Kitchen Lights", friendly_name: "Kitchen Lights",
supported_features: 33, supported_features: 33,
supported_color_modes: ["brightness"],
custom_ui_state_card: "state-card-custom-ui", custom_ui_state_card: "state-card-custom-ui",
icon: "mdi:track-light", icon: "mdi:track-light",
}, },

View File

@ -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> = {}
): 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`
<div class="container">
<div class="filters">
<ha-formfield label="Custom Integration">
<ha-switch @change=${this._toggleCustomIntegration}></ha-switch>
</ha-formfield>
<ha-formfield label="Relies on cloud">
<ha-switch @change=${this._toggleCloud}></ha-switch>
</ha-formfield>
</div>
<ha-ignored-config-entry-card
.hass=${this.hass}
.entry=${createConfigEntry("Ignored Entry")}
.manifest=${createManifest(this.isCustomIntegration, this.isCloud)}
></ha-ignored-config-entry-card>
${configFlows.map(
(flow) => html`
<ha-config-flow-card
.hass=${this.hass}
.flow=${flow}
.manifest=${createManifest(
this.isCustomIntegration,
this.isCloud,
flow.handler === "roku" ? "Roku" : "Philips Hue"
)}
></ha-config-flow-card>
`
)}
${configEntries.map(
(info) => html`
<ha-integration-card
class=${classMap({
highlight: info.highlight !== undefined,
})}
.hass=${this.hass}
domain="esphome"
.items=${info.items}
.manifest=${createManifest(
this.isCustomIntegration,
this.isCloud
)}
.entityRegistryEntries=${createEntityRegistryEntries(
info.items[0]
)}
.deviceRegistryEntries=${createDeviceRegistryEntries(
info.items[0]
)}
?disabled=${info.disabled}
.selectedConfigEntryId=${info.highlight}
></ha-integration-card>
`
)}
</div>
<div class="container">
<!-- One that is standalone to see how it increases height if height
not defined by other cards. -->
<ha-integration-card
.hass=${this.hass}
domain="esphome"
.items=${[
loadedEntry,
setupErrorEntry,
migrationErrorEntry,
setupRetryEntry,
failedUnloadEntry,
]}
.manifest=${createManifest(this.isCustomIntegration, this.isCloud)}
.entityRegistryEntries=${createEntityRegistryEntries(loadedEntry)}
.deviceRegistryEntries=${createDeviceRegistryEntries(loadedEntry)}
></ha-integration-card>
</div>
`;
}
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;
}
}

View File

@ -9,13 +9,10 @@ import {
} from "lit-element"; } from "lit-element";
import "../../../src/components/ha-card"; import "../../../src/components/ha-card";
import { import {
SUPPORT_BRIGHTNESS, LightColorModes,
SUPPORT_COLOR,
SUPPORT_COLOR_TEMP,
SUPPORT_EFFECT, SUPPORT_EFFECT,
SUPPORT_FLASH, SUPPORT_FLASH,
SUPPORT_TRANSITION, SUPPORT_TRANSITION,
SUPPORT_WHITE_VALUE,
} from "../../../src/data/light"; } from "../../../src/data/light";
import "../../../src/dialogs/more-info/more-info-content"; import "../../../src/dialogs/more-info/more-info-content";
import { getEntity } from "../../../src/fake_data/entity"; import { getEntity } from "../../../src/fake_data/entity";
@ -32,7 +29,8 @@ const ENTITIES = [
getEntity("light", "kitchen_light", "on", { getEntity("light", "kitchen_light", "on", {
friendly_name: "Brightness Light", friendly_name: "Brightness Light",
brightness: 200, brightness: 200,
supported_features: SUPPORT_BRIGHTNESS, supported_color_modes: [LightColorModes.BRIGHTNESS],
color_mode: LightColorModes.BRIGHTNESS,
}), }),
getEntity("light", "color_temperature_light", "on", { getEntity("light", "color_temperature_light", "on", {
friendly_name: "White Color Temperature Light", friendly_name: "White Color Temperature Light",
@ -40,20 +38,96 @@ const ENTITIES = [
color_temp: 75, color_temp: 75,
min_mireds: 30, min_mireds: 30,
max_mireds: 150, 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", { getEntity("light", "color_hs_light", "on", {
friendly_name: "Color Effets Light", friendly_name: "Color HS Light",
brightness: 255, brightness: 255,
hs_color: [30, 100], hs_color: [30, 100],
white_value: 36, rgb_color: [30, 100, 255],
supported_features: min_mireds: 30,
SUPPORT_BRIGHTNESS + max_mireds: 150,
SUPPORT_EFFECT + supported_features: SUPPORT_EFFECT + SUPPORT_FLASH + SUPPORT_TRANSITION,
SUPPORT_FLASH + supported_color_modes: [
SUPPORT_COLOR + LightColorModes.BRIGHTNESS,
SUPPORT_TRANSITION + LightColorModes.COLOR_TEMP,
SUPPORT_WHITE_VALUE, 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"], effect_list: ["random", "colorloop"],
}), }),
]; ];

View File

@ -177,8 +177,9 @@ class HassioAddonDashboard extends LitElement {
const requestedAddon = extractSearchParam("addon"); const requestedAddon = extractSearchParam("addon");
if (requestedAddon) { if (requestedAddon) {
const addonsInfo = await fetchHassioAddonsInfo(this.hass); const addonsInfo = await fetchHassioAddonsInfo(this.hass);
const validAddon = addonsInfo.addons const validAddon = addonsInfo.addons.some(
.some((addon) => addon.slug === requestedAddon); (addon) => addon.slug === requestedAddon
);
if (!validAddon) { if (!validAddon) {
this._error = this.supervisor.localize("my.error_addon_not_found"); this._error = this.supervisor.localize("my.error_addon_not_found");
} else { } else {

View File

@ -242,14 +242,18 @@ class HassioAddonInfo extends LitElement {
? html` ? html`
Current version: ${this.addon.version} Current version: ${this.addon.version}
<div class="changelog" @click=${this._openChangelog}> <div class="changelog" @click=${this._openChangelog}>
(<span class="changelog-link">${ (<span class="changelog-link"
this.supervisor.localize("addon.dashboard.changelog")}</span >${this.supervisor.localize(
"addon.dashboard.changelog"
)}</span
>) >)
</div> </div>
` `
: html`<span class="changelog-link" @click=${this._openChangelog}>${ : html`<span class="changelog-link" @click=${this._openChangelog}
this.supervisor.localize("addon.dashboard.changelog") >${this.supervisor.localize(
}</span>`} "addon.dashboard.changelog"
)}</span
>`}
</div> </div>
<div class="description light-color"> <div class="description light-color">

View File

@ -73,7 +73,7 @@ class SupervisorMetric extends LitElement {
); );
} }
.value { .value {
width: 42px; width: 48px;
padding-right: 4px; padding-right: 4px;
} }
`; `;

View File

@ -44,7 +44,10 @@ export class HassioMain extends SupervisorBaseElement {
// We changed the navigate event to fire directly on the window, as that's // 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 // 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. // 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 // @ts-ignore
fireEvent(this, ev.type, ev.detail, { fireEvent(this, ev.type, ev.detail, {
bubbles: false, bubbles: false,

View File

@ -269,13 +269,15 @@ class HassioSupervisorInfo extends LitElement {
</b> </b>
<br /><br /> <br /><br />
${this.supervisor.localize("system.supervisor.beta_release_items")} ${this.supervisor.localize("system.supervisor.beta_release_items")}
<ul>
<li>Home Assistant Core</li> <li>Home Assistant Core</li>
<li>Home Assistant Supervisor</li> <li>Home Assistant Supervisor</li>
<li>Home Assistant Operating System</li> <li>Home Assistant Operating System</li>
</ul>
<br /> <br />
${this.supervisor.localize("system.supervisor.join_beta_action")}`, ${this.supervisor.localize("system.supervisor.beta_join_confirm")}`,
confirmText: this.supervisor.localize( confirmText: this.supervisor.localize(
"system.supervisor.beta_join_confirm" "system.supervisor.join_beta_action"
), ),
dismissText: this.supervisor.localize("common.cancel"), dismissText: this.supervisor.localize("common.cancel"),
}); });

View File

@ -25,7 +25,7 @@
"@braintree/sanitize-url": "^5.0.0", "@braintree/sanitize-url": "^5.0.0",
"@codemirror/commands": "^0.18.0", "@codemirror/commands": "^0.18.0",
"@codemirror/gutter": "^0.18.0", "@codemirror/gutter": "^0.18.0",
"@codemirror/highlight": "^0.18.1", "@codemirror/highlight": "^0.18.0",
"@codemirror/history": "^0.18.0", "@codemirror/history": "^0.18.0",
"@codemirror/legacy-modes": "^0.18.0", "@codemirror/legacy-modes": "^0.18.0",
"@codemirror/rectangular-selection": "^0.18.0", "@codemirror/rectangular-selection": "^0.18.0",
@ -100,7 +100,6 @@
"@webcomponents/webcomponentsjs": "^2.2.7", "@webcomponents/webcomponentsjs": "^2.2.7",
"chart.js": "~2.8.0", "chart.js": "~2.8.0",
"chartjs-chart-timeline": "^0.3.0", "chartjs-chart-timeline": "^0.3.0",
"codemirror": "^5.49.0",
"comlink": "^4.3.0", "comlink": "^4.3.0",
"core-js": "^3.6.5", "core-js": "^3.6.5",
"cropperjs": "^1.5.7", "cropperjs": "^1.5.7",
@ -109,7 +108,7 @@
"fecha": "^4.2.0", "fecha": "^4.2.0",
"fuse.js": "^6.0.0", "fuse.js": "^6.0.0",
"google-timezones-json": "^1.0.2", "google-timezones-json": "^1.0.2",
"hls.js": "^0.13.2", "hls.js": "^1.0.1",
"home-assistant-js-websocket": "^5.9.0", "home-assistant-js-websocket": "^5.9.0",
"idb-keyval": "^3.2.0", "idb-keyval": "^3.2.0",
"intl-messageformat": "^8.3.9", "intl-messageformat": "^8.3.9",
@ -139,10 +138,12 @@
"vue": "^2.6.11", "vue": "^2.6.11",
"vue2-daterange-picker": "^0.5.1", "vue2-daterange-picker": "^0.5.1",
"web-animations-js": "^2.3.2", "web-animations-js": "^2.3.2",
"workbox-core": "^5.1.3", "workbox-cacheable-response": "^6.1.5",
"workbox-precaching": "^5.1.3", "workbox-core": "^6.1.5",
"workbox-routing": "^5.1.3", "workbox-expiration": "^6.1.5",
"workbox-strategies": "^5.1.3", "workbox-precaching": "^6.1.5",
"workbox-routing": "^6.1.5",
"workbox-strategies": "^6.1.5",
"xss": "^1.0.6" "xss": "^1.0.6"
}, },
"devDependencies": { "devDependencies": {
@ -167,8 +168,6 @@
"@types/chai": "^4.1.7", "@types/chai": "^4.1.7",
"@types/chromecast-caf-receiver": "^5.0.11", "@types/chromecast-caf-receiver": "^5.0.11",
"@types/chromecast-caf-sender": "^1.0.3", "@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/js-yaml": "^3.12.1",
"@types/leaflet": "^1.4.3", "@types/leaflet": "^1.4.3",
"@types/leaflet-draw": "^1.0.1", "@types/leaflet-draw": "^1.0.1",
@ -228,14 +227,14 @@
"terser-webpack-plugin": "^5.1.1", "terser-webpack-plugin": "^5.1.1",
"ts-lit-plugin": "^1.2.1", "ts-lit-plugin": "^1.2.1",
"ts-mocha": "^7.0.0", "ts-mocha": "^7.0.0",
"typescript": "^4.0.3", "typescript": "^4.2.4",
"vinyl-buffer": "^1.0.1", "vinyl-buffer": "^1.0.1",
"vinyl-source-stream": "^2.0.0", "vinyl-source-stream": "^2.0.0",
"webpack": "^5.24.1", "webpack": "^5.24.1",
"webpack-cli": "^4.5.0", "webpack-cli": "^4.5.0",
"webpack-dev-server": "^3.11.2", "webpack-dev-server": "^3.11.2",
"webpack-manifest-plugin": "^3.0.0", "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": "Polymer fixed to 3.1 because 3.2 throws on logbook page",
"_comment_2": "Fix in https://github.com/Polymer/polymer/pull/5569", "_comment_2": "Fix in https://github.com/Polymer/polymer/pull/5569",

View File

@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup( setup(
name="home-assistant-frontend", name="home-assistant-frontend",
version="20210407.3", version="20210428.0",
description="The Home Assistant frontend", description="The Home Assistant frontend",
url="https://github.com/home-assistant/home-assistant-polymer", url="https://github.com/home-assistant/home-assistant-polymer",
author="The Home Assistant Authors", author="The Home Assistant Authors",

View File

@ -8,6 +8,7 @@ import {
PropertyValues, PropertyValues,
} from "lit-element"; } from "lit-element";
import punycode from "punycode"; import punycode from "punycode";
import { applyThemesOnElement } from "../common/dom/apply_themes_on_element";
import { extractSearchParamsObject } from "../common/url/search-params"; import { extractSearchParamsObject } from "../common/url/search-params";
import { import {
AuthProvider, AuthProvider,
@ -116,6 +117,20 @@ class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
this._fetchAuthProviders(); this._fetchAuthProviders();
this._fetchDiscoveryInfo(); 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) { if (!this.redirectUri) {
return; return;
} }

View File

@ -62,7 +62,7 @@ export const ensureConnectedCastSession = (cast: CastManager, auth: Auth) => {
return undefined; return undefined;
} }
return new Promise((resolve) => { return new Promise<void>((resolve) => {
const unsub = cast.addEventListener("connection-changed", () => { const unsub = cast.addEventListener("connection-changed", () => {
if (cast.castConnectedToOurHass) { if (cast.castConnectedToOurHass) {
unsub(); unsub();

View File

@ -102,3 +102,18 @@ export const lab2hex = (lab: [number, number, number]): string => {
const rgb = lab2rgb(lab); const rgb = lab2rgb(lab);
return rgb2hex(rgb); 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];
};

View File

@ -70,13 +70,18 @@ export const applyThemesOnElement = (
themeRules["text-accent-color"] = themeRules["text-accent-color"] =
rgbContrast(rgbAccentColor, [33, 33, 33]) < 6 ? "#fff" : "#212121"; rgbContrast(rgbAccentColor, [33, 33, 33]) < 6 ? "#fff" : "#212121";
} }
// Nothing was changed
if (element._themes?.cacheKey === cacheKey) {
return;
}
} }
if (selectedTheme && themes.themes[selectedTheme]) { if (selectedTheme && themes.themes[selectedTheme]) {
themeRules = 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 // No styles to reset, and no styles to set
return; return;
} }
@ -87,8 +92,8 @@ export const applyThemesOnElement = (
: undefined; : undefined;
// Add previous set keys to reset them, and new theme // Add previous set keys to reset them, and new theme
const styles = { ...element._themes, ...newTheme?.styles }; const styles = { ...element._themes?.keys, ...newTheme?.styles };
element._themes = newTheme?.keys; element._themes = { cacheKey, keys: newTheme?.keys };
// Set and/or reset styles // Set and/or reset styles
if (element.updateStyles) { if (element.updateStyles) {

View File

@ -12,20 +12,24 @@ declare global {
export const navigate = (_node: any, path: string, replace = false) => { export const navigate = (_node: any, path: string, replace = false) => {
if (__DEMO__) { if (__DEMO__) {
if (replace) { if (replace) {
history.replaceState( top.history.replaceState(
history.state?.root ? { root: true } : null, top.history.state?.root ? { root: true } : null,
"", "",
`${location.pathname}#${path}` `${top.location.pathname}#${path}`
); );
} else { } else {
window.location.hash = path; top.location.hash = path;
} }
} else if (replace) { } else if (replace) {
history.replaceState(history.state?.root ? { root: true } : null, "", path); top.history.replaceState(
top.history.state?.root ? { root: true } : null,
"",
path
);
} else { } else {
history.pushState(null, "", path); top.history.pushState(null, "", path);
} }
fireEvent(window, "location-changed", { fireEvent(top, "location-changed", {
replace, replace,
}); });
}; };

View File

@ -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. * @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; let topScore = Number.NEGATIVE_INFINITY;
for (const word of words) { for (const word of item.strings) {
const scores = fuzzyScore( const scores = fuzzyScore(
filter, filter,
filter.toLowerCase(), filter.toLowerCase(),
@ -28,13 +31,9 @@ export const fuzzySequentialMatch = (filter: string, ...words: string[]) => {
continue; continue;
} }
// The VS Code implementation of filter returns a: // The VS Code implementation of filter returns a 0 for a weak match.
// - Negative score for a good match that starts in the middle of the string // But if .filter() sees a "0", it considers that a failed match and will remove it.
// - Positive score if the match starts at the beginning of the string // So, we set score to 1 in these cases so the match will be included, and mostly respect correct ordering.
// - 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.
const score = scores[0] === 0 ? 1 : scores[0]; const score = scores[0] === 0 ? 1 : scores[0];
if (score > topScore) { if (score > topScore) {
@ -49,10 +48,22 @@ export const fuzzySequentialMatch = (filter: string, ...words: string[]) => {
return topScore; 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 { export interface ScorableTextItem {
score?: number; score?: number;
filterText: string; strings: string[];
altText?: string;
} }
type FuzzyFilterSort = <T extends ScorableTextItem>( type FuzzyFilterSort = <T extends ScorableTextItem>(
@ -63,9 +74,7 @@ type FuzzyFilterSort = <T extends ScorableTextItem>(
export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) => { export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) => {
return items return items
.map((item) => { .map((item) => {
item.score = item.altText item.score = fuzzySequentialMatch(filter, item);
? fuzzySequentialMatch(filter, item.filterText, item.altText)
: fuzzySequentialMatch(filter, item.filterText);
return item; return item;
}) })
.filter((item) => item.score !== undefined) .filter((item) => item.score !== undefined)

View File

@ -58,7 +58,7 @@ export const formatNumber = (
).format(Number(num)); ).format(Number(num));
} }
} }
return num ? num.toString() : ""; return num.toString();
}; };
/** /**

View File

@ -1,4 +1,4 @@
export const afterNextRender = (cb: () => void): void => { export const afterNextRender = (cb: (value: unknown) => void): void => {
requestAnimationFrame(() => setTimeout(cb, 0)); requestAnimationFrame(() => setTimeout(cb, 0));
}; };

View File

@ -15,6 +15,7 @@ import { computeActiveState } from "../../common/entity/compute_active_state";
import { computeStateDomain } from "../../common/entity/compute_state_domain"; import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { stateIcon } from "../../common/entity/state_icon"; import { stateIcon } from "../../common/entity/state_icon";
import { iconColorCSS } from "../../common/style/icon_color_css"; import { iconColorCSS } from "../../common/style/icon_color_css";
import { getLightRgbColor, LightEntity } from "../../data/light";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-icon"; import "../ha-icon";
@ -99,11 +100,13 @@ export class StateBadge extends LitElement {
hostStyle.backgroundImage = `url(${imageUrl})`; hostStyle.backgroundImage = `url(${imageUrl})`;
this._showIcon = false; this._showIcon = false;
} else if (stateObj.state === "on") { } else if (stateObj.state === "on") {
if (stateObj.attributes.hs_color && this.stateColor !== false) { if (
const hue = stateObj.attributes.hs_color[0]; computeStateDomain(stateObj) === "light" &&
const sat = stateObj.attributes.hs_color[1]; this.stateColor !== false
if (sat > 10) { ) {
iconStyle.color = `hsl(${hue}, 100%, ${100 - sat / 2}%)`; const rgb = getLightRgbColor(stateObj as LightEntity);
if (rgb) {
iconStyle.color = `rgb(${rgb.slice(0, 3).join(",")})`;
} }
} }
if (stateObj.attributes.brightness && this.stateColor !== false) { if (stateObj.attributes.brightness && this.stateColor !== false) {

View File

@ -6,5 +6,6 @@ export const analyticsLearnMore = (hass: HomeAssistant) => html`<a
.href=${documentationUrl(hass, "/integrations/analytics/")} .href=${documentationUrl(hass, "/integrations/analytics/")}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
>${hass.localize("ui.panel.config.core.section.core.analytics.learn_more")}</a >
>`; How we process your data
</a>`;

View File

@ -8,7 +8,6 @@ import {
property, property,
TemplateResult, TemplateResult,
} from "lit-element"; } from "lit-element";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { Analytics, AnalyticsPreferences } from "../data/analytics"; import { Analytics, AnalyticsPreferences } from "../data/analytics";
import { haStyle } from "../resources/styles"; import { haStyle } from "../resources/styles";
@ -17,7 +16,18 @@ import "./ha-checkbox";
import type { HaCheckbox } from "./ha-checkbox"; import type { HaCheckbox } from "./ha-checkbox";
import "./ha-settings-row"; 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 { declare global {
interface HASSDomEvents { interface HASSDomEvents {
@ -48,14 +58,10 @@ export class HaAnalytics extends LitElement {
</ha-checkbox> </ha-checkbox>
</span> </span>
<span slot="heading" data-for="base"> <span slot="heading" data-for="base">
${this.hass.localize( Basic analytics
`ui.panel.config.core.section.core.analytics.preference.base.title`
)}
</span> </span>
<span slot="description" data-for="base"> <span slot="description" data-for="base">
${this.hass.localize( This includes information about your system.
`ui.panel.config.core.section.core.analytics.preference.base.description`
)}
</span> </span>
</ha-settings-row> </ha-settings-row>
${ADDITIONAL_PREFERENCES.map( ${ADDITIONAL_PREFERENCES.map(
@ -64,44 +70,23 @@ export class HaAnalytics extends LitElement {
<span slot="prefix"> <span slot="prefix">
<ha-checkbox <ha-checkbox
@change=${this._handleRowCheckboxClick} @change=${this._handleRowCheckboxClick}
.checked=${this.analytics?.preferences[preference]} .checked=${this.analytics?.preferences[preference.key]}
.preference=${preference} .preference=${preference.key}
name=${preference} name=${preference.key}
> >
</ha-checkbox> </ha-checkbox>
${!baseEnabled ${!baseEnabled
? html`<paper-tooltip animation-delay="0" position="right" ? html`<paper-tooltip animation-delay="0" position="right">
>${this.hass.localize( You need to enable basic analytics for this option to be
"ui.panel.config.core.section.core.analytics.needs_base" available
)}
</paper-tooltip>` </paper-tooltip>`
: ""} : ""}
</span> </span>
<span slot="heading" data-for=${preference}> <span slot="heading" data-for=${preference.key}>
${preference === "usage" ${preference.title}
? 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`
)}
</span> </span>
<span slot="description" data-for=${preference}> <span slot="description" data-for=${preference.key}>
${preference !== "usage" ${preference.description}
? 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`
)}
</span> </span>
</ha-settings-row>` </ha-settings-row>`
)} )}
@ -117,14 +102,10 @@ export class HaAnalytics extends LitElement {
</ha-checkbox> </ha-checkbox>
</span> </span>
<span slot="heading" data-for="diagnostics"> <span slot="heading" data-for="diagnostics">
${this.hass.localize( Diagnostics
`ui.panel.config.core.section.core.analytics.preference.diagnostics.title`
)}
</span> </span>
<span slot="description" data-for="diagnostics"> <span slot="description" data-for="diagnostics">
${this.hass.localize( Share crash reports when unexpected errors occur.
`ui.panel.config.core.section.core.analytics.preference.diagnostics.description`
)}
</span> </span>
</ha-settings-row> </ha-settings-row>
`; `;
@ -161,7 +142,10 @@ export class HaAnalytics extends LitElement {
preferences[preference] = checkbox.checked; preferences[preference] = checkbox.checked;
if (ADDITIONAL_PREFERENCES.includes(preference) && checkbox.checked) { if (
ADDITIONAL_PREFERENCES.some((entry) => entry.key === preference) &&
checkbox.checked
) {
preferences.base = true; preferences.base = true;
} else if (preference === "base" && !checkbox.checked) { } else if (preference === "base" && !checkbox.checked) {
preferences.usage = false; preferences.usage = false;

View File

@ -9,6 +9,7 @@ import {
property, property,
TemplateResult, TemplateResult,
} from "lit-element"; } from "lit-element";
import { styleMap } from "lit-html/directives/style-map";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import type { ToggleButton } from "../types"; import type { ToggleButton } from "../types";
import "./ha-svg-icon"; import "./ha-svg-icon";
@ -19,6 +20,8 @@ export class HaButtonToggleGroup extends LitElement {
@property() public active?: string; @property() public active?: string;
@property({ type: Boolean }) public fullWidth = false;
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<div> <div>
@ -33,6 +36,11 @@ export class HaButtonToggleGroup extends LitElement {
<ha-svg-icon .path=${button.iconPath}></ha-svg-icon> <ha-svg-icon .path=${button.iconPath}></ha-svg-icon>
</mwc-icon-button>` </mwc-icon-button>`
: html`<mwc-button : html`<mwc-button
style=${styleMap({
width: this.fullWidth
? `${100 / this.buttons.length}%`
: "initial",
})}
.value=${button.value} .value=${button.value}
?active=${this.active === button.value} ?active=${this.active === button.value}
@click=${this._handleClick} @click=${this._handleClick}

View File

@ -2,7 +2,7 @@ import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */ /* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element"; import { PolymerElement } from "@polymer/polymer/polymer-element";
import { EventsMixin } from "../mixins/events-mixin"; import { EventsMixin } from "../mixins/events-mixin";
import { rgb2hs } from "../common/color/convert-color";
/** /**
* Color-picker custom element * Color-picker custom element
* *
@ -114,6 +114,12 @@ class HaColorPicker extends EventsMixin(PolymerElement) {
observer: "applyHsColor", observer: "applyHsColor",
}, },
// use these properties to update the state via attributes
desiredRgbColor: {
type: Object,
observer: "applyRgbColor",
},
// width, height and radius apply to the coordinates of // width, height and radius apply to the coordinates of
// of the canvas. // of the canvas.
// border width are relative to these numbers // border width are relative to these numbers
@ -177,8 +183,11 @@ class HaColorPicker extends EventsMixin(PolymerElement) {
this.drawMarker(); this.drawMarker();
if (this.desiredHsColor) { if (this.desiredHsColor) {
this.setMarkerOnColor(this.desiredHsColor); this.applyHsColor(this.desiredHsColor);
this.applyColorToCanvas(this.desiredHsColor); }
if (this.desiredRgbColor) {
this.applyRgbColor(this.desiredRgbColor);
} }
this.interactionLayer.addEventListener("mousedown", (ev) => this.interactionLayer.addEventListener("mousedown", (ev) =>
@ -282,12 +291,13 @@ class HaColorPicker extends EventsMixin(PolymerElement) {
processUserSelect(ev) { processUserSelect(ev) {
const canvasXY = this.convertToCanvasCoordinates(ev.clientX, ev.clientY); const canvasXY = this.convertToCanvasCoordinates(ev.clientX, ev.clientY);
const hs = this.getColor(canvasXY.x, canvasXY.y); 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 // apply color to marker position and canvas
onColorSelect(hs) { onColorSelect(hs, rgb) {
this.setMarkerOnColor(hs); // marker always follows mounse 'raw' hs value (= mouse position) this.setMarkerOnColor(hs); // marker always follows mouse 'raw' hs value (= mouse position)
if (!this.ignoreSegments) { if (!this.ignoreSegments) {
// apply segments if needed // apply segments if needed
hs = this.applySegmentFilter(hs); hs = this.applySegmentFilter(hs);
@ -301,11 +311,11 @@ class HaColorPicker extends EventsMixin(PolymerElement) {
// eventually after throttle limit has passed // eventually after throttle limit has passed
clearTimeout(this.ensureFinalSelect); clearTimeout(this.ensureFinalSelect);
this.ensureFinalSelect = setTimeout(() => { this.ensureFinalSelect = setTimeout(() => {
this.fireColorSelected(hs); // do it for the final time this.fireColorSelected(hs, rgb); // do it for the final time
}, this.throttle); }, this.throttle);
return; return;
} }
this.fireColorSelected(hs); // do it this.fireColorSelected(hs, rgb); // do it
this.colorSelectIsThrottled = true; this.colorSelectIsThrottled = true;
setTimeout(() => { setTimeout(() => {
this.colorSelectIsThrottled = false; this.colorSelectIsThrottled = false;
@ -313,9 +323,9 @@ class HaColorPicker extends EventsMixin(PolymerElement) {
} }
// set color values and fire colorselected event // set color values and fire colorselected event
fireColorSelected(hs) { fireColorSelected(hs, rgb) {
this.hsColor = hs; 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); this.applyColorToCanvas(hs);
} }
applyRgbColor(rgb) {
const [h, s] = rgb2hs(rgb);
this.applyHsColor({ h, s });
}
/* /*
* input processing helpers * input processing helpers
*/ */
@ -395,6 +410,15 @@ class HaColorPicker extends EventsMixin(PolymerElement) {
return { h: hue, s: sat }; 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) { applySegmentFilter(hs) {
// apply hue segment steps // apply hue segment steps
if (this.hueSegments) { if (this.hueSegments) {
@ -468,7 +492,7 @@ class HaColorPicker extends EventsMixin(PolymerElement) {
.getPropertyValue("--wheel-bordercolor") .getPropertyValue("--wheel-bordercolor")
.trim(); .trim();
const wheelShadow = wheelStyle.getPropertyValue("--wheel-shadow").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" // the shadow should be defined as: "10px 5px 5px 0px COLOR"
if (wheelShadow !== "none") { if (wheelShadow !== "none") {
const values = wheelShadow.split("px "); const values = wheelShadow.split("px ");

View File

@ -1,3 +1,4 @@
import type HlsType from "hls.js";
import { import {
css, css,
CSSResult, CSSResult,
@ -15,8 +16,6 @@ import { nextRender } from "../common/util/render-status";
import { getExternalConfig } from "../external_app/external_config"; import { getExternalConfig } from "../external_app/external_config";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
type HLSModule = typeof import("hls.js");
@customElement("ha-hls-player") @customElement("ha-hls-player")
class HaHLSPlayer extends LitElement { class HaHLSPlayer extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -43,7 +42,7 @@ class HaHLSPlayer extends LitElement {
@internalProperty() private _attached = false; @internalProperty() private _attached = false;
private _hlsPolyfillInstance?: Hls; private _hlsPolyfillInstance?: HlsType;
private _useExoPlayer = false; private _useExoPlayer = false;
@ -107,8 +106,8 @@ class HaHLSPlayer extends LitElement {
const useExoPlayerPromise = this._getUseExoPlayer(); const useExoPlayerPromise = this._getUseExoPlayer();
const masterPlaylistPromise = fetch(this.url); const masterPlaylistPromise = fetch(this.url);
const hls = ((await import("hls.js")) as any).default as HLSModule; const Hls = (await import("hls.js")).default;
let hlsSupported = hls.isSupported(); let hlsSupported = Hls.isSupported();
if (!hlsSupported) { if (!hlsSupported) {
hlsSupported = hlsSupported =
@ -144,8 +143,8 @@ class HaHLSPlayer extends LitElement {
// If codec is HEVC and ExoPlayer is supported, use ExoPlayer. // If codec is HEVC and ExoPlayer is supported, use ExoPlayer.
if (this._useExoPlayer && match !== null && match[1] !== undefined) { if (this._useExoPlayer && match !== null && match[1] !== undefined) {
this._renderHLSExoPlayer(playlist_url); this._renderHLSExoPlayer(playlist_url);
} else if (hls.isSupported()) { } else if (Hls.isSupported()) {
this._renderHLSPolyfill(videoEl, hls, playlist_url); this._renderHLSPolyfill(videoEl, Hls, playlist_url);
} else { } else {
this._renderHLSNative(videoEl, playlist_url); this._renderHLSNative(videoEl, playlist_url);
} }
@ -182,7 +181,7 @@ class HaHLSPlayer extends LitElement {
private async _renderHLSPolyfill( private async _renderHLSPolyfill(
videoEl: HTMLVideoElement, videoEl: HTMLVideoElement,
Hls: HLSModule, Hls: typeof HlsType,
url: string url: string
) { ) {
const hls = new Hls({ const hls = new Hls({

View File

@ -1,12 +1,9 @@
import { customElement, html, LitElement, property } from "lit-element"; import { customElement, html, LitElement, property } from "lit-element";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { TimeSelector } from "../../data/selector"; import { TimeSelector } from "../../data/selector";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import "../paper-time-input"; import "../paper-time-input";
const test = new Date().toLocaleString();
const useAMPM = test.includes("AM") || test.includes("PM");
@customElement("ha-selector-time") @customElement("ha-selector-time")
export class HaTimeSelector extends LitElement { export class HaTimeSelector extends LitElement {
@property() public hass!: HomeAssistant; @property() public hass!: HomeAssistant;
@ -19,16 +16,24 @@ export class HaTimeSelector extends LitElement {
@property({ type: Boolean }) public disabled = false; @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() { protected render() {
const useAMPM = this._useAmPm(this.hass.locale.language);
const parts = this.value?.split(":") || []; const parts = this.value?.split(":") || [];
const hours = useAMPM ? parts[0] ?? "12" : parts[0] ?? "0"; const hours = parts[0];
return html` return html`
<paper-time-input <paper-time-input
.label=${this.label} .label=${this.label}
.hour=${useAMPM && Number(hours) > 12 ? Number(hours) - 12 : hours} .hour=${hours &&
.min=${parts[1] ?? "00"} (useAMPM && Number(hours) > 12 ? Number(hours) - 12 : hours)}
.sec=${parts[2] ?? "00"} .min=${parts[1]}
.sec=${parts[2]}
.format=${useAMPM ? 12 : 24} .format=${useAMPM ? 12 : 24}
.amPm=${useAMPM && (Number(hours) > 12 ? "PM" : "AM")} .amPm=${useAMPM && (Number(hours) > 12 ? "PM" : "AM")}
.disabled=${this.disabled} .disabled=${this.disabled}
@ -42,12 +47,16 @@ export class HaTimeSelector extends LitElement {
private _timeChanged(ev) { private _timeChanged(ev) {
let value = ev.target.value; let value = ev.target.value;
if (useAMPM) { const useAMPM = this._useAmPm(this.hass.locale.language);
let hours = Number(ev.target.hour); let hours = Number(ev.target.hour || 0);
if (value && useAMPM) {
if (ev.target.amPm === "PM") { if (ev.target.amPm === "PM") {
hours += 12; 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", { fireEvent(this, "value-changed", {
value, value,

View File

@ -1,3 +1,4 @@
import { mdiHelpCircle } from "@mdi/js";
import { HassService, HassServiceTarget } from "home-assistant-js-websocket"; import { HassService, HassServiceTarget } from "home-assistant-js-websocket";
import { import {
css, css,
@ -18,11 +19,12 @@ import { ENTITY_COMPONENT_DOMAINS } from "../data/entity";
import { Selector } from "../data/selector"; import { Selector } from "../data/selector";
import { PolymerChangedEvent } from "../polymer-types"; import { PolymerChangedEvent } from "../polymer-types";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
import { documentationUrl } from "../util/documentation-url";
import "./ha-checkbox";
import "./ha-selector/ha-selector"; import "./ha-selector/ha-selector";
import "./ha-service-picker"; import "./ha-service-picker";
import "./ha-settings-row"; import "./ha-settings-row";
import "./ha-yaml-editor"; import "./ha-yaml-editor";
import "./ha-checkbox";
import type { HaYamlEditor } from "./ha-yaml-editor"; import type { HaYamlEditor } from "./ha-yaml-editor";
interface ExtHassService extends Omit<HassService, "fields"> { interface ExtHassService extends Omit<HassService, "fields"> {
@ -49,6 +51,8 @@ export class HaServiceControl extends LitElement {
data?: Record<string, any>; data?: Record<string, any>;
}; };
@internalProperty() private _value!: this["value"];
@property({ reflect: true, type: Boolean }) public narrow!: boolean; @property({ reflect: true, type: Boolean }) public narrow!: boolean;
@property({ type: Boolean }) public showAdvanced?: boolean; @property({ type: Boolean }) public showAdvanced?: boolean;
@ -57,7 +61,7 @@ export class HaServiceControl extends LitElement {
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor; @query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;
protected updated(changedProperties: PropertyValues) { protected updated(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("value")) { if (!changedProperties.has("value")) {
return; return;
} }
@ -92,21 +96,23 @@ export class HaServiceControl extends LitElement {
target.device_id = this.value.data.device_id; target.device_id = this.value.data.device_id;
} }
this.value = { this._value = {
...this.value, ...this.value,
target, target,
data: { ...this.value.data }, data: { ...this.value.data },
}; };
delete this.value.data!.entity_id; delete this._value.data!.entity_id;
delete this.value.data!.device_id; delete this._value.data!.device_id;
delete this.value.data!.area_id; delete this._value.data!.area_id;
} else {
this._value = this.value;
} }
if (this.value?.data) { if (this._value?.data) {
const yamlEditor = this._yamlEditor; const yamlEditor = this._yamlEditor;
if (yamlEditor && yamlEditor.value !== this.value.data) { if (yamlEditor && yamlEditor.value !== this._value.data) {
yamlEditor.setValue(this.value.data); yamlEditor.setValue(this._value.data);
} }
} }
} }
@ -151,12 +157,12 @@ export class HaServiceControl extends LitElement {
}); });
protected render() { protected render() {
const serviceData = this._getServiceInfo(this.value?.service); const serviceData = this._getServiceInfo(this._value?.service);
const shouldRenderServiceDataYaml = const shouldRenderServiceDataYaml =
(serviceData?.fields.length && !serviceData.hasSelector.length) || (serviceData?.fields.length && !serviceData.hasSelector.length) ||
(serviceData && (serviceData &&
Object.keys(this.value?.data || {}).some( Object.keys(this._value?.data || {}).some(
(key) => !serviceData!.hasSelector.includes(key) (key) => !serviceData!.hasSelector.includes(key)
)); ));
@ -171,10 +177,32 @@ export class HaServiceControl extends LitElement {
return html`<ha-service-picker return html`<ha-service-picker
.hass=${this.hass} .hass=${this.hass}
.value=${this.value?.service} .value=${this._value?.service}
@value-changed=${this._serviceChanged} @value-changed=${this._serviceChanged}
></ha-service-picker> ></ha-service-picker>
<div class="description">
<p>${serviceData?.description}</p> <p>${serviceData?.description}</p>
${this.value?.service
? html` <a
href="${documentationUrl(
this.hass,
"/integrations/" + computeDomain(this.value?.service)
)}"
title="${this.hass.localize(
"ui.components.service-control.integration_doc"
)}"
target="_blank"
rel="noreferrer"
>
<mwc-icon-button>
<ha-svg-icon
path=${mdiHelpCircle}
class="help-icon"
></ha-svg-icon>
</mwc-icon-button>
</a>`
: ""}
</div>
${serviceData && "target" in serviceData ${serviceData && "target" in serviceData
? html`<ha-settings-row .narrow=${this.narrow}> ? html`<ha-settings-row .narrow=${this.narrow}>
${hasOptional ${hasOptional
@ -195,19 +223,19 @@ export class HaServiceControl extends LitElement {
? { target: serviceData.target } ? { target: serviceData.target }
: { : {
target: { target: {
entity: { domain: computeDomain(this.value!.service) }, entity: { domain: computeDomain(this._value!.service) },
}, },
}} }}
@value-changed=${this._targetChanged} @value-changed=${this._targetChanged}
.value=${this.value?.target} .value=${this._value?.target}
></ha-selector ></ha-selector
></ha-settings-row>` ></ha-settings-row>`
: entityId : entityId
? html`<ha-entity-picker ? html`<ha-entity-picker
.hass=${this.hass} .hass=${this.hass}
.value=${this.value?.data?.entity_id} .value=${this._value?.data?.entity_id}
.label=${entityId.description} .label=${entityId.description}
.includeDomains=${this._domainFilter(this.value!.service)} .includeDomains=${this._domainFilter(this._value!.service)}
@value-changed=${this._entityPicked} @value-changed=${this._entityPicked}
allow-custom-entity allow-custom-entity
></ha-entity-picker>` ></ha-entity-picker>`
@ -218,15 +246,15 @@ export class HaServiceControl extends LitElement {
"ui.components.service-control.service_data" "ui.components.service-control.service_data"
)} )}
.name=${"data"} .name=${"data"}
.defaultValue=${this.value?.data} .defaultValue=${this._value?.data}
@value-changed=${this._dataChanged} @value-changed=${this._dataChanged}
></ha-yaml-editor>` ></ha-yaml-editor>`
: serviceData?.fields.map((dataField) => : serviceData?.fields.map((dataField) =>
dataField.selector && dataField.selector &&
(!dataField.advanced || (!dataField.advanced ||
this.showAdvanced || this.showAdvanced ||
(this.value?.data && (this._value?.data &&
this.value.data[dataField.key] !== undefined)) this._value.data[dataField.key] !== undefined))
? html`<ha-settings-row .narrow=${this.narrow}> ? html`<ha-settings-row .narrow=${this.narrow}>
${dataField.required ${dataField.required
? hasOptional ? hasOptional
@ -235,8 +263,8 @@ export class HaServiceControl extends LitElement {
: html`<ha-checkbox : html`<ha-checkbox
.key=${dataField.key} .key=${dataField.key}
.checked=${this._checkedKeys.has(dataField.key) || .checked=${this._checkedKeys.has(dataField.key) ||
(this.value?.data && (this._value?.data &&
this.value.data[dataField.key] !== undefined)} this._value.data[dataField.key] !== undefined)}
@change=${this._checkboxChanged} @change=${this._checkboxChanged}
slot="prefix" slot="prefix"
></ha-checkbox>`} ></ha-checkbox>`}
@ -245,15 +273,15 @@ export class HaServiceControl extends LitElement {
><ha-selector ><ha-selector
.disabled=${!dataField.required && .disabled=${!dataField.required &&
!this._checkedKeys.has(dataField.key) && !this._checkedKeys.has(dataField.key) &&
(!this.value?.data || (!this._value?.data ||
this.value.data[dataField.key] === undefined)} this._value.data[dataField.key] === undefined)}
.hass=${this.hass} .hass=${this.hass}
.selector=${dataField.selector} .selector=${dataField.selector}
.key=${dataField.key} .key=${dataField.key}
@value-changed=${this._serviceDataChanged} @value-changed=${this._serviceDataChanged}
.value=${this.value?.data && .value=${this._value?.data &&
this.value.data[dataField.key] !== undefined this._value.data[dataField.key] !== undefined
? this.value.data[dataField.key] ? this._value.data[dataField.key]
: dataField.default} : dataField.default}
></ha-selector ></ha-selector
></ha-settings-row>` ></ha-settings-row>`
@ -268,13 +296,13 @@ export class HaServiceControl extends LitElement {
this._checkedKeys.add(key); this._checkedKeys.add(key);
} else { } else {
this._checkedKeys.delete(key); this._checkedKeys.delete(key);
const data = { ...this.value?.data }; const data = { ...this._value?.data };
delete data[key]; delete data[key];
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
value: { value: {
...this.value, ...this._value,
data, data,
}, },
}); });
@ -284,7 +312,7 @@ export class HaServiceControl extends LitElement {
private _serviceChanged(ev: PolymerChangedEvent<string>) { private _serviceChanged(ev: PolymerChangedEvent<string>) {
ev.stopPropagation(); ev.stopPropagation();
if (ev.detail.value === this.value?.service) { if (ev.detail.value === this._value?.service) {
return; return;
} }
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
@ -295,17 +323,17 @@ export class HaServiceControl extends LitElement {
private _entityPicked(ev: CustomEvent) { private _entityPicked(ev: CustomEvent) {
ev.stopPropagation(); ev.stopPropagation();
const newValue = ev.detail.value; const newValue = ev.detail.value;
if (this.value?.data?.entity_id === newValue) { if (this._value?.data?.entity_id === newValue) {
return; return;
} }
let value; let value;
if (!newValue && this.value?.data) { if (!newValue && this._value?.data) {
value = { ...this.value }; value = { ...this._value };
delete value.data.entity_id; delete value.data.entity_id;
} else { } else {
value = { value = {
...this.value, ...this._value,
data: { ...this.value?.data, entity_id: ev.detail.value }, data: { ...this._value?.data, entity_id: ev.detail.value },
}; };
} }
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
@ -316,15 +344,15 @@ export class HaServiceControl extends LitElement {
private _targetChanged(ev: CustomEvent) { private _targetChanged(ev: CustomEvent) {
ev.stopPropagation(); ev.stopPropagation();
const newValue = ev.detail.value; const newValue = ev.detail.value;
if (this.value?.target === newValue) { if (this._value?.target === newValue) {
return; return;
} }
let value; let value;
if (!newValue) { if (!newValue) {
value = { ...this.value }; value = { ...this._value };
delete value.target; delete value.target;
} else { } else {
value = { ...this.value, target: ev.detail.value }; value = { ...this._value, target: ev.detail.value };
} }
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
value, value,
@ -336,13 +364,13 @@ export class HaServiceControl extends LitElement {
const key = (ev.currentTarget as any).key; const key = (ev.currentTarget as any).key;
const value = ev.detail.value; const value = ev.detail.value;
if ( if (
this.value?.data?.[key] === value || this._value?.data?.[key] === value ||
(!this.value?.data?.[key] && (value === "" || value === undefined)) (!this._value?.data?.[key] && (value === "" || value === undefined))
) { ) {
return; return;
} }
const data = { ...this.value?.data, [key]: value }; const data = { ...this._value?.data, [key]: value };
if (value === "" || value === undefined) { if (value === "" || value === undefined) {
delete data[key]; delete data[key];
@ -350,7 +378,7 @@ export class HaServiceControl extends LitElement {
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
value: { value: {
...this.value, ...this._value,
data, data,
}, },
}); });
@ -363,7 +391,7 @@ export class HaServiceControl extends LitElement {
} }
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
value: { value: {
...this.value, ...this._value,
data: ev.detail.value, data: ev.detail.value,
}, },
}); });
@ -406,6 +434,15 @@ export class HaServiceControl extends LitElement {
ha-checkbox { ha-checkbox {
margin-left: -16px; margin-left: -16px;
} }
.help-icon {
color: var(--secondary-text-color);
}
.description {
justify-content: space-between;
display: flex;
align-items: center;
padding-right: 2px;
}
`; `;
} }
} }

View File

@ -133,7 +133,7 @@ export class PaperTimeInput extends PolymerElement {
always-float-label$="[[alwaysFloatInputLabels]]" always-float-label$="[[alwaysFloatInputLabels]]"
disabled="[[disabled]]" disabled="[[disabled]]"
> >
<span suffix="" slot="suffix">:</span> <span suffix slot="suffix">:</span>
</paper-input> </paper-input>
<!-- Min Input --> <!-- Min Input -->
@ -303,28 +303,28 @@ export class PaperTimeInput extends PolymerElement {
notify: true, notify: true,
}, },
/** /**
* Suffix for the hour input * Label for the hour input
*/ */
hourLabel: { hourLabel: {
type: String, type: String,
value: "", value: "",
}, },
/** /**
* Suffix for the min input * Label for the min input
*/ */
minLabel: { minLabel: {
type: String, type: String,
value: ":", value: "",
}, },
/** /**
* Suffix for the sec input * Label for the sec input
*/ */
secLabel: { secLabel: {
type: String, type: String,
value: "", value: "",
}, },
/** /**
* Suffix for the milli sec input * Label for the milli sec input
*/ */
millisecLabel: { millisecLabel: {
type: String, type: String,

View File

@ -314,16 +314,18 @@ class ActionRenderer {
if (defaultExecuted) { if (defaultExecuted) {
this._renderEntry(choosePath, `${name}: Default action executed`); this._renderEntry(choosePath, `${name}: Default action executed`);
} else { } else if (chooseTrace.result) {
const choiceConfig = this._getDataFromPath( const choiceConfig = this._getDataFromPath(
`${this.keys[index]}/choose/${chooseTrace.result?.choice}` `${this.keys[index]}/choose/${chooseTrace.result.choice}`
) as ChooseActionChoice | undefined; ) as ChooseActionChoice | undefined;
const choiceName = choiceConfig const choiceName = choiceConfig
? `${ ? `${
choiceConfig.alias || `Choice ${chooseTrace.result?.choice}` choiceConfig.alias || `Choice ${chooseTrace.result.choice}`
} executed` } executed`
: `Error: ${chooseTrace.error}`; : `Error: ${chooseTrace.error}`;
this._renderEntry(choosePath, `${name}: ${choiceName}`); this._renderEntry(choosePath, `${name}: ${choiceName}`);
} else {
this._renderEntry(choosePath, `${name}: No action taken`);
} }
let i; let i;

View File

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

View File

@ -5,11 +5,18 @@ export interface ConfigEntry {
domain: string; domain: string;
title: string; title: string;
source: string; source: string;
state: string; state:
| "loaded"
| "setup_error"
| "migration_error"
| "setup_retry"
| "not_loaded"
| "failed_unload";
connection_class: string; connection_class: string;
supports_options: boolean; supports_options: boolean;
supports_unload: boolean; supports_unload: boolean;
disabled_by: string | null; disabled_by: "user" | null;
reason: string | null;
} }
export interface ConfigEntryMutableParams { export interface ConfigEntryMutableParams {

View File

@ -28,6 +28,7 @@ export interface DataEntryFlowStepForm {
data_schema: HaFormSchema[]; data_schema: HaFormSchema[];
errors: Record<string, string>; errors: Record<string, string>;
description_placeholders: Record<string, string>; description_placeholders: Record<string, string>;
last_step: boolean | null;
} }
export interface DataEntryFlowStepExternal { export interface DataEntryFlowStepExternal {

View File

@ -9,13 +9,13 @@ export interface DeviceRegistryEntry {
config_entries: string[]; config_entries: string[];
connections: Array<[string, string]>; connections: Array<[string, string]>;
identifiers: Array<[string, string]>; identifiers: Array<[string, string]>;
manufacturer: string; manufacturer: string | null;
model?: string; model: string | null;
name?: string; name: string | null;
sw_version?: string; sw_version: string | null;
via_device_id?: string; via_device_id: string | null;
area_id?: string; area_id: string | null;
name_by_user?: string; name_by_user: string | null;
entry_type: "service" | null; entry_type: "service" | null;
disabled_by: string | null; disabled_by: string | null;
} }

View File

@ -5,12 +5,12 @@ import { HomeAssistant } from "../types";
export interface EntityRegistryEntry { export interface EntityRegistryEntry {
entity_id: string; entity_id: string;
name: string; name: string | null;
icon?: string; icon: string | null;
platform: string; platform: string;
config_entry_id?: string; config_entry_id: string | null;
device_id?: string; device_id: string | null;
area_id?: string; area_id: string | null;
disabled_by: string | null; disabled_by: string | null;
} }

View File

@ -15,7 +15,18 @@ export interface IntegrationManifest {
ssdp?: Array<{ manufacturer?: string; modelName?: string; st?: string }>; ssdp?: Array<{ manufacturer?: string; modelName?: string; st?: string }>;
zeroconf?: string[]; zeroconf?: string[];
homekit?: { models: 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 = ( export const integrationIssuesUrl = (
@ -38,3 +49,6 @@ export const fetchIntegrationManifest = (
hass: HomeAssistant, hass: HomeAssistant,
integration: string integration: string
) => hass.callWS<IntegrationManifest>({ type: "manifest/get", integration }); ) => hass.callWS<IntegrationManifest>({ type: "manifest/get", integration });
export const fetchIntegrationSetups = (hass: HomeAssistant) =>
hass.callWS<IntegrationSetup[]>({ type: "integration/setup_info" });

View File

@ -3,26 +3,82 @@ import {
HassEntityBase, HassEntityBase,
} from "home-assistant-js-websocket"; } 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 { interface LightEntityAttributes extends HassEntityAttributeBase {
min_mireds: number; min_mireds: number;
max_mireds: number; max_mireds: number;
friendly_name: string; friendly_name: string;
brightness: number; 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; color_temp: number;
white_value: number;
effect?: string; effect?: string;
effect_list: string[] | null; effect_list: string[] | null;
supported_color_modes: LightColorModes[];
color_mode: LightColorModes;
} }
export interface LightEntity extends HassEntityBase { export interface LightEntity extends HassEntityBase {
attributes: LightEntityAttributes; 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;

View File

@ -19,6 +19,10 @@ export interface LovelacePanelConfig {
export interface LovelaceConfig { export interface LovelaceConfig {
title?: string; title?: string;
strategy?: {
type: string;
options?: Record<string, unknown>;
};
views: LovelaceViewConfig[]; views: LovelaceViewConfig[];
background?: string; background?: string;
} }
@ -77,6 +81,10 @@ export interface LovelaceViewConfig {
index?: number; index?: number;
title?: string; title?: string;
type?: string; type?: string;
strategy?: {
type: string;
options?: Record<string, unknown>;
};
badges?: Array<string | LovelaceBadgeConfig>; badges?: Array<string | LovelaceBadgeConfig>;
cards?: LovelaceCardConfig[]; cards?: LovelaceCardConfig[];
path?: string; path?: string;
@ -94,6 +102,7 @@ export interface LovelaceViewElement extends HTMLElement {
index?: number; index?: number;
cards?: Array<LovelaceCard | HuiErrorCard>; cards?: Array<LovelaceCard | HuiErrorCard>;
badges?: LovelaceBadge[]; badges?: LovelaceBadge[];
isStrategy: boolean;
setConfig(config: LovelaceViewConfig): void; setConfig(config: LovelaceViewConfig): void;
} }

View File

@ -292,9 +292,11 @@ export const computeMediaControls = (
? "hass:pause" ? "hass:pause"
: "hass:stop", : "hass:stop",
action: action:
state === "playing" && !supportsFeature(stateObj, SUPPORT_PAUSE) state !== "playing"
? "media_stop" ? "media_play"
: "media_play_pause", : supportsFeature(stateObj, SUPPORT_PAUSE)
? "media_pause"
: "media_stop",
}); });
} }

View File

@ -6,3 +6,6 @@ export const callExecuteScript = (hass: HomeAssistant, sequence: Action[]) =>
type: "execute_script", type: "execute_script",
sequence, sequence,
}); });
export const serviceCallWillDisconnect = (domain: string, service: string) =>
domain === "homeassistant" && ["restart", "stop"].includes(service);

View File

@ -16,9 +16,27 @@ export interface LoggedError {
export const fetchSystemLog = (hass: HomeAssistant) => export const fetchSystemLog = (hass: HomeAssistant) =>
hass.callApi<LoggedError[]>("GET", "error/all"); hass.callApi<LoggedError[]>("GET", "error/all");
export const getLoggedErrorIntegration = (item: LoggedError) => export const getLoggedErrorIntegration = (item: LoggedError) => {
item.name.startsWith("homeassistant.components.") // Try to derive from logger name
? item.name.split(".")[2] if (item.name.startsWith("homeassistant.components.")) {
: item.name.startsWith("custom_components.") return item.name.split(".")[2];
? item.name.split(".")[1] }
: undefined; 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/");

View File

@ -1,4 +1,5 @@
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { HaFormSchema } from "../components/ha-form/ha-form";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
export interface ZHAEntityReference extends HassEntity { export interface ZHAEntityReference extends HassEntity {
@ -54,6 +55,52 @@ export interface Cluster {
type: string; 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<number, AttributeConfigurationStatus>;
}
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 { export interface Command {
name: string; name: string;
id: number; id: number;
@ -75,6 +122,11 @@ export interface ZHAGroup {
members: ZHADeviceEndpoint[]; members: ZHADeviceEndpoint[];
} }
export interface ZHAConfiguration {
data: Record<string, Record<string, unknown>>;
schemas: Record<string, HaFormSchema[]>;
}
export interface ZHAGroupMember { export interface ZHAGroupMember {
ieee: string; ieee: string;
endpoint_id: string; endpoint_id: string;
@ -83,10 +135,10 @@ export interface ZHAGroupMember {
export const reconfigureNode = ( export const reconfigureNode = (
hass: HomeAssistant, hass: HomeAssistant,
ieeeAddress: string, ieeeAddress: string,
callbackFunction: any callbackFunction: (message: ClusterConfigurationEvent) => void
) => { ) => {
return hass.connection.subscribeMessage( return hass.connection.subscribeMessage(
(message) => callbackFunction(message), (message: ClusterConfigurationEvent) => callbackFunction(message),
{ {
type: "zha/devices/reconfigure", type: "zha/devices/reconfigure",
ieee: ieeeAddress, ieee: ieeeAddress,
@ -282,6 +334,22 @@ export const addGroup = (
members: membersToAdd, members: membersToAdd,
}); });
export const fetchZHAConfiguration = (
hass: HomeAssistant
): Promise<ZHAConfiguration> =>
hass.callWS({
type: "zha/configuration",
});
export const updateZHAConfiguration = (
hass: HomeAssistant,
data: any
): Promise<any> =>
hass.callWS({
type: "zha/configuration/update",
data: data,
});
export const INITIALIZED = "INITIALIZED"; export const INITIALIZED = "INITIALIZED";
export const INTERVIEW_COMPLETE = "INTERVIEW_COMPLETE"; export const INTERVIEW_COMPLETE = "INTERVIEW_COMPLETE";
export const CONFIGURED = "CONFIGURED"; export const CONFIGURED = "CONFIGURED";
@ -301,3 +369,7 @@ export const DEVICE_MESSAGE_TYPES = [
DEVICE_FULLY_INITIALIZED, DEVICE_FULLY_INITIALIZED,
]; ];
export const LOG_OUTPUT = "log_output"; 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";

View File

@ -1,3 +1,4 @@
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
import { DeviceRegistryEntry } from "./device_registry"; import { DeviceRegistryEntry } from "./device_registry";
@ -29,6 +30,10 @@ export interface ZWaveJSNode {
} }
export interface ZWaveJSNodeConfigParams { export interface ZWaveJSNodeConfigParams {
[key: string]: ZWaveJSNodeConfigParam;
}
export interface ZWaveJSNodeConfigParam {
property: number; property: number;
value: any; value: any;
configuration_value_type: string; configuration_value_type: string;
@ -56,6 +61,22 @@ export interface ZWaveJSSetConfigParamData {
value: string | number; 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 { export enum NodeStatus {
Unknown, Unknown,
Asleep, Asleep,
@ -75,6 +96,26 @@ export const fetchNetworkStatus = (
entry_id, entry_id,
}); });
export const fetchDataCollectionStatus = (
hass: HomeAssistant,
entry_id: string
): Promise<ZWaveJSDataCollectionStatus> =>
hass.callWS({
type: "zwave_js/data_collection_status",
entry_id,
});
export const setDataCollectionPreference = (
hass: HomeAssistant,
entry_id: string,
opted_in: boolean
): Promise<any> =>
hass.callWS({
type: "zwave_js/update_data_collection_preference",
entry_id,
opted_in,
});
export const fetchNodeStatus = ( export const fetchNodeStatus = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, entry_id: string,
@ -90,7 +131,7 @@ export const fetchNodeConfigParameters = (
hass: HomeAssistant, hass: HomeAssistant,
entry_id: string, entry_id: string,
node_id: number node_id: number
): Promise<ZWaveJSNodeConfigParams[]> => ): Promise<ZWaveJSNodeConfigParams> =>
hass.callWS({ hass.callWS({
type: "zwave_js/get_config_parameters", type: "zwave_js/get_config_parameters",
entry_id, entry_id,
@ -104,7 +145,7 @@ export const setNodeConfigParameter = (
property: number, property: number,
value: number, value: number,
property_key?: number property_key?: number
): Promise<unknown> => { ): Promise<ZWaveJSSetConfigParamResult> => {
const data: ZWaveJSSetConfigParamData = { const data: ZWaveJSSetConfigParamData = {
type: "zwave_js/set_config_parameter", type: "zwave_js/set_config_parameter",
entry_id, entry_id,
@ -116,9 +157,25 @@ export const setNodeConfigParameter = (
return hass.callWS(data); return hass.callWS(data);
}; };
export const getIdentifiersFromDevice = function ( export const reinterviewNode = (
hass: HomeAssistant,
entry_id: string,
node_id: number,
callbackFunction: (message: ZWaveJSRefreshNodeStatusMessage) => void
): Promise<UnsubscribeFunc> => {
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 device: DeviceRegistryEntry
): ZWaveJSNodeIdentifiers | undefined { ): ZWaveJSNodeIdentifiers | undefined => {
if (!device) { if (!device) {
return undefined; return undefined;
} }
@ -136,3 +193,48 @@ export const getIdentifiersFromDevice = function (
home_id: identifiers[0], 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<ZWaveJSLogMessage>(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<ZWaveJSLogConfig> =>
hass.callWS({
type: "zwave_js/get_log_config",
entry_id,
});
export const setZWaveJSLogLevel = (
hass: HomeAssistant,
entry_id: string,
level: string
): Promise<ZWaveJSLogConfig> =>
hass.callWS({
type: "zwave_js/update_log_config",
entry_id,
config: { level },
});

View File

@ -45,7 +45,8 @@ export const showDialog = async (
root: ShadowRoot | HTMLElement, root: ShadowRoot | HTMLElement,
dialogTag: string, dialogTag: string,
dialogParams: unknown, dialogParams: unknown,
dialogImport?: () => Promise<unknown> dialogImport?: () => Promise<unknown>,
addHistory = true
) => { ) => {
if (!(dialogTag in LOADED)) { if (!(dialogTag in LOADED)) {
if (!dialogImport) { if (!dialogImport) {
@ -59,36 +60,37 @@ export const showDialog = async (
}); });
} }
history.replaceState( if (addHistory) {
top.history.replaceState(
{ {
dialog: dialogTag, dialog: dialogTag,
open: false, open: false,
oldState: oldState:
history.state?.open && history.state?.dialog !== dialogTag top.history.state?.open && top.history.state?.dialog !== dialogTag
? history.state ? top.history.state
: null, : null,
}, },
"" ""
); );
try { try {
history.pushState( top.history.pushState(
{ dialog: dialogTag, dialogParams: dialogParams, open: true }, { dialog: dialogTag, dialogParams: dialogParams, open: true },
"" ""
); );
} catch (err) { } catch (err) {
// dialogParams could not be cloned, probably contains callback // dialogParams could not be cloned, probably contains callback
history.pushState( top.history.pushState(
{ dialog: dialogTag, dialogParams: null, open: true }, { dialog: dialogTag, dialogParams: null, open: true },
"" ""
); );
} }
}
const dialogElement = await LOADED[dialogTag]; const dialogElement = await LOADED[dialogTag];
dialogElement.showDialog(dialogParams); dialogElement.showDialog(dialogParams);
}; };
export const replaceDialog = () => { export const replaceDialog = () => {
history.replaceState({ ...history.state, replaced: true }, ""); top.history.replaceState({ ...top.history.state, replaced: true }, "");
}; };
export const closeDialog = async (dialogTag: string): Promise<boolean> => { export const closeDialog = async (dialogTag: string): Promise<boolean> => {

View File

@ -11,7 +11,6 @@ import {
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
} from "lit-element"; } from "lit-element";
import { classMap } from "lit-html/directives/class-map";
import { supportsFeature } from "../../../common/entity/supports-feature"; import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-attributes"; import "../../../components/ha-attributes";
import "../../../components/ha-color-picker"; import "../../../components/ha-color-picker";
@ -19,20 +18,22 @@ import "../../../components/ha-icon-button";
import "../../../components/ha-labeled-slider"; import "../../../components/ha-labeled-slider";
import "../../../components/ha-paper-dropdown-menu"; import "../../../components/ha-paper-dropdown-menu";
import { import {
getLightRgbColor,
LightColorModes,
LightEntity, LightEntity,
SUPPORT_BRIGHTNESS, lightIsInColorMode,
SUPPORT_COLOR, lightSupportsColor,
SUPPORT_COLOR_TEMP, lightSupportsColorMode,
lightSupportsDimming,
SUPPORT_EFFECT, SUPPORT_EFFECT,
SUPPORT_WHITE_VALUE,
} from "../../../data/light"; } from "../../../data/light";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import "../../../components/ha-button-toggle-group";
interface HueSatColor { const toggleButtons = [
h: number; { label: "Color", value: "color" },
s: number; { label: "Temperature", value: LightColorModes.COLOR_TEMP },
} ];
@customElement("more-info-light") @customElement("more-info-light")
class MoreInfoLight extends LitElement { class MoreInfoLight extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -41,28 +42,51 @@ class MoreInfoLight extends LitElement {
@internalProperty() private _brightnessSliderValue = 0; @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 _hueSegments = 24;
@internalProperty() private _saturationSegments = 8; @internalProperty() private _saturationSegments = 8;
@internalProperty() private _colorPickerColor?: HueSatColor; @internalProperty() private _colorPickerColor?: [number, number, number];
@internalProperty() private _mode?: "color" | LightColorModes.COLOR_TEMP;
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this.hass || !this.stateObj) { if (!this.hass || !this.stateObj) {
return html``; 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` return html`
<div <div class="content">
class="content ${classMap({ ${lightSupportsDimming(this.stateObj)
"is-on": this.stateObj.state === "on",
})}"
>
${supportsFeature(this.stateObj!, SUPPORT_BRIGHTNESS)
? html` ? html`
<ha-labeled-slider <ha-labeled-slider
caption=${this.hass.localize("ui.card.light.brightness")} caption=${this.hass.localize("ui.card.light.brightness")}
@ -77,7 +101,17 @@ class MoreInfoLight extends LitElement {
: ""} : ""}
${this.stateObj.state === "on" ${this.stateObj.state === "on"
? html` ? html`
${supportsFeature(this.stateObj, SUPPORT_COLOR_TEMP) ${supportsTemp || supportsColor ? html`<hr></hr>` : ""}
${supportsTemp && supportsColor
? html`<ha-button-toggle-group
fullWidth
.buttons=${toggleButtons}
.active=${this._mode}
@value-changed=${this._modeChanged}
></ha-button-toggle-group>`
: ""}
${supportsTemp &&
(!supportsColor || this._mode === LightColorModes.COLOR_TEMP)
? html` ? html`
<ha-labeled-slider <ha-labeled-slider
class="color_temp" class="color_temp"
@ -91,27 +125,16 @@ class MoreInfoLight extends LitElement {
@change=${this._ctSliderChanged} @change=${this._ctSliderChanged}
pin pin
></ha-labeled-slider> ></ha-labeled-slider>
<hr></hr>
` `
: ""} : ""}
${supportsFeature(this.stateObj, SUPPORT_WHITE_VALUE) ${supportsColor && (!supportsTemp || this._mode === "color")
? html`
<ha-labeled-slider
caption=${this.hass.localize("ui.card.light.white_value")}
icon="hass:file-word-box"
max="255"
.value=${this._wvSliderValue}
@change=${this._wvSliderChanged}
pin
></ha-labeled-slider>
`
: ""}
${supportsFeature(this.stateObj, SUPPORT_COLOR)
? html` ? html`
<div class="segmentationContainer"> <div class="segmentationContainer">
<ha-color-picker <ha-color-picker
class="color" class="color"
@colorselected=${this._colorPicked} @colorselected=${this._colorPicked}
.desiredHsColor=${this._colorPickerColor} .desiredRgbColor=${this._colorPickerColor}
throttle="500" throttle="500"
.hueSegments=${this._hueSegments} .hueSegments=${this._hueSegments}
.saturationSegments=${this._saturationSegments} .saturationSegments=${this._saturationSegments}
@ -123,6 +146,67 @@ class MoreInfoLight extends LitElement {
class="segmentationButton" class="segmentationButton"
></ha-icon-button> ></ha-icon-button>
</div> </div>
${
supportsRgbw || supportsRgbww
? html`<ha-labeled-slider
.caption=${this.hass.localize(
"ui.card.light.color_brightness"
)}
icon="hass:brightness-7"
max="100"
.value=${this._colorBrightnessSliderValue ?? 255}
@change=${this._colorBrightnessSliderChanged}
pin
></ha-labeled-slider>`
: ""
}
${
supportsRgbw
? html`
<ha-labeled-slider
.caption=${this.hass.localize(
"ui.card.light.white_value"
)}
icon="hass:file-word-box"
max="100"
.name=${"wv"}
.value=${this._wvSliderValue}
@change=${this._wvSliderChanged}
pin
></ha-labeled-slider>
`
: ""
}
${
supportsRgbww
? html`
<ha-labeled-slider
.caption=${this.hass.localize(
"ui.card.light.cold_white_value"
)}
icon="hass:file-word-box-outline"
max="100"
.name=${"cw"}
.value=${this._cwSliderValue}
@change=${this._wvSliderChanged}
pin
></ha-labeled-slider>
<ha-labeled-slider
.caption=${this.hass.localize(
"ui.card.light.warm_white_value"
)}
icon="hass:file-word-box"
max="100"
.name=${"ww"}
.value=${this._wwSliderValue}
@change=${this._wvSliderChanged}
pin
></ha-labeled-slider>
`
: ""
}
<hr></hr>
` `
: ""} : ""}
${supportsFeature(this.stateObj, SUPPORT_EFFECT) && ${supportsFeature(this.stateObj, SUPPORT_EFFECT) &&
@ -151,32 +235,83 @@ class MoreInfoLight extends LitElement {
: ""} : ""}
<ha-attributes <ha-attributes
.stateObj=${this.stateObj} .stateObj=${this.stateObj}
extra-filters="brightness,color_temp,white_value,effect_list,effect,hs_color,rgb_color,xy_color,min_mireds,max_mireds,entity_id" extra-filters="brightness,color_temp,white_value,effect_list,effect,hs_color,rgb_color,rgbw_color,rgbww_color,xy_color,min_mireds,max_mireds,entity_id,supported_color_modes,color_mode"
></ha-attributes> ></ha-attributes>
</div> </div>
`; `;
} }
protected updated(changedProps: PropertyValues): void { protected updated(changedProps: PropertyValues<this>) {
if (!changedProps.has("stateObj")) {
return;
}
const stateObj = this.stateObj! as LightEntity; const stateObj = this.stateObj! as LightEntity;
if (changedProps.has("stateObj")) { const oldStateObj = changedProps.get("stateObj") as LightEntity | undefined;
if (stateObj.state === "on") { 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._brightnessAdjusted = undefined;
}
this._brightnessSliderValue = Math.round( this._brightnessSliderValue = Math.round(
(stateObj.attributes.brightness * 100) / 255 (stateObj.attributes.brightness * brightnessAdjust) / 255
); );
this._ctSliderValue = stateObj.attributes.color_temp; this._ctSliderValue = stateObj.attributes.color_temp;
this._wvSliderValue = stateObj.attributes.white_value; 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;
if (stateObj.attributes.hs_color) { this._colorPickerColor = getLightRgbColor(stateObj)?.slice(0, 3) as
this._colorPickerColor = { | [number, number, number]
h: stateObj.attributes.hs_color[0], | undefined;
s: stateObj.attributes.hs_color[1] / 100,
};
}
} else { } else {
this._brightnessSliderValue = 0; this._brightnessSliderValue = 0;
} }
} }
private _modeChanged(ev: CustomEvent) {
this._mode = ev.detail.value;
} }
private _effectChanged(ev: CustomEvent) { private _effectChanged(ev: CustomEvent) {
@ -193,12 +328,29 @@ class MoreInfoLight extends LitElement {
} }
private _brightnessSliderChanged(ev: CustomEvent) { private _brightnessSliderChanged(ev: CustomEvent) {
const bri = parseInt((ev.target as any).value, 10); const bri = Number((ev.target as any).value);
if (isNaN(bri)) { if (isNaN(bri)) {
return; 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", { this.hass.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id, entity_id: this.stateObj!.entity_id,
brightness_pct: bri, brightness_pct: bri,
@ -206,7 +358,7 @@ class MoreInfoLight extends LitElement {
} }
private _ctSliderChanged(ev: CustomEvent) { private _ctSliderChanged(ev: CustomEvent) {
const ct = parseInt((ev.target as any).value, 10); const ct = Number((ev.target as any).value);
if (isNaN(ct)) { if (isNaN(ct)) {
return; return;
@ -219,16 +371,62 @@ class MoreInfoLight extends LitElement {
} }
private _wvSliderChanged(ev: CustomEvent) { 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)) { if (isNaN(wv)) {
return; 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", { this.hass.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id, entity_id: this.stateObj!.entity_id,
white_value: wv, 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,
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() { private _segmentClick() {
@ -241,16 +439,91 @@ 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. * Called when a new color has been picked.
* should be throttled with the 'throttle=' attribute of the color picker * should be throttled with the 'throttle=' attribute of the color picker
*/ */
private _colorPicked(ev: CustomEvent) { private _colorPicked(ev: CustomEvent) {
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", { this.hass.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id, entity_id: this.stateObj!.entity_id,
hs_color: [ev.detail.hs.h, ev.detail.hs.s * 100], hs_color: [ev.detail.hs.h, ev.detail.hs.s * 100],
}); });
} }
}
static get styles(): CSSResult { static get styles(): CSSResult {
return css` return css`
@ -275,11 +548,18 @@ class MoreInfoLight extends LitElement {
); );
/* The color temp minimum value shouldn't be rendered differently. It's not "off". */ /* The color temp minimum value shouldn't be rendered differently. It's not "off". */
--paper-slider-knob-start-border-color: var(--primary-color); --paper-slider-knob-start-border-color: var(--primary-color);
margin-bottom: 4px;
} }
.segmentationContainer { .segmentationContainer {
position: relative; position: relative;
max-height: 500px; max-height: 500px;
display: flex;
justify-content: center;
}
ha-button-toggle-group {
margin: 8px 0px;
} }
ha-color-picker { ha-color-picker {
@ -293,12 +573,19 @@ class MoreInfoLight extends LitElement {
.segmentationButton { .segmentationButton {
position: absolute; position: absolute;
top: 5%; top: 5%;
left: 0;
color: var(--secondary-text-color); color: var(--secondary-text-color);
} }
paper-item { paper-item {
cursor: pointer; cursor: pointer;
} }
hr {
border-color: var(--divider-color);
border-bottom: none;
margin: 8px 0;
}
`; `;
} }
} }

View File

@ -66,10 +66,11 @@ interface CommandItem extends QuickBarItem {
} }
interface EntityItem extends QuickBarItem { interface EntityItem extends QuickBarItem {
altText: string;
icon?: string; icon?: string;
} }
const isCommandItem = (item: EntityItem | CommandItem): item is CommandItem => { const isCommandItem = (item: QuickBarItem): item is CommandItem => {
return (item as CommandItem).categoryKey !== undefined; return (item as CommandItem).categoryKey !== undefined;
}; };
@ -230,7 +231,7 @@ export class QuickBar extends LitElement {
private _renderItem(item: QuickBarItem, index?: number) { private _renderItem(item: QuickBarItem, index?: number) {
return isCommandItem(item) return isCommandItem(item)
? this._renderCommandItem(item, index) ? this._renderCommandItem(item, index)
: this._renderEntityItem(item, index); : this._renderEntityItem(item as EntityItem, index);
} }
private _renderEntityItem(item: EntityItem, index?: number) { private _renderEntityItem(item: EntityItem, index?: number) {
@ -289,13 +290,6 @@ export class QuickBar extends LitElement {
</span> </span>
<span class="command-text">${item.primaryText}</span> <span class="command-text">${item.primaryText}</span>
${item.altText
? html`
<span slot="secondary" class="item-text secondary"
>${item.altText}</span
>
`
: null}
</mwc-list-item> </mwc-list-item>
`; `;
} }
@ -389,17 +383,20 @@ export class QuickBar extends LitElement {
} }
} }
private _generateEntityItems(): QuickBarItem[] { private _generateEntityItems(): EntityItem[] {
return Object.keys(this.hass.states) return Object.keys(this.hass.states)
.map((entityId) => { .map((entityId) => {
const primaryText = computeStateName(this.hass.states[entityId]); const entityItem = {
return { primaryText: computeStateName(this.hass.states[entityId]),
primaryText,
filterText: primaryText,
altText: entityId, altText: entityId,
icon: domainIcon(computeDomain(entityId), this.hass.states[entityId]), icon: domainIcon(computeDomain(entityId), this.hass.states[entityId]),
action: () => fireEvent(this, "hass-more-info", { entityId }), action: () => fireEvent(this, "hass-more-info", { entityId }),
}; };
return {
...entityItem,
strings: [entityItem.primaryText, entityItem.altText],
};
}) })
.sort((a, b) => .sort((a, b) =>
compare(a.primaryText.toLowerCase(), b.primaryText.toLowerCase()) compare(a.primaryText.toLowerCase(), b.primaryText.toLowerCase())
@ -412,7 +409,10 @@ export class QuickBar extends LitElement {
...this._generateServerControlCommands(), ...this._generateServerControlCommands(),
...this._generateNavigationCommands(), ...this._generateNavigationCommands(),
].sort((a, b) => ].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(); const reloadableDomains = componentsWithService(this.hass, "reload").sort();
return reloadableDomains.map((domain) => { return reloadableDomains.map((domain) => {
const categoryText = this.hass.localize( const commandItem = {
`ui.dialogs.quick-bar.commands.types.reload` primaryText:
); this.hass.localize(
const primaryText = `ui.dialogs.quick-bar.commands.reload.${domain}`
this.hass.localize(`ui.dialogs.quick-bar.commands.reload.${domain}`) || ) ||
this.hass.localize( this.hass.localize(
"ui.dialogs.quick-bar.commands.reload.reload", "ui.dialogs.quick-bar.commands.reload.reload",
"domain", "domain",
domainToName(this.hass.localize, 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 { return {
primaryText, ...commandItem,
filterText: `${categoryText} ${primaryText}`,
action: () => this.hass.callService(domain, "reload"),
categoryKey: "reload", categoryKey: "reload",
iconPath: mdiReload, strings: [`${commandItem.categoryText} ${commandItem.primaryText}`],
categoryText,
}; };
}); });
} }
@ -446,26 +449,28 @@ export class QuickBar extends LitElement {
const serverActions = ["restart", "stop"]; const serverActions = ["restart", "stop"];
return serverActions.map((action) => { return serverActions.map((action) => {
const categoryKey = "server_control"; const categoryKey: CommandItem["categoryKey"] = "server_control";
const categoryText = this.hass.localize(
`ui.dialogs.quick-bar.commands.types.${categoryKey}` const item = {
); primaryText: this.hass.localize(
const primaryText = this.hass.localize(
"ui.dialogs.quick-bar.commands.server_control.perform_action", "ui.dialogs.quick-bar.commands.server_control.perform_action",
"action", "action",
this.hass.localize( this.hass.localize(
`ui.dialogs.quick-bar.commands.server_control.${action}` `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( return this._generateConfirmationCommand(
{ {
primaryText, ...item,
filterText: `${categoryText} ${primaryText}`, strings: [`${item.categoryText} ${item.primaryText}`],
categoryKey,
iconPath: mdiServerNetwork,
categoryText,
action: () => this.hass.callService("homeassistant", action),
}, },
this.hass.localize("ui.dialogs.generic.ok") this.hass.localize("ui.dialogs.generic.ok")
); );
@ -550,18 +555,21 @@ export class QuickBar extends LitElement {
items: BaseNavigationCommand[] items: BaseNavigationCommand[]
): CommandItem[] { ): CommandItem[] {
return items.map((item) => { return items.map((item) => {
const categoryKey = "navigation"; const categoryKey: CommandItem["categoryKey"] = "navigation";
const categoryText = this.hass.localize(
const navItem = {
...item,
iconPath: mdiEarth,
categoryText: this.hass.localize(
`ui.dialogs.quick-bar.commands.types.${categoryKey}` `ui.dialogs.quick-bar.commands.types.${categoryKey}`
); ),
action: () => navigate(this, item.path),
};
return { return {
...item, ...navItem,
strings: [`${navItem.categoryText} ${navItem.primaryText}`],
categoryKey, categoryKey,
iconPath: mdiEarth,
categoryText,
filterText: `${categoryText} ${item.primaryText}`,
action: () => navigate(this, item.path),
}; };
}); });
} }

View File

@ -10,9 +10,11 @@ import {
NetworkOnly, NetworkOnly,
StaleWhileRevalidate, StaleWhileRevalidate,
} from "workbox-strategies"; } from "workbox-strategies";
import { CacheableResponsePlugin } from "workbox-cacheable-response";
import { ExpirationPlugin } from "workbox-expiration";
const noFallBackRegEx = new RegExp( 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. // Clean up caches from older workboxes and old service workers.
@ -31,27 +33,22 @@ function initRouting() {
// Cache static content (including translations) on first access. // Cache static content (including translations) on first access.
registerRoute( registerRoute(
new RegExp(`${location.host}/(static|frontend_latest|frontend_es5)/.+`), new RegExp("/(static|frontend_latest|frontend_es5)/.+"),
new CacheFirst({ matchOptions: { ignoreSearch: true } }) new CacheFirst({ matchOptions: { ignoreSearch: true } })
); );
// Get api from network. // Get api from network.
registerRoute( registerRoute(new RegExp("/(api|auth)/.*"), new NetworkOnly());
new RegExp(`${location.host}/(api|auth)/.*`),
new NetworkOnly()
);
// Get manifest, service worker, onboarding from network. // Get manifest, service worker, onboarding from network.
registerRoute( registerRoute(
new RegExp( new RegExp("/(service_worker.js|manifest.json|onboarding.html)"),
`${location.host}/(service_worker.js|manifest.json|onboarding.html)`
),
new NetworkOnly() new NetworkOnly()
); );
// For the root "/" we ignore search // For the root "/" we ignore search
registerRoute( registerRoute(
new RegExp(`^${location.host}/(\\?.*)?$`), new RegExp(/\/(\?.*)?$/),
new StaleWhileRevalidate({ matchOptions: { ignoreSearch: true } }) new StaleWhileRevalidate({ matchOptions: { ignoreSearch: true } })
); );
@ -59,7 +56,20 @@ function initRouting() {
// This includes "/states" response and user files from "/local". // This includes "/states" response and user files from "/local".
// First access might bring stale data from cache, but a single refresh will bring updated // First access might bring stale data from cache, but a single refresh will bring updated
// file. // 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() { function initPushNotifications() {

View File

@ -81,9 +81,27 @@ class LightEntity extends Entity {
if (service === "turn_on") { if (service === "turn_on") {
// eslint-disable-next-line // eslint-disable-next-line
let { brightness, hs_color, brightness_pct } = data; let { hs_color, brightness_pct, rgb_color, color_temp } = data;
brightness = (255 * brightness_pct) / 100; const attrs = { ...this.attributes };
this.update("on", { ...this.attributes, brightness, hs_color }); 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") { } else if (service === "turn_off") {
this.update("off"); this.update("off");
} else if (service === "toggle") { } else if (service === "toggle") {

View File

@ -30,6 +30,7 @@ export interface MockHomeAssistant extends HomeAssistant {
updateStates(newStates: HassEntities); updateStates(newStates: HassEntities);
addEntities(entites: Entity | Entity[], replace?: boolean); addEntities(entites: Entity | Entity[], replace?: boolean);
updateTranslations(fragment: null | string, language?: string); updateTranslations(fragment: null | string, language?: string);
addTranslations(translations: Record<string, string>, language?: string);
mockWS( mockWS(
type: string, type: string,
callback: (msg: any, onChange?: (response: any) => void) => any callback: (msg: any, onChange?: (response: any) => void) => any
@ -60,15 +61,25 @@ export const provideHass = (
) { ) {
const lang = language || getLocalLanguage(); const lang = language || getLocalLanguage();
const translation = await getTranslation(fragment, lang); const translation = await getTranslation(fragment, lang);
await addTranslations(translation.data, lang);
}
async function addTranslations(
translations: Record<string, string>,
language?: string
) {
const lang = language || getLocalLanguage();
const resources = { const resources = {
[lang]: { [lang]: {
...(hass().resources && hass().resources[lang]), ...(hass().resources && hass().resources[lang]),
...translation.data, ...translations,
}, },
}; };
hass().updateHass({ hass().updateHass({
resources, 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: () => "", localize: () => "",
translationMetadata: translationMetadata as any, translationMetadata: translationMetadata as any,
async loadBackendTranslation() {
return hass().localize;
},
dockedSidebar: "auto", dockedSidebar: "auto",
vibrate: true, vibrate: true,
suspendWhenHidden: false, suspendWhenHidden: false,
@ -250,6 +264,7 @@ export const provideHass = (
}, },
updateStates, updateStates,
updateTranslations, updateTranslations,
addTranslations,
addEntities, addEntities,
mockWS(type, callback) { mockWS(type, callback) {
wsCommands[type] = callback; wsCommands[type] = callback;

View File

@ -23,11 +23,9 @@
margin-right: 16px; margin-right: 16px;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { html {
background-color: #111111; background-color: #111111;
color: #e1e1e1; color: #e1e1e1;
--primary-text-color: #e1e1e1;
--secondary-text-color: #9b9b9b;
} }
} }
</style> </style>

View File

@ -51,6 +51,7 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
html { html {
background-color: #111111; background-color: #111111;
color: #e1e1e1;
} }
#ha-init-skeleton::before { #ha-init-skeleton::before {
background-color: #1c1c1c; background-color: #1c1c1c;

View File

@ -34,17 +34,8 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
html { 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; background-color: #111111;
color: #e1e1e1;
} }
} }

View File

@ -32,6 +32,7 @@ import { registerServiceWorker } from "../util/register-service-worker";
import "./onboarding-create-user"; import "./onboarding-create-user";
import "./onboarding-loading"; import "./onboarding-loading";
import "./onboarding-analytics"; import "./onboarding-analytics";
import { applyThemesOnElement } from "../common/dom/apply_themes_on_element";
type OnboardingEvent = type OnboardingEvent =
| { | {
@ -137,6 +138,19 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
if (window.innerWidth > 450) { if (window.innerWidth > 450) {
import("./particles"); 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) { protected updated(changedProps: PropertyValues) {

View File

@ -32,13 +32,9 @@ class OnboardingAnalytics extends LitElement {
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<p> <p>
${this.hass.localize( Share anonymized information from your installation to help make Home
"ui.panel.config.core.section.core.analytics.introduction", Assistant better and help us convince manufacturers to add local control
"link", and privacy-focused features.
html`<a href="https://analytics.home-assistant.io" target="_blank"
>analytics.home-assistant.io</a
>`
)}
</p> </p>
<ha-analytics <ha-analytics
@analytics-preferences-changed=${this._preferencesChanged} @analytics-preferences-changed=${this._preferencesChanged}

View File

@ -22,12 +22,16 @@ import {
import { import {
computeDeviceName, computeDeviceName,
DeviceRegistryEntry, DeviceRegistryEntry,
devicesInArea,
} from "../../../data/device_registry"; } from "../../../data/device_registry";
import {
computeEntityRegistryName,
EntityRegistryEntry,
} from "../../../data/entity_registry";
import { findRelated, RelatedResult } from "../../../data/search"; import { findRelated, RelatedResult } from "../../../data/search";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types"; import { HomeAssistant, Route } from "../../../types";
import { showEntityEditorDialog } from "../entities/show-dialog-entity-editor";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import { import {
loadAreaRegistryDetailDialog, loadAreaRegistryDetailDialog,
@ -44,6 +48,8 @@ class HaConfigAreaPage extends LitElement {
@property() public devices!: DeviceRegistryEntry[]; @property() public devices!: DeviceRegistryEntry[];
@property() public entities!: EntityRegistryEntry[];
@property({ type: Boolean, reflect: true }) public narrow!: boolean; @property({ type: Boolean, reflect: true }) public narrow!: boolean;
@property() public isWide!: boolean; @property() public isWide!: boolean;
@ -58,9 +64,39 @@ class HaConfigAreaPage extends LitElement {
| AreaRegistryEntry | AreaRegistryEntry
| undefined => areas.find((area) => area.area_id === areaId)); | undefined => areas.find((area) => area.area_id === areaId));
private _devices = memoizeOne( private _memberships = memoizeOne(
(areaId: string, devices: DeviceRegistryEntry[]): DeviceRegistryEntry[] => (
devicesInArea(devices, areaId) 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) { 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` return html`
<hass-tabs-subpage <hass-tabs-subpage
@ -144,6 +184,33 @@ class HaConfigAreaPage extends LitElement {
> >
`} `}
</ha-card> </ha-card>
<ha-card
.header=${this.hass.localize(
"ui.panel.config.areas.editor.linked_entities_caption"
)}
>${entities.length
? entities.map(
(entity) =>
html`
<paper-item
@click=${this._openEntity}
.entity=${entity}
>
<paper-item-body>
${computeEntityRegistryName(this.hass, entity)}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
`
)
: html`
<paper-item class="no-link"
>${this.hass.localize(
"ui.panel.config.areas.editor.no_linked_entities"
)}</paper-item
>
`}
</ha-card>
</div> </div>
<div class="column"> <div class="column">
${isComponentLoaded(this.hass, "automation") ${isComponentLoaded(this.hass, "automation")
@ -299,6 +366,14 @@ class HaConfigAreaPage extends LitElement {
this._openDialog(entry); 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) { private _openDialog(entry?: AreaRegistryEntry) {
showAreaRegistryDetailDialog(this, { showAreaRegistryDetailDialog(this, {
entry, entry,

View File

@ -24,10 +24,8 @@ import {
AreaRegistryEntry, AreaRegistryEntry,
createAreaRegistryEntry, createAreaRegistryEntry,
} from "../../../data/area_registry"; } from "../../../data/area_registry";
import { import type { DeviceRegistryEntry } from "../../../data/device_registry";
DeviceRegistryEntry, import type { EntityRegistryEntry } from "../../../data/entity_registry";
devicesInArea,
} from "../../../data/device_registry";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box"; import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-loading-screen"; import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage-data-table"; import "../../../layouts/hass-tabs-subpage-data-table";
@ -53,12 +51,39 @@ export class HaConfigAreasDashboard extends LitElement {
@property() public devices!: DeviceRegistryEntry[]; @property() public devices!: DeviceRegistryEntry[];
@property() public entities!: EntityRegistryEntry[];
private _areas = memoizeOne( private _areas = memoizeOne(
(areas: AreaRegistryEntry[], devices: DeviceRegistryEntry[]) => { (
areas: AreaRegistryEntry[],
devices: DeviceRegistryEntry[],
entities: EntityRegistryEntry[]
) => {
return areas.map((area) => { 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 { return {
...area, ...area,
devices: devicesInArea(devices, area.area_id).length, devices: devicesInArea.size,
entities: entitiesInArea,
}; };
}); });
} }
@ -97,6 +122,15 @@ export class HaConfigAreasDashboard extends LitElement {
width: "20%", width: "20%",
direction: "asc", 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} .tabs=${configSections.integrations}
.route=${this.route} .route=${this.route}
.columns=${this._columns(this.narrow)} .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} @row-click=${this._handleRowClicked}
.noDataText=${this.hass.localize( .noDataText=${this.hass.localize(
"ui.panel.config.areas.picker.no_areas" "ui.panel.config.areas.picker.no_areas"

View File

@ -15,6 +15,10 @@ import {
DeviceRegistryEntry, DeviceRegistryEntry,
subscribeDeviceRegistry, subscribeDeviceRegistry,
} from "../../../data/device_registry"; } from "../../../data/device_registry";
import {
EntityRegistryEntry,
subscribeEntityRegistry,
} from "../../../data/entity_registry";
import { import {
HassRouterPage, HassRouterPage,
RouterOptions, RouterOptions,
@ -51,6 +55,9 @@ class HaConfigAreas extends HassRouterPage {
@internalProperty() @internalProperty()
private _deviceRegistryEntries: DeviceRegistryEntry[] = []; private _deviceRegistryEntries: DeviceRegistryEntry[] = [];
@internalProperty()
private _entityRegistryEntries: EntityRegistryEntry[] = [];
@internalProperty() private _areas: AreaRegistryEntry[] = []; @internalProperty() private _areas: AreaRegistryEntry[] = [];
private _unsubs?: UnsubscribeFunc[]; private _unsubs?: UnsubscribeFunc[];
@ -90,6 +97,7 @@ class HaConfigAreas extends HassRouterPage {
pageEl.entries = this._configEntries; pageEl.entries = this._configEntries;
pageEl.devices = this._deviceRegistryEntries; pageEl.devices = this._deviceRegistryEntries;
pageEl.entities = this._entityRegistryEntries;
pageEl.areas = this._areas; pageEl.areas = this._areas;
pageEl.narrow = this.narrow; pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide; pageEl.isWide = this.isWide;
@ -113,6 +121,9 @@ class HaConfigAreas extends HassRouterPage {
subscribeDeviceRegistry(this.hass.connection, (entries) => { subscribeDeviceRegistry(this.hass.connection, (entries) => {
this._deviceRegistryEntries = entries; this._deviceRegistryEntries = entries;
}), }),
subscribeEntityRegistry(this.hass.connection, (entries) => {
this._entityRegistryEntries = entries;
}),
]; ];
} }
} }

View File

@ -99,9 +99,27 @@ export class HaAutomationTracePathDetails extends LitElement {
return "This node was not executed and so no further trace information is available."; 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) => { let active = false;
const childConditionsPrefix = `${this.selected.path}/conditions/`;
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 { const {
path, path,
timestamp, timestamp,
@ -112,9 +130,17 @@ export class HaAutomationTracePathDetails extends LitElement {
} = trace as any; } = trace as any;
return html` return html`
${curPath === this.selected.path
? ""
: html`<h2>
Condition ${curPath.substr(childConditionsPrefix.length)}
</h2>`}
${data.length === 1 ? "" : html`<h3>Iteration ${idx + 1}</h3>`} ${data.length === 1 ? "" : html`<h3>Iteration ${idx + 1}</h3>`}
Executed: Executed:
${formatDateTimeWithSeconds(new Date(timestamp), this.hass.locale)}<br /> ${formatDateTimeWithSeconds(
new Date(timestamp),
this.hass.locale
)}<br />
${result ${result
? html`Result: ? html`Result:
<pre>${safeDump(result)}</pre>` <pre>${safeDump(result)}</pre>`
@ -125,7 +151,11 @@ export class HaAutomationTracePathDetails extends LitElement {
? "" ? ""
: html`<pre>${safeDump(rest)}</pre>`} : html`<pre>${safeDump(rest)}</pre>`}
`; `;
}); })
);
}
return parts;
} }
private _renderSelectedConfig() { private _renderSelectedConfig() {

View File

@ -87,12 +87,24 @@ export class HaAutomationTrace extends LitElement {
const title = stateObj?.attributes.friendly_name || this._entityId; const title = stateObj?.attributes.friendly_name || this._entityId;
let devButtons: TemplateResult | string = "";
if (__DEV__) {
devButtons = html`<div style="position: absolute; right: 0;">
<button @click=${this._importTrace}>
Import trace
</button>
<button @click=${this._loadLocalStorageTrace}>
Load stored trace
</button>
</div>`;
}
const actionButtons = html` const actionButtons = html`
<mwc-icon-button label="Refresh" @click=${() => this._loadTraces()}> <mwc-icon-button label="Refresh" @click=${() => this._loadTraces()}>
<ha-svg-icon .path=${mdiRefresh}></ha-svg-icon> <ha-svg-icon .path=${mdiRefresh}></ha-svg-icon>
</mwc-icon-button> </mwc-icon-button>
<mwc-icon-button <mwc-icon-button
.disabled=${!this._runId} .disabled=${!this._trace}
label="Download Trace" label="Download Trace"
@click=${this._downloadTrace} @click=${this._downloadTrace}
> >
@ -101,6 +113,7 @@ export class HaAutomationTrace extends LitElement {
`; `;
return html` return html`
${devButtons}
<hass-tabs-subpage <hass-tabs-subpage
.hass=${this.hass} .hass=${this.hass}
.narrow=${this.narrow} .narrow=${this.narrow}
@ -410,6 +423,27 @@ export class HaAutomationTrace extends LitElement {
aEl.click(); aEl.click();
} }
private _importTrace() {
const traceText = prompt("Enter downloaded trace");
if (!traceText) {
return;
}
localStorage.devTrace = traceText;
this._loadLocalTrace(traceText);
}
private _loadLocalStorageTrace() {
if (localStorage.devTrace) {
this._loadLocalTrace(localStorage.devTrace);
}
}
private _loadLocalTrace(traceText: string) {
const traceInfo = JSON.parse(traceText);
this._trace = traceInfo.trace;
this._logbookEntries = traceInfo.logbookEntries;
}
private _showTab(ev) { private _showTab(ev) {
this._view = (ev.target as any).view; this._view = (ev.target as any).view;
} }

View File

@ -73,7 +73,7 @@ class HaConfigCloud extends HassRouterPage {
private _resolveCloudStatusLoaded!: () => void; private _resolveCloudStatusLoaded!: () => void;
private _cloudStatusLoaded = new Promise((resolve) => { private _cloudStatusLoaded = new Promise<void>((resolve) => {
this._resolveCloudStatusLoaded = resolve; this._resolveCloudStatusLoaded = resolve;
}); });

View File

@ -40,21 +40,13 @@ class ConfigAnalytics extends LitElement {
: undefined; : undefined;
return html` return html`
<ha-card <ha-card header="Analytics">
.header=${this.hass.localize(
"ui.panel.config.core.section.core.analytics.header"
)}
>
<div class="card-content"> <div class="card-content">
${error ? html`<div class="error">${error}</div>` : ""} ${error ? html`<div class="error">${error}</div>` : ""}
<p> <p>
${this.hass.localize( Share anonymized information from your installation to help make
"ui.panel.config.core.section.core.analytics.introduction", Home Assistant better and help us convince manufacturers to add
"link", local control and privacy-focused features.
html`<a href="https://analytics.home-assistant.io" target="_blank"
>analytics.home-assistant.io</a
>`
)}
</p> </p>
<ha-analytics <ha-analytics
@analytics-preferences-changed=${this._preferencesChanged} @analytics-preferences-changed=${this._preferencesChanged}

View File

@ -79,36 +79,6 @@ class HaConfigDashboard extends LitElement {
</ha-card> </ha-card>
` `
)} )}
${isComponentLoaded(this.hass, "zha")
? html`
<div class="promo-advanced">
${this.hass.localize(
"ui.panel.config.integration_panel_move.missing_zha",
"integrations_page",
html`<a href="/config/integrations">
${this.hass.localize(
"ui.panel.config.integration_panel_move.link_integration_page"
)}
</a>`
)}
</div>
`
: ""}
${isComponentLoaded(this.hass, "zwave")
? html`
<div class="promo-advanced">
${this.hass.localize(
"ui.panel.config.integration_panel_move.missing_zwave",
"integrations_page",
html`<a href="/config/integrations">
${this.hass.localize(
"ui.panel.config.integration_panel_move.link_integration_page"
)}
</a>`
)}
</div>
`
: ""}
${!this.showAdvanced ${!this.showAdvanced
? html` ? html`
<div class="promo-advanced"> <div class="promo-advanced">

View File

@ -11,9 +11,13 @@ import {
TemplateResult, TemplateResult,
} from "lit-element"; } from "lit-element";
import { DeviceRegistryEntry } from "../../../../../../data/device_registry"; import { DeviceRegistryEntry } from "../../../../../../data/device_registry";
import {
getIdentifiersFromDevice,
ZWaveJSNodeIdentifiers,
} from "../../../../../../data/zwave_js";
import { haStyle } from "../../../../../../resources/styles"; import { haStyle } from "../../../../../../resources/styles";
import { HomeAssistant } from "../../../../../../types"; import { HomeAssistant } from "../../../../../../types";
import { showZWaveJSReinterviewNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node";
@customElement("ha-device-actions-zwave_js") @customElement("ha-device-actions-zwave_js")
export class HaDeviceActionsZWaveJS extends LitElement { export class HaDeviceActionsZWaveJS extends LitElement {
@ -23,9 +27,19 @@ export class HaDeviceActionsZWaveJS extends LitElement {
@internalProperty() private _entryId?: string; @internalProperty() private _entryId?: string;
@internalProperty() private _nodeId?: number;
protected updated(changedProperties: PropertyValues) { protected updated(changedProperties: PropertyValues) {
if (changedProperties.has("device")) { if (changedProperties.has("device")) {
this._entryId = this.device.config_entries[0]; 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 {
)} )}
</mwc-button> </mwc-button>
</a> </a>
<mwc-button @click=${this._reinterviewClicked}
>Re-interview Device</mwc-button
>
`; `;
} }
private async _reinterviewClicked() {
if (!this._nodeId || !this._entryId) {
return;
}
showZWaveJSReinterviewNodeDialog(this, {
entry_id: this._entryId,
node_id: this._nodeId,
});
}
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [ return [
haStyle, haStyle,

View File

@ -33,7 +33,7 @@ class DialogDeviceRegistryDetail extends LitElement {
@internalProperty() private _params?: DeviceRegistryDetailDialogParams; @internalProperty() private _params?: DeviceRegistryDetailDialogParams;
@internalProperty() private _areaId?: string; @internalProperty() private _areaId?: string | null;
@internalProperty() private _disabledBy!: string | null; @internalProperty() private _disabledBy!: string | null;

View File

@ -728,7 +728,7 @@ export class HaConfigDevicePage extends LitElement {
} }
if (!newName && !newEntityId) { if (!newName && !newEntityId) {
return new Promise((resolve) => resolve()); return undefined;
} }
return updateEntityRegistryEntry(this.hass!, entity.entity_id, { return updateEntityRegistryEntry(this.hass!, entity.entity_id, {

View File

@ -38,7 +38,7 @@ export class HaEntityRegistryBasicEditor extends SubscribeMixin(LitElement) {
@internalProperty() private _entityId!: string; @internalProperty() private _entityId!: string;
@internalProperty() private _areaId?: string; @internalProperty() private _areaId?: string | null;
@internalProperty() private _disabledBy!: string | null; @internalProperty() private _disabledBy!: string | null;

View File

@ -663,6 +663,10 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
entity_id: entityId, entity_id: entityId,
platform: computeDomain(entityId), platform: computeDomain(entityId),
disabled_by: null, disabled_by: null,
area_id: null,
config_entry_id: null,
device_id: null,
icon: null,
readonly: true, readonly: true,
selectable: false, selectable: false,
}); });

View File

@ -140,7 +140,10 @@ class HaConfigInfo extends LitElement {
</div> </div>
<div class="content"> <div class="content">
<system-health-card .hass=${this.hass}></system-health-card> <system-health-card .hass=${this.hass}></system-health-card>
<integrations-card .hass=${this.hass}></integrations-card> <integrations-card
.hass=${this.hass}
.narrow=${this.narrow}
></integrations-card>
</div> </div>
</hass-tabs-subpage> </hass-tabs-subpage>
`; `;

View File

@ -13,8 +13,10 @@ import "../../../components/ha-card";
import { import {
domainToName, domainToName,
fetchIntegrationManifests, fetchIntegrationManifests,
fetchIntegrationSetups,
integrationIssuesUrl, integrationIssuesUrl,
IntegrationManifest, IntegrationManifest,
IntegrationSetup,
} from "../../../data/integration"; } from "../../../data/integration";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url"; import { brandsUrl } from "../../../util/brands-url";
@ -23,15 +25,22 @@ import { brandsUrl } from "../../../util/brands-url";
class IntegrationsCard extends LitElement { class IntegrationsCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@internalProperty() private _manifests?: { @internalProperty() private _manifests?: {
[domain: string]: IntegrationManifest; [domain: string]: IntegrationManifest;
}; };
@internalProperty() private _setups?: {
[domain: string]: IntegrationSetup;
};
private _sortedIntegrations = memoizeOne((components: string[]) => { private _sortedIntegrations = memoizeOne((components: string[]) => {
return Array.from( return Array.from(
new Set( new Set(
components components.map((comp) =>
.map((comp) => (comp.includes(".") ? comp.split(".")[1] : comp)) comp.includes(".") ? comp.split(".")[1] : comp
)
) )
).sort(); ).sort();
}); });
@ -39,6 +48,7 @@ class IntegrationsCard extends LitElement {
firstUpdated(changedProps) { firstUpdated(changedProps) {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
this._fetchManifests(); this._fetchManifests();
this._fetchSetups();
} }
protected render(): TemplateResult { protected render(): TemplateResult {
@ -47,10 +57,47 @@ class IntegrationsCard extends LitElement {
.header=${this.hass.localize("ui.panel.config.info.integrations")} .header=${this.hass.localize("ui.panel.config.info.integrations")}
> >
<table class="card-content"> <table class="card-content">
<thead>
<tr>
<th></th>
${!this.narrow
? html`<th></th>
<th></th>
<th></th>`
: ""}
<th>Setup time</th>
</tr>
</thead>
<tbody> <tbody>
${this._sortedIntegrations(this.hass!.config.components).map( ${this._sortedIntegrations(this.hass!.config.components).map(
(domain) => { (domain) => {
const manifest = this._manifests && this._manifests[domain]; const manifest = this._manifests && this._manifests[domain];
const docLink = manifest
? html`<a
href=${manifest.documentation}
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.info.documentation"
)}</a
>`
: "";
const issueLink =
manifest && (manifest.is_built_in || manifest.issue_tracker)
? html`
<a
href=${integrationIssuesUrl(domain, manifest)}
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.info.issues"
)}</a
>
`
: "";
const setupSeconds = this._setups?.[domain]?.seconds?.toFixed(
2
);
return html` return html`
<tr> <tr>
<td> <td>
@ -63,39 +110,25 @@ class IntegrationsCard extends LitElement {
<td class="name"> <td class="name">
${domainToName(this.hass.localize, domain, manifest)}<br /> ${domainToName(this.hass.localize, domain, manifest)}<br />
<span class="domain">${domain}</span> <span class="domain">${domain}</span>
${this.narrow
? html`<div class="mobile-row">
<div>${docLink} ${issueLink}</div>
${setupSeconds ? html`${setupSeconds}s` : ""}
</div>`
: ""}
</td> </td>
${!manifest ${this.narrow
? "" ? ""
: html` : html`
<td> <td>
<a ${docLink}
href=${manifest.documentation}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.info.documentation"
)}
</a>
</td> </td>
${manifest.is_built_in || manifest.issue_tracker
? html`
<td> <td>
<a ${issueLink}
href=${integrationIssuesUrl( </td>
domain, <td class="setup">
manifest ${setupSeconds ? html`${setupSeconds}s` : ""}
)}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.info.issues"
)}
</a>
</td> </td>
`
: ""}
`} `}
</tr> </tr>
`; `;
@ -115,9 +148,21 @@ class IntegrationsCard extends LitElement {
this._manifests = manifests; 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 { static get styles(): CSSResult {
return css` return css`
td { table {
width: 100%;
}
td,
th {
padding: 0 8px; padding: 0 8px;
} }
td:first-child { td:first-child {
@ -126,9 +171,22 @@ class IntegrationsCard extends LitElement {
td.name { td.name {
padding: 8px; padding: 8px;
} }
td.setup {
text-align: right;
}
th {
text-align: right;
}
.domain { .domain {
color: var(--secondary-text-color); color: var(--secondary-text-color);
} }
.mobile-row {
display: flex;
justify-content: space-between;
}
.mobile-row a:not(:last-of-type) {
margin-right: 4px;
}
img { img {
display: block; display: block;
max-height: 40px; max-height: 40px;

View File

@ -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`
<ha-integration-action-card
class=${classMap({
discovered: !attention,
attention: attention,
})}
.hass=${this.hass}
.manifest=${this.manifest}
.banner=${this.hass.localize(
`ui.panel.config.integrations.${
attention ? "attention" : "discovered"
}`
)}
.domain=${this.flow.handler}
.label=${this.flow.localized_title}
>
<mwc-button
unelevated
@click=${this._continueFlow}
.label=${this.hass.localize(
`ui.panel.config.integrations.${
attention ? "reconfigure" : "configure"
}`
)}
></mwc-button>
${DISCOVERY_SOURCES.includes(this.flow.context.source) &&
this.flow.context.unique_id
? html`
<mwc-button
@click=${this._ignoreFlow}
.label=${this.hass.localize(
"ui.panel.config.integrations.ignore.ignore"
)}
></mwc-button>
`
: ""}
</ha-integration-action-card>
`;
}
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;
}
}

View File

@ -2,9 +2,8 @@ import "@material/mwc-icon-button";
import { ActionDetail } from "@material/mwc-list"; import { ActionDetail } from "@material/mwc-list";
import "@material/mwc-list/mwc-list-item"; import "@material/mwc-list/mwc-list-item";
import { mdiFilterVariant, mdiPlus } from "@mdi/js"; import { mdiFilterVariant, mdiPlus } from "@mdi/js";
import "@polymer/app-route/app-route";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { import {
css, css,
CSSResult, CSSResult,
@ -16,31 +15,15 @@ import {
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
} from "lit-element"; } from "lit-element";
import { classMap } from "lit-html/directives/class-map";
import { ifDefined } from "lit-html/directives/if-defined"; import { ifDefined } from "lit-html/directives/if-defined";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { HASSDomEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
import "../../../common/search/search-input";
import { caseInsensitiveCompare } from "../../../common/string/compare"; import { caseInsensitiveCompare } from "../../../common/string/compare";
import { LocalizeFunc } from "../../../common/translations/localize";
import { extractSearchParam } from "../../../common/url/search-params"; import { extractSearchParam } from "../../../common/url/search-params";
import { nextRender } from "../../../common/util/render-status"; import { nextRender } from "../../../common/util/render-status";
import "../../../components/ha-button-menu"; import { ConfigEntry, getConfigEntries } from "../../../data/config_entries";
import "../../../components/ha-card";
import "../../../components/ha-fab";
import "../../../components/ha-checkbox";
import "../../../components/ha-svg-icon";
import { import {
ConfigEntry,
deleteConfigEntry,
getConfigEntries,
} from "../../../data/config_entries";
import {
ATTENTION_SOURCES,
DISCOVERY_SOURCES,
getConfigFlowInProgressCollection, getConfigFlowInProgressCollection,
ignoreConfigFlow,
localizeConfigFlowTitle, localizeConfigFlowTitle,
subscribeConfigFlowInProgress, subscribeConfigFlowInProgress,
} from "../../../data/config_flow"; } from "../../../data/config_flow";
@ -55,26 +38,49 @@ import {
} from "../../../data/entity_registry"; } from "../../../data/entity_registry";
import { import {
domainToName, domainToName,
fetchIntegrationManifest,
fetchIntegrationManifests, fetchIntegrationManifests,
IntegrationManifest, IntegrationManifest,
} from "../../../data/integration"; } from "../../../data/integration";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; 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 { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { configSections } from "../ha-panel-config"; 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; localized_title?: string;
} }
@ -119,9 +125,10 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
@internalProperty() @internalProperty()
private _deviceRegistryEntries: DeviceRegistryEntry[] = []; private _deviceRegistryEntries: DeviceRegistryEntry[] = [];
@internalProperty() private _manifests!: { @internalProperty()
[domain: string]: IntegrationManifest; private _manifests: Record<string, IntegrationManifest> = {};
};
private _extraFetchedManifests?: Set<string>;
@internalProperty() private _showIgnored = false; @internalProperty() private _showIgnored = false;
@ -150,15 +157,14 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
this.hass.loadBackendTranslation("config", flow.handler) this.hass.loadBackendTranslation("config", flow.handler)
); );
} }
this._fetchManifest(flow.handler);
}); });
await Promise.all(translationsPromisses); await Promise.all(translationsPromisses);
await nextRender(); await nextRender();
this._configEntriesInProgress = flowsInProgress.map((flow) => { this._configEntriesInProgress = flowsInProgress.map((flow) => ({
return {
...flow, ...flow,
localized_title: localizeConfigFlowTitle(this.hass.localize, flow), localized_title: localizeConfigFlowTitle(this.hass.localize, flow),
}; }));
});
}), }),
]; ];
} }
@ -217,12 +223,6 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
configEntriesInProgress: DataEntryFlowProgressExtended[], configEntriesInProgress: DataEntryFlowProgressExtended[],
filter?: string filter?: string
): DataEntryFlowProgressExtended[] => { ): DataEntryFlowProgressExtended[] => {
configEntriesInProgress = configEntriesInProgress.map(
(flow: DataEntryFlowProgressExtended) => ({
...flow,
title: localizeConfigFlowTitle(this.hass.localize, flow),
})
);
if (!filter) { if (!filter) {
return configEntriesInProgress; return configEntriesInProgress;
} }
@ -349,11 +349,12 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
"number", "number",
disabledConfigEntries.size disabledConfigEntries.size
)} )}
<mwc-button @click=${this._toggleShowDisabled}> <mwc-button
${this.hass.localize( @click=${this._toggleShowDisabled}
.label=${this.hass.localize(
"ui.panel.config.integrations.disable.show" "ui.panel.config.integrations.disable.show"
)} )}
</mwc-button> ></mwc-button>
</div>` </div>`
: ""} : ""}
${filterMenu} ${filterMenu}
@ -362,112 +363,31 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
<div <div
class="container" class="container"
@entry-removed=${this._handleRemoved} @entry-removed=${this._handleEntryRemoved}
@entry-updated=${this._handleUpdated} @entry-updated=${this._handleEntryUpdated}
> >
${this._showIgnored ${this._showIgnored
? ignoredConfigEntries.map( ? ignoredConfigEntries.map(
(item: ConfigEntryExtended) => html` (entry: ConfigEntryExtended) => html`
<ha-card outlined class="ignored"> <ha-ignored-config-entry-card
<div class="header"> .hass=${this.hass}
${this.hass.localize( .manifest=${this._manifests[entry.domain]}
"ui.panel.config.integrations.ignore.ignored" .entry=${entry}
)} @change=${this._handleFlowUpdated}
</div> ></ha-ignored-config-entry-card>
<div class="card-content">
<div class="image">
<img
src=${brandsUrl(item.domain, "logo")}
referrerpolicy="no-referrer"
@error=${this._onImageError}
@load=${this._onImageLoad}
/>
</div>
<h2>
${// 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}
</h2>
<mwc-button
@click=${this._removeIgnoredIntegration}
.entry=${item}
aria-label=${this.hass.localize(
"ui.panel.config.integrations.ignore.stop_ignore"
)}
>${this.hass.localize(
"ui.panel.config.integrations.ignore.stop_ignore"
)}</mwc-button
>
</div>
</ha-card>
` `
) )
: ""} : ""}
${configEntriesInProgress.length ${configEntriesInProgress.length
? configEntriesInProgress.map( ? configEntriesInProgress.map(
(flow: DataEntryFlowProgressExtended) => { (flow: DataEntryFlowProgressExtended) => html`
const attention = ATTENTION_SOURCES.includes( <ha-config-flow-card
flow.context.source .hass=${this.hass}
); .manifest=${this._manifests[flow.handler]}
return html`
<ha-card
outlined
class=${classMap({
discovered: !attention,
attention: attention,
})}
>
<div class="header">
${this.hass.localize(
`ui.panel.config.integrations.${
attention ? "attention" : "discovered"
}`
)}
</div>
<div class="card-content">
<div class="image">
<img
src=${brandsUrl(flow.handler, "logo")}
referrerpolicy="no-referrer"
@error=${this._onImageError}
@load=${this._onImageLoad}
/>
</div>
<h2>
${flow.localized_title}
</h2>
<div>
<mwc-button
unelevated
@click=${this._continueFlow}
.flowId=${flow.flow_id}
>
${this.hass.localize(
`ui.panel.config.integrations.${
attention ? "reconfigure" : "configure"
}`
)}
</mwc-button>
${DISCOVERY_SOURCES.includes(flow.context.source) &&
flow.context.unique_id
? html`
<mwc-button
@click=${this._ignoreFlow}
.flow=${flow} .flow=${flow}
> @change=${this._handleFlowUpdated}
${this.hass.localize( ></ha-config-flow-card>
"ui.panel.config.integrations.ignore.ignore"
)}
</mwc-button>
` `
: ""}
</div>
</div>
</ha-card>
`;
}
) )
: ""} : ""}
${this._showDisabled ${this._showDisabled
@ -498,10 +418,12 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
.deviceRegistryEntries=${this._deviceRegistryEntries} .deviceRegistryEntries=${this._deviceRegistryEntries}
></ha-integration-card>` ></ha-integration-card>`
) )
: !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` ? html`
<ha-card outlined> <div class="empty-message">
<div class="card-content">
<h1> <h1>
${this.hass.localize("ui.panel.config.integrations.none")} ${this.hass.localize("ui.panel.config.integrations.none")}
</h1> </h1>
@ -510,13 +432,14 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
"ui.panel.config.integrations.no_integrations" "ui.panel.config.integrations.no_integrations"
)} )}
</p> </p>
<mwc-button @click=${this._createFlow} unelevated <mwc-button
>${this.hass.localize( @click=${this._createFlow}
unelevated
.label=${this.hass.localize(
"ui.panel.config.integrations.add_integration" "ui.panel.config.integrations.add_integration"
)}</mwc-button )}
> ></mwc-button>
</div> </div>
</ha-card>
` `
: ""} : ""}
${this._filter && ${this._filter &&
@ -524,7 +447,7 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
!groupedConfigEntries.size && !groupedConfigEntries.size &&
this._configEntries.length this._configEntries.length
? html` ? html`
<div class="none-found"> <div class="empty-message">
<h1> <h1>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.integrations.none_found" "ui.panel.config.integrations.none_found"
@ -575,19 +498,40 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
} }
private async _fetchManifests() { private async _fetchManifests() {
const manifests = {};
const fetched = await fetchIntegrationManifests(this.hass); 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; for (const manifest of fetched) manifests[manifest.domain] = manifest;
this._manifests = manifests; this._manifests = manifests;
} }
private _handleRemoved(ev: HASSDomEvent<ConfigEntryRemovedEvent>) { 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<ConfigEntryRemovedEvent>) {
this._configEntries = this._configEntries!.filter( this._configEntries = this._configEntries!.filter(
(entry) => entry.entry_id !== ev.detail.entryId (entry) => entry.entry_id !== ev.detail.entryId
); );
} }
private _handleUpdated(ev: HASSDomEvent<ConfigEntryUpdatedEvent>) { private _handleEntryUpdated(ev: HASSDomEvent<ConfigEntryUpdatedEvent>) {
const newEntry = ev.detail.entry; const newEntry = ev.detail.entry;
this._configEntries = this._configEntries!.map((entry) => this._configEntries = this._configEntries!.map((entry) =>
entry.entry_id === newEntry.entry_id entry.entry_id === newEntry.entry_id
@ -599,6 +543,7 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
private _handleFlowUpdated() { private _handleFlowUpdated() {
this._loadConfigEntries(); this._loadConfigEntries();
getConfigFlowInProgressCollection(this.hass.connection).refresh(); getConfigFlowInProgressCollection(this.hass.connection).refresh();
this._fetchManifests();
} }
private _createFlow() { private _createFlow() {
@ -608,50 +553,14 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
}, },
showAdvanced: this.showAdvanced, 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); 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<ActionDetail>) { private _handleMenuAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) { switch (ev.detail.index) {
case 0: case 0:
this._toggleShowIgnored(); this._showIgnored = !this._showIgnored;
break; break;
case 1: case 1:
this._toggleShowDisabled(); this._toggleShowDisabled();
@ -659,54 +568,14 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
} }
} }
private _toggleShowIgnored() {
this._showIgnored = !this._showIgnored;
}
private _toggleShowDisabled() { private _toggleShowDisabled() {
this._showDisabled = !this._showDisabled; 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) { private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value; this._filter = ev.detail.value;
} }
private _onImageLoad(ev) {
ev.target.style.visibility = "initial";
}
private _onImageError(ev) {
ev.target.style.visibility = "hidden";
}
private async _highlightEntry() { private async _highlightEntry() {
await nextRender(); await nextRender();
const entryId = this._searchParms.get("config_entry")!; const entryId = this._searchParms.get("config_entry")!;
@ -769,66 +638,18 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
padding: 8px 16px 16px; padding: 8px 16px 16px;
margin-bottom: 64px; margin-bottom: 64px;
} }
ha-card { .container > * {
max-width: 500px; max-width: 500px;
display: flex;
flex-direction: column;
justify-content: space-between;
} }
.attention {
--ha-card-border-color: var(--error-color); .empty-message {
}
.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 {
margin: auto; margin: auto;
text-align: center; text-align: center;
} }
.empty-message h1 {
margin-bottom: 0;
}
search-input.header { search-input.header {
display: block; display: block;
position: relative; position: relative;
@ -848,27 +669,7 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
position: relative; position: relative;
top: 2px; 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 { .active-filters {
color: var(--primary-text-color); color: var(--primary-text-color);
position: relative; position: relative;

View File

@ -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`
<ha-integration-action-card
.hass=${this.hass}
.manifest=${this.manifest}
.banner=${this.hass.localize(
"ui.panel.config.integrations.ignore.ignored"
)}
.domain=${this.entry.domain}
.localizedDomainName=${this.entry.localized_domain_name}
.label=${this.entry.title === "Ignored"
? // In 2020.2 we added support for entry.title. All ignored entries before
// that have title "Ignored" so we fallback to localized domain name.
this.entry.localized_domain_name
: this.entry.title}
>
<mwc-button
@click=${this._removeIgnoredIntegration}
.label=${this.hass.localize(
"ui.panel.config.integrations.ignore.stop_ignore"
)}
></mwc-button>
</ha-integration-action-card>
`;
}
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;
}
}

View File

@ -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`
<ha-card outlined>
<ha-integration-header
.hass=${this.hass}
.banner=${this.banner}
.domain=${this.domain}
.label=${this.label}
.localizedDomainName=${this.localizedDomainName}
.manifest=${this.manifest}
></ha-integration-header>
<div class="filler"></div>
<div class="actions"><slot></slot></div>
</ha-card>
`;
}
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;
}
}

View File

@ -1,4 +1,8 @@
import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item"; 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 "@polymer/paper-tooltip/paper-tooltip";
import { mdiAlertCircle, mdiDotsVertical, mdiOpenInNew } from "@mdi/js"; import { mdiAlertCircle, mdiDotsVertical, mdiOpenInNew } from "@mdi/js";
import { import {
@ -14,7 +18,9 @@ import { classMap } from "lit-html/directives/class-map";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event"; import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import "../../../components/ha-icon-next"; import "../../../components/ha-icon-next";
import "../../../components/ha-button-menu";
import "../../../components/ha-svg-icon"; import "../../../components/ha-svg-icon";
import "../../../components/ha-card";
import { import {
ConfigEntry, ConfigEntry,
deleteConfigEntry, deleteConfigEntry,
@ -23,9 +29,9 @@ import {
reloadConfigEntry, reloadConfigEntry,
updateConfigEntry, updateConfigEntry,
} from "../../../data/config_entries"; } from "../../../data/config_entries";
import { DeviceRegistryEntry } from "../../../data/device_registry"; import type { DeviceRegistryEntry } from "../../../data/device_registry";
import { EntityRegistryEntry } from "../../../data/entity_registry"; import type { EntityRegistryEntry } from "../../../data/entity_registry";
import { domainToName, IntegrationManifest } from "../../../data/integration"; import type { IntegrationManifest } from "../../../data/integration";
import { showConfigEntrySystemOptionsDialog } from "../../../dialogs/config-entry-system-options/show-dialog-config-entry-system-options"; 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 { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow";
import { import {
@ -34,51 +40,23 @@ import {
showPromptDialog, showPromptDialog,
} from "../../../dialogs/generic/show-dialog-box"; } from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url"; import type { ConfigEntryExtended } from "./ha-config-integrations";
import { ConfigEntryExtended } from "./ha-config-integrations"; import "./ha-integration-header";
export interface ConfigEntryUpdatedEvent { const ERROR_STATES: ConfigEntry["state"][] = [
entry: ConfigEntry; "migration_error",
} "setup_error",
"setup_retry",
export interface ConfigEntryRemovedEvent { ];
entryId: string;
}
declare global {
// for fire event
interface HASSDomEvents {
"entry-updated": ConfigEntryUpdatedEvent;
"entry-removed": ConfigEntryRemovedEvent;
}
}
const integrationsWithPanel = { const integrationsWithPanel = {
hassio: { hassio: "/hassio/dashboard",
buttonLocalizeKey: "ui.panel.config.hassio.button", mqtt: "/config/mqtt",
path: "/hassio/dashboard", zha: "/config/zha/dashboard",
}, ozw: "/config/ozw/dashboard",
mqtt: { zwave: "/config/zwave",
buttonLocalizeKey: "ui.panel.config.mqtt.button", zwave_js: "/config/zwave_js/dashboard",
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",
},
}; };
@customElement("ha-integration-card") @customElement("ha-integration-card")
@ -89,7 +67,7 @@ export class HaIntegrationCard extends LitElement {
@property() public items!: ConfigEntryExtended[]; @property() public items!: ConfigEntryExtended[];
@property() public manifest!: IntegrationManifest; @property() public manifest?: IntegrationManifest;
@property() public entityRegistryEntries!: EntityRegistryEntry[]; @property() public entityRegistryEntries!: EntityRegistryEntry[];
@ -99,46 +77,68 @@ export class HaIntegrationCard extends LitElement {
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
}
protected render(): TemplateResult { protected render(): TemplateResult {
let item = this._selectededConfigEntry;
if (this.items.length === 1) { if (this.items.length === 1) {
return this._renderSingleEntry(this.items[0]); item = this.items[0];
} } else if (this.selectedConfigEntryId) {
if (this.selectedConfigEntryId) { item = this.items.find(
const configEntry = this.items.find(
(entry) => entry.entry_id === this.selectedConfigEntryId (entry) => entry.entry_id === this.selectedConfigEntryId
); );
if (configEntry) {
return this._renderSingleEntry(configEntry);
} }
}
return this._renderGroupedIntegration(); const hasItem = item !== undefined;
return html`
<ha-card
outlined
class="${classMap({
single: hasItem,
group: !hasItem,
hasMultiple: this.items.length > 1,
disabled: this.disabled,
"state-not-loaded": hasItem && item!.state === "not_loaded",
"state-failed-unload": hasItem && item!.state === "failed_unload",
"state-error": hasItem && ERROR_STATES.includes(item!.state),
})}"
.configEntry=${item}
>
<ha-integration-header
.hass=${this.hass}
.banner=${this.disabled
? this.hass.localize(
"ui.panel.config.integrations.config_entry.disable.disabled"
)
: undefined}
.domain=${this.domain}
.label=${item
? item.title || item.localized_domain_name || this.domain
: undefined}
.localizedDomainName=${item ? item.localized_domain_name : undefined}
.manifest=${this.manifest}
>
${this.items.length > 1
? html`
<div class="back-btn" slot="above-header">
<ha-icon-button
icon="hass:chevron-left"
@click=${this._back}
></ha-icon-button>
</div>
`
: ""}
</ha-integration-header>
${item
? this._renderSingleEntry(item)
: this._renderGroupedIntegration()}
</ha-card>
`;
} }
private _renderGroupedIntegration(): TemplateResult { private _renderGroupedIntegration(): TemplateResult {
return html` return html`
<ha-card outlined class="group ${classMap({ disabled: this.disabled })}">
${this.disabled
? html`<div class="header">
${this.hass.localize(
"ui.panel.config.integrations.config_entry.disable.disabled"
)}
</div>`
: ""}
<div class="group-header">
<img
src=${brandsUrl(this.domain, "icon")}
referrerpolicy="no-referrer"
@error=${this._onImageError}
@load=${this._onImageLoad}
/>
<h2>
${domainToName(this.hass.localize, this.domain)}
</h2>
</div>
<paper-listbox> <paper-listbox>
${this.items.map( ${this.items.map(
(item) => (item) =>
@ -151,7 +151,7 @@ export class HaIntegrationCard extends LitElement {
"ui.panel.config.integrations.config_entry.unnamed_entry" "ui.panel.config.integrations.config_entry.unnamed_entry"
)}</paper-item-body )}</paper-item-body
> >
${item.state === "not_loaded" ${ERROR_STATES.includes(item.state)
? html`<span> ? html`<span>
<ha-svg-icon <ha-svg-icon
class="error" class="error"
@ -159,11 +159,7 @@ export class HaIntegrationCard extends LitElement {
></ha-svg-icon ></ha-svg-icon
><paper-tooltip animation-delay="0" position="left"> ><paper-tooltip animation-delay="0" position="left">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.integrations.config_entry.not_loaded", `ui.panel.config.integrations.config_entry.state.${item.state}`
"logs_link",
this.hass.localize(
"ui.panel.config.integrations.config_entry.logs"
)
)} )}
</paper-tooltip> </paper-tooltip>
</span>` </span>`
@ -172,7 +168,6 @@ export class HaIntegrationCard extends LitElement {
</paper-item>` </paper-item>`
)} )}
</paper-listbox> </paper-listbox>
</ha-card>
`; `;
} }
@ -181,61 +176,59 @@ export class HaIntegrationCard extends LitElement {
const services = this._getServices(item); const services = this._getServices(item);
const entities = this._getEntities(item); const entities = this._getEntities(item);
return html` let stateText: [string, ...unknown[]] | undefined;
<ha-card let stateTextExtra: TemplateResult | string | undefined;
outlined
class="single integration ${classMap({ if (item.disabled_by) {
disabled: Boolean(item.disabled_by), stateText = [
"not-loaded": !item.disabled_by && item.state === "not_loaded",
})}"
.configEntry=${item}
.id=${item.entry_id}
>
${this.items.length > 1
? html`<ha-icon-button
class="back-btn"
icon="hass:chevron-left"
@click=${this._back}
></ha-icon-button>`
: ""}
${item.disabled_by
? html`<div class="header">
${this.hass.localize(
"ui.panel.config.integrations.config_entry.disable.disabled_cause", "ui.panel.config.integrations.config_entry.disable.disabled_cause",
"cause", "cause",
this.hass.localize( this.hass.localize(
`ui.panel.config.integrations.config_entry.disable.disabled_by.${item.disabled_by}` `ui.panel.config.integrations.config_entry.disable.disabled_by.${item.disabled_by}`
) || item.disabled_by ) || item.disabled_by,
)} ];
</div>` if (item.state === "failed_unload") {
: item.state === "not_loaded" stateTextExtra = html`.
? html`<div class="header">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.integrations.config_entry.not_loaded", "ui.panel.config.integrations.config_entry.disable_restart_confirm"
"logs_link", )}.`;
html`<a href="/config/logs" }
} 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`
<br />
<a href="/config/logs"
>${this.hass.localize( >${this.hass.localize(
"ui.panel.config.integrations.config_entry.logs" "ui.panel.config.integrations.config_entry.check_the_logs"
)}</a )}</a
>` >
)} `;
</div>` }
: ""} }
<div class="card-content">
<div class="image"> return html`
<img ${stateText
src=${brandsUrl(item.domain, "logo")} ? html`
referrerpolicy="no-referrer" <div class="message">
@error=${this._onImageError} <ha-svg-icon .path=${mdiAlertCircle}></ha-svg-icon>
@load=${this._onImageLoad} <div>
/> ${this.hass.localize(...stateText)}${stateTextExtra}
</div> </div>
<h2> </div>
${item.localized_domain_name} `
</h2> : ""}
<h3> <div class="content">
${item.localized_domain_name === item.title ? "" : item.title}
</h3>
${devices.length || services.length || entities.length ${devices.length || services.length || entities.length
? html` ? html`
<div> <div>
@ -282,26 +275,20 @@ export class HaIntegrationCard extends LitElement {
` `
: ""} : ""}
</div> </div>
<div class="card-actions"> <div class="actions">
<div> <div>
${item.disabled_by === "user" ${item.disabled_by === "user"
? html`<mwc-button unelevated @click=${this._handleEnable}> ? html`<mwc-button unelevated @click=${this._handleEnable}>
${this.hass.localize("ui.common.enable")} ${this.hass.localize("ui.common.enable")}
</mwc-button>` </mwc-button>`
: ""} : item.domain in integrationsWithPanel
<mwc-button @click=${this._editEntryName}>
${this.hass.localize(
"ui.panel.config.integrations.config_entry.rename"
)}
</mwc-button>
${item.domain in integrationsWithPanel
? html`<a ? html`<a
href=${`${ href=${`${integrationsWithPanel[item.domain]}?config_entry=${
integrationsWithPanel[item.domain].path item.entry_id
}?config_entry=${item.entry_id}`} }`}
><mwc-button> ><mwc-button>
${this.hass.localize( ${this.hass.localize(
integrationsWithPanel[item.domain].buttonLocalizeKey "ui.panel.config.integrations.config_entry.configure"
)} )}
</mwc-button></a </mwc-button></a
>` >`
@ -309,12 +296,15 @@ export class HaIntegrationCard extends LitElement {
? html` ? html`
<mwc-button @click=${this._showOptions}> <mwc-button @click=${this._showOptions}>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.integrations.config_entry.options" "ui.panel.config.integrations.config_entry.configure"
)} )}
</mwc-button> </mwc-button>
` `
: ""} : ""}
</div> </div>
${!this.manifest
? ""
: html`
<ha-button-menu corner="BOTTOM_START"> <ha-button-menu corner="BOTTOM_START">
<mwc-icon-button <mwc-icon-button
.title=${this.hass.localize("ui.common.menu")} .title=${this.hass.localize("ui.common.menu")}
@ -323,14 +313,17 @@ export class HaIntegrationCard extends LitElement {
> >
<ha-svg-icon .path=${mdiDotsVertical}></ha-svg-icon> <ha-svg-icon .path=${mdiDotsVertical}></ha-svg-icon>
</mwc-icon-button> </mwc-icon-button>
<mwc-list-item @request-selected="${this._editEntryName}">
${this.hass.localize(
"ui.panel.config.integrations.config_entry.rename"
)}
</mwc-list-item>
<mwc-list-item @request-selected="${this._handleSystemOptions}"> <mwc-list-item @request-selected="${this._handleSystemOptions}">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.integrations.config_entry.system_options" "ui.panel.config.integrations.config_entry.system_options"
)} )}
</mwc-list-item> </mwc-list-item>
${!this.manifest
? ""
: html`
<a <a
href=${this.manifest.documentation} href=${this.manifest.documentation}
rel="noreferrer" rel="noreferrer"
@ -345,19 +338,22 @@ export class HaIntegrationCard extends LitElement {
></ha-svg-icon> ></ha-svg-icon>
</mwc-list-item> </mwc-list-item>
</a> </a>
`}
${!item.disabled_by && ${!item.disabled_by &&
item.state === "loaded" && item.state === "loaded" &&
item.supports_unload && item.supports_unload &&
item.source !== "system" item.source !== "system"
? html`<mwc-list-item @request-selected="${this._handleReload}"> ? html`<mwc-list-item
@request-selected="${this._handleReload}"
>
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.integrations.config_entry.reload" "ui.panel.config.integrations.config_entry.reload"
)} )}
</mwc-list-item>` </mwc-list-item>`
: ""} : ""}
${item.disabled_by === "user" ${item.disabled_by === "user"
? html`<mwc-list-item @request-selected="${this._handleEnable}"> ? html`<mwc-list-item
@request-selected="${this._handleEnable}"
>
${this.hass.localize("ui.common.enable")} ${this.hass.localize("ui.common.enable")}
</mwc-list-item>` </mwc-list-item>`
: item.source !== "system" : item.source !== "system"
@ -379,11 +375,21 @@ export class HaIntegrationCard extends LitElement {
</mwc-list-item>` </mwc-list-item>`
: ""} : ""}
</ha-button-menu> </ha-button-menu>
`}
</div> </div>
</ha-card>
`; `;
} }
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) { private _selectConfigEntry(ev: Event) {
this.selectedConfigEntryId = (ev.currentTarget as any).entryId; 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) { private _showOptions(ev) {
showOptionsFlowDialog(this, ev.target.closest("ha-card").configEntry); showOptionsFlowDialog(this, ev.target.closest("ha-card").configEntry);
} }
@ -589,123 +587,115 @@ export class HaIntegrationCard extends LitElement {
return [ return [
haStyle, haStyle,
css` css`
:host {
max-width: 500px;
}
ha-card { ha-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
--state-color: var(--divider-color, #e0e0e0);
--ha-card-border-color: var(--state-color);
--state-message-color: var(--state-color);
} }
ha-card.single { .state-error {
justify-content: space-between; --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 { :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 { .hasMultiple.single .back-btn {
--ha-card-border-color: var(--error-color); height: 24px;
display: flex;
align-items: center;
} }
.header { .hasMultiple.group .back-btn {
padding: 8px; height: 0px;
text-align: center;
} }
.disabled .header {
background: var(--warning-color); .message {
color: var(--text-primary-color); font-weight: bold;
padding-bottom: 16px;
display: flex;
margin-left: 40px;
} }
.not-loaded .header { .message ha-svg-icon {
background: var(--error-color); color: var(--state-message-color);
color: var(--text-primary-color);
} }
.not-loaded .header a { .message div {
color: var(--text-primary-color); flex: 1;
margin-left: 8px;
padding-top: 2px;
} }
.card-content {
padding: 16px; .content {
text-align: center; flex: 1;
padding: 0px 16px 0 72px;
} }
ha-card.integration .card-content {
padding-bottom: 3px; .actions {
}
.card-actions {
border-top: none;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding-right: 5px; padding: 8px 0 0 8px;
height: 48px;
} }
.group-header { .actions a {
display: flex; text-decoration: none;
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;
} }
a { a {
color: var(--primary-color); 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 { ha-button-menu {
color: var(--secondary-text-color); color: var(--secondary-text-color);
--mdc-menu-min-width: 200px; --mdc-menu-min-width: 200px;
} }
@media (min-width: 563px) { @media (min-width: 563px) {
ha-card.group {
position: relative;
min-height: 164px;
}
paper-listbox { paper-listbox {
max-height: 150px; position: absolute;
top: 64px;
left: 0;
right: 0;
bottom: 0;
overflow: auto; overflow: auto;
} }
.disabled paper-listbox {
top: 88px;
}
} }
paper-item { paper-item {
cursor: pointer; cursor: pointer;
min-height: 35px; 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 { mwc-list-item ha-svg-icon {
color: var(--secondary-text-color); color: var(--secondary-text-color);
} }
.back-btn {
position: absolute;
background: rgba(var(--rgb-card-background-color), 0.6);
border-radius: 50%;
}
`, `,
]; ];
} }

View File

@ -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`<div class="banner">
${this.banner}
</div>`}
<slot name="above-header"></slot>
<div class="header">
<img
src=${brandsUrl(this.domain, "icon")}
referrerpolicy="no-referrer"
@error=${this._onImageError}
@load=${this._onImageLoad}
/>
<div class="info">
<div class="primary">${primary}</div>
${secondary ? html`<div class="secondary">${secondary}</div>` : ""}
</div>
${icons.length === 0
? ""
: html`
<div class="icons">
${icons.map(
([icon, description]) => html`
<span>
<ha-svg-icon .path=${icon}></ha-svg-icon>
<paper-tooltip animation-delay="0"
>${description}</paper-tooltip
>
</span>
`
)}
</div>
`}
</div>
`;
}
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;
}
}

View File

@ -76,9 +76,7 @@ class DialogZHADeviceChildren extends LitElement {
}, },
}; };
public showDialog( public showDialog(params: ZHADeviceChildrenDialogParams): void {
params: ZHADeviceChildrenDialogParams
): void {
this._device = params.device; this._device = params.device;
this._fetchData(); this._fetchData();
} }

View File

@ -8,42 +8,62 @@ import {
property, property,
TemplateResult, TemplateResult,
} from "lit-element"; } 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 { haStyleDialog } from "../../../../../resources/styles";
import { HomeAssistant } from "../../../../../types"; import { HomeAssistant } from "../../../../../types";
import { ZHAReconfigureDeviceDialogParams } from "./show-dialog-zha-reconfigure-device"; 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 "../../../../../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 { fireEvent } from "../../../../../common/dom/fire_event";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { createCloseHeading } from "../../../../../components/ha-dialog";
@customElement("dialog-zha-reconfigure-device") @customElement("dialog-zha-reconfigure-device")
class DialogZHAReconfigureDevice extends LitElement { class DialogZHAReconfigureDevice extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@internalProperty() private _active = false; @internalProperty() private _status?: string;
@internalProperty() private _formattedEvents = ""; @internalProperty() private _stages?: string[];
@internalProperty() @internalProperty() private _clusterConfigurationStatuses?: Map<
private _params: ZHAReconfigureDeviceDialogParams | undefined = undefined; number,
ClusterConfigurationStatus
> = new Map();
private _subscribed?: Promise<() => Promise<void>>; @internalProperty() private _params:
| ZHAReconfigureDeviceDialogParams
| undefined = undefined;
private _reconfigureDeviceTimeoutHandle: any = undefined; @internalProperty() private _allSuccessful = true;
public async showDialog( @internalProperty() private _showDetails = false;
params: ZHAReconfigureDeviceDialogParams
): Promise<void> { private _subscribed?: Promise<UnsubscribeFunc>;
public showDialog(params: ZHAReconfigureDeviceDialogParams): void {
this._params = params; this._params = params;
this._subscribe(params); this._stages = undefined;
} }
public closeDialog(): void { public closeDialog(): void {
this._unsubscribe(); this._unsubscribe();
this._formattedEvents = "";
this._params = undefined; this._params = undefined;
this._status = undefined;
this._stages = undefined;
this._clusterConfigurationStatuses = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName }); fireEvent(this, "dialog-closed", { dialog: this.localName });
} }
@ -51,58 +71,311 @@ class DialogZHAReconfigureDevice extends LitElement {
if (!this._params) { if (!this._params) {
return html``; return html``;
} }
return html` return html`
<ha-dialog <ha-dialog
open open
hideActions @closed="${this.closeDialog}"
@closing="${this.closeDialog}"
.heading=${createCloseHeading( .heading=${createCloseHeading(
this.hass, this.hass,
this.hass.localize(`ui.dialogs.zha_reconfigure_device.heading`) this.hass.localize(`ui.dialogs.zha_reconfigure_device.heading`) +
": " +
(this._params?.device.user_given_name || this._params?.device.name)
)} )}
> >
<div class="searching"> ${!this._status
${this._active
? html` ? html`
<h1> <p>
${this._params?.device.user_given_name || ${this.hass.localize(
this._params?.device.name} "ui.dialogs.zha_reconfigure_device.introduction"
</h1> )}
<ha-circular-progress </p>
active <p>
alt="Searching" <em>
></ha-circular-progress> ${this.hass.localize(
"ui.dialogs.zha_reconfigure_device.battery_device_warning"
)}
</em>
</p>
<mwc-button
slot="primaryAction"
@click=${this._startReconfiguration}
>
${this.hass.localize(
"ui.dialogs.zha_reconfigure_device.start_reconfiguration"
)}
</mwc-button>
`
: ``}
${this._status === "started"
? html`
<div class="flex-container">
<ha-circular-progress active></ha-circular-progress>
<div class="status">
<p>
<b>
${this.hass.localize(
"ui.dialogs.zha_reconfigure_device.in_progress"
)}
</b>
</p>
<p>
${this.hass.localize(
"ui.dialogs.zha_reconfigure_device.run_in_background"
)}
</p>
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.dialogs.generic.close")}
</mwc-button>
<mwc-button slot="secondaryAction" @click=${this._toggleDetails}>
${this._showDetails
? this.hass.localize(
`ui.dialogs.zha_reconfigure_device.button_hide`
)
: this.hass.localize(
`ui.dialogs.zha_reconfigure_device.button_show`
)}
</mwc-button>
`
: ``}
${this._status === "failed"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCloseCircle}
class="failed"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.dialogs.zha_reconfigure_device.configuration_failed"
)}
</p>
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.dialogs.generic.close")}
</mwc-button>
<mwc-button slot="secondaryAction" @click=${this._toggleDetails}>
${this._showDetails
? this.hass.localize(
`ui.dialogs.zha_reconfigure_device.button_hide`
)
: this.hass.localize(
`ui.dialogs.zha_reconfigure_device.button_show`
)}
</mwc-button>
`
: ``}
${this._status === "finished"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCheckCircle}
class="success"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.dialogs.zha_reconfigure_device.configuration_complete"
)}
</p>
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.dialogs.generic.close")}
</mwc-button>
<mwc-button slot="secondaryAction" @click=${this._toggleDetails}>
${this._showDetails
? this.hass.localize(
`ui.dialogs.zha_reconfigure_device.button_hide`
)
: this.hass.localize(
`ui.dialogs.zha_reconfigure_device.button_show`
)}
</mwc-button>
`
: ``}
${this._stages
? html`
<div class="stages">
${this._stages.map(
(stage) => html`
<span class="stage">
<ha-svg-icon
.path=${mdiCheckCircle}
class="success"
></ha-svg-icon>
${stage}
</span>
`
)}
</div>
`
: ""}
${this._showDetails
? html`
<div class="wrapper">
<h2 class="grid-item">
${this.hass.localize(
`ui.dialogs.zha_reconfigure_device.cluster_header`
)}
</h2>
<h2 class="grid-item">
${this.hass.localize(
`ui.dialogs.zha_reconfigure_device.bind_header`
)}
</h2>
<h2 class="grid-item">
${this.hass.localize(
`ui.dialogs.zha_reconfigure_device.reporting_header`
)}
</h2>
${this._clusterConfigurationStatuses!.size > 0
? html`
${Array.from(
this._clusterConfigurationStatuses!.values()
).map(
(clusterStatus) => html`
<div class="grid-item">
${clusterStatus.cluster.name}
</div>
<div class="grid-item">
${clusterStatus.bindSuccess !== undefined
? clusterStatus.bindSuccess
? html`
<span class="stage">
<ha-svg-icon
.path=${mdiCheckCircle}
class="success"
></ha-svg-icon>
</span>
`
: html`
<span class="stage">
<ha-svg-icon
.path=${mdiCloseCircle}
class="failed"
></ha-svg-icon>
</span>
` `
: ""} : ""}
</div> </div>
<paper-textarea <div class="grid-item">
readonly ${clusterStatus.attributes.size > 0
max-rows="10" ? html`
class="log" <div class="attributes">
value="${this._formattedEvents}" <div class="grid-item">
> ${this.hass.localize(
</paper-textarea> `ui.dialogs.zha_reconfigure_device.attribute`
)}
</div>
<div class="grid-item">
<div>
${this.hass.localize(
`ui.dialogs.zha_reconfigure_device.min_max_change`
)}
</div>
</div>
${Array.from(
clusterStatus.attributes.values()
).map(
(attribute) => html`
<span class="grid-item">
${attribute.name}:
${attribute.success
? html`
<span class="stage">
<ha-svg-icon
.path=${mdiCheckCircle}
class="success"
></ha-svg-icon>
</span>
`
: html`
<span class="stage">
<ha-svg-icon
.path=${mdiCloseCircle}
class="failed"
></ha-svg-icon>
</span>
`}
</span>
<div class="grid-item">
${attribute.min}/${attribute.max}/${attribute.change}
</div>
`
)}
</div>
`
: ""}
</div>
`
)}
`
: ""}
</div>
`
: ""}
</ha-dialog> </ha-dialog>
`; `;
} }
private _handleMessage(message: any): void { private async _startReconfiguration(): Promise<void> {
if (message.type === LOG_OUTPUT) { if (!this.hass || !this._params) {
this._formattedEvents += message.log_entry.message + "\n"; return;
const paperTextArea = this.shadowRoot!.querySelector("paper-textarea");
if (paperTextArea) {
const textArea = (paperTextArea.inputElement as IronAutogrowTextareaElement)
.textarea;
textArea.scrollTop = textArea.scrollHeight;
} }
this._clusterConfigurationStatuses = new Map(
(await fetchClustersForZhaNode(this.hass, this._params.device.ieee)).map(
(cluster: Cluster) => [
cluster.id,
{
cluster: cluster,
bindSuccess: undefined,
attributes: new Map<number, AttributeConfigurationStatus>(),
},
]
)
);
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 { private _unsubscribe(): void {
this._active = false;
if (this._reconfigureDeviceTimeoutHandle) {
clearTimeout(this._reconfigureDeviceTimeoutHandle);
}
if (this._subscribed) { if (this._subscribed) {
this._subscribed.then((unsub) => unsub()); this._subscribed.then((unsub) => unsub());
this._subscribed = undefined; this._subscribed = undefined;
@ -113,33 +386,66 @@ class DialogZHAReconfigureDevice extends LitElement {
if (!this.hass) { if (!this.hass) {
return; return;
} }
this._active = true;
this._subscribed = reconfigureNode( this._subscribed = reconfigureNode(
this.hass, this.hass,
params.device.ieee, params.device.ieee,
this._handleMessage.bind(this) this._handleMessage.bind(this)
); );
this._reconfigureDeviceTimeoutHandle = setTimeout( }
() => this._unsubscribe(),
60000 private _toggleDetails() {
); this._showDetails = !this._showDetails;
} }
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [ return [
haStyleDialog, haStyleDialog,
css` css`
ha-circular-progress { .wrapper {
padding: 20px; display: grid;
grid-template-columns: 3fr 1fr 2fr;
} }
.searching { .attributes {
margin-top: 20px; 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; display: flex;
flex-direction: column;
align-items: center; 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;
} }
`, `,
]; ];

View File

@ -9,6 +9,7 @@ import {
html, html,
LitElement, LitElement,
property, property,
PropertyValues,
TemplateResult, TemplateResult,
} from "lit-element"; } from "lit-element";
import { computeRTL } from "../../../../../common/util/compute_rtl"; 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 { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types"; import type { HomeAssistant, Route } from "../../../../../types";
import "../../../ha-config-section"; import "../../../ha-config-section";
import "../../../../../components/ha-form/ha-form";
import {
fetchZHAConfiguration,
updateZHAConfiguration,
ZHAConfiguration,
} from "../../../../../data/zha";
export const zhaTabs: PageNavigation[] = [ export const zhaTabs: PageNavigation[] = [
{ {
@ -51,6 +58,15 @@ class ZHAConfigDashboard extends LitElement {
@property() public configEntryId?: string; @property() public configEntryId?: string;
@property() private _configuration?: ZHAConfiguration;
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
if (this.hass) {
this._fetchConfiguration();
}
}
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<hass-tabs-subpage <hass-tabs-subpage
@ -60,10 +76,11 @@ class ZHAConfigDashboard extends LitElement {
.tabs=${zhaTabs} .tabs=${zhaTabs}
back-path="/config/integrations" back-path="/config/integrations"
> >
<ha-card header="Zigbee Network"> <ha-card
<div class="card-content"> header=${this.hass.localize(
In the future you can change network settings for ZHA here. "ui.panel.config.zha.configuration_page.shortcuts_title"
</div> )}
>
${this.configEntryId ${this.configEntryId
? html`<div class="card-actions"> ? html`<div class="card-actions">
<a <a
@ -87,6 +104,38 @@ class ZHAConfigDashboard extends LitElement {
</div>` </div>`
: ""} : ""}
</ha-card> </ha-card>
${this._configuration
? Object.entries(this._configuration.schemas).map(
([section, schema]) => html` <ha-card
header=${this.hass.localize(
`ui.panel.config.zha.configuration_page.${section}.title`
)}
>
<div class="card-content">
<ha-form
.schema=${schema}
.data=${this._configuration!.data[section]}
@value-changed=${this._dataChanged}
.section=${section}
.computeLabel=${this._computeLabelCallback(
this.hass.localize,
section
)}
></ha-form>
</div>
</ha-card>`
)
: ""}
<ha-card>
<div class="card-actions">
<mwc-button @click=${this._updateConfiguration}>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.update_button"
)}
</mwc-button>
</div>
</ha-card>
<a href="/config/zha/add" slot="fab"> <a href="/config/zha/add" slot="fab">
<ha-fab <ha-fab
.label=${this.hass.localize("ui.panel.config.zha.add_device")} .label=${this.hass.localize("ui.panel.config.zha.add_device")}
@ -100,6 +149,26 @@ class ZHAConfigDashboard extends LitElement {
`; `;
} }
private async _fetchConfiguration(): Promise<void> {
this._configuration = await fetchZHAConfiguration(this.hass!);
}
private _dataChanged(ev) {
this._configuration!.data[ev.currentTarget!.section] = ev.detail.value;
}
private async _updateConfiguration(): Promise<any> {
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 { static get styles(): CSSResultArray {
return [ return [
haStyle, haStyle,

View File

@ -159,7 +159,7 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) {
} }
if (!newName && !newEntityId) { if (!newName && !newEntityId) {
return new Promise((resolve) => resolve()); return undefined;
} }
return updateEntityRegistryEntry(this.hass!, entity.entity_id, { 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]) { if (this.hass.states[entity.entity_id]) {
return computeStateName(this.hass.states[entity.entity_id]); return computeStateName(this.hass.states[entity.entity_id]);
} }

View File

@ -17,8 +17,8 @@ import {
refreshTopology, refreshTopology,
ZHADevice, ZHADevice,
} from "../../../../../data/zha"; } from "../../../../../data/zha";
import "../../../../../layouts/hass-subpage"; import "../../../../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../../../../types"; import type { HomeAssistant, Route } from "../../../../../types";
import { Network, Edge, Node, EdgeOptions } from "vis-network"; import { Network, Edge, Node, EdgeOptions } from "vis-network";
import "../../../../../common/search/search-input"; import "../../../../../common/search/search-input";
import "../../../../../components/device/ha-device-picker"; import "../../../../../components/device/ha-device-picker";
@ -29,12 +29,17 @@ import { formatAsPaddedHex } from "./functions";
import { DeviceRegistryEntry } from "../../../../../data/device_registry"; import { DeviceRegistryEntry } from "../../../../../data/device_registry";
import "../../../../../components/ha-checkbox"; import "../../../../../components/ha-checkbox";
import type { HaCheckbox } from "../../../../../components/ha-checkbox"; import type { HaCheckbox } from "../../../../../components/ha-checkbox";
import { zhaTabs } from "./zha-config-dashboard";
@customElement("zha-network-visualization-page") @customElement("zha-network-visualization-page")
export class ZHANetworkVisualizationPage extends LitElement { 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() @property()
public zoomedDeviceId?: string; public zoomedDeviceId?: string;
@ -133,9 +138,12 @@ export class ZHANetworkVisualizationPage extends LitElement {
protected render() { protected render() {
return html` return html`
<hass-subpage <hass-tabs-subpage
.tabs=${zhaTabs}
.hass=${this.hass} .hass=${this.hass}
.narrow=${this.narrow} .narrow=${this.narrow}
.isWide=${this.isWide}
.route=${this.route}
.header=${this.hass.localize( .header=${this.hass.localize(
"ui.panel.config.zha.visualization.header" "ui.panel.config.zha.visualization.header"
)} )}
@ -172,7 +180,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
> >
</div> </div>
<div id="visualization"></div> <div id="visualization"></div>
</hass-subpage> </hass-tabs-subpage>
`; `;
} }

View File

@ -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<UnsubscribeFunc>;
public async showDialog(
params: ZWaveJSReinterviewNodeDialogParams
): Promise<void> {
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`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
this.hass.localize("ui.panel.config.zwave_js.reinterview_node.title")
)}
>
${!this._status
? html`
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.reinterview_node.introduction"
)}
</p>
<p>
<em>
${this.hass.localize(
"ui.panel.config.zwave_js.reinterview_node.battery_device_warning"
)}
</em>
</p>
<mwc-button slot="primaryAction" @click=${this._startReinterview}>
${this.hass.localize(
"ui.panel.config.zwave_js.reinterview_node.start_reinterview"
)}
</mwc-button>
`
: ``}
${this._status === "started"
? html`
<div class="flex-container">
<ha-circular-progress active></ha-circular-progress>
<div class="status">
<p>
<b>
${this.hass.localize(
"ui.panel.config.zwave_js.reinterview_node.in_progress"
)}
</b>
</p>
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.reinterview_node.run_in_background"
)}
</p>
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.panel.config.zwave_js.common.close")}
</mwc-button>
`
: ``}
${this._status === "failed"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCloseCircle}
class="failed"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.reinterview_node.interview_failed"
)}
</p>
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.panel.config.zwave_js.common.close")}
</mwc-button>
`
: ``}
${this._status === "finished"
? html`
<div class="flex-container">
<ha-svg-icon
.path=${mdiCheckCircle}
class="success"
></ha-svg-icon>
<div class="status">
<p>
${this.hass.localize(
"ui.panel.config.zwave_js.reinterview_node.interview_complete"
)}
</p>
</div>
</div>
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.panel.config.zwave_js.common.close")}
</mwc-button>
`
: ``}
${this._stages
? html`
<div class="stages">
${this._stages.map(
(stage) => html`
<span class="stage">
<ha-svg-icon
.path=${mdiCheckCircle}
class="success"
></ha-svg-icon>
${stage}
</span>
`
)}
</div>
`
: ""}
</ha-dialog>
`;
}
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;
}
}

View File

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

View File

@ -17,9 +17,11 @@ import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-icon-next"; import "../../../../../components/ha-icon-next";
import { getSignedPath } from "../../../../../data/auth"; import { getSignedPath } from "../../../../../data/auth";
import { import {
fetchDataCollectionStatus,
fetchNetworkStatus, fetchNetworkStatus,
fetchNodeStatus, fetchNodeStatus,
NodeStatus, NodeStatus,
setDataCollectionPreference,
ZWaveJSNetwork, ZWaveJSNetwork,
ZWaveJSNode, ZWaveJSNode,
} from "../../../../../data/zwave_js"; } from "../../../../../data/zwave_js";
@ -55,6 +57,8 @@ class ZWaveJSConfigDashboard extends LitElement {
@internalProperty() private _icon = mdiCircle; @internalProperty() private _icon = mdiCircle;
@internalProperty() private _dataCollectionOptIn?: boolean;
protected firstUpdated() { protected firstUpdated() {
if (this.hass) { if (this.hass) {
this._fetchData(); this._fetchData();
@ -167,6 +171,39 @@ class ZWaveJSConfigDashboard extends LitElement {
</mwc-button> </mwc-button>
</div> </div>
</ha-card> </ha-card>
<ha-card>
<div class="card-header">
<h1>Third-Party Data Reporting</h1>
${this._dataCollectionOptIn !== undefined
? html`
<ha-switch
.checked=${this._dataCollectionOptIn === true}
@change=${this._dataCollectionToggled}
></ha-switch>
`
: html`
<ha-circular-progress
size="small"
active
></ha-circular-progress>
`}
</div>
<div class="card-content">
<p>
Enable the reporting of anonymized telemetry and
statistics to the <em>Z-Wave JS organization</em>. This
data will be used to focus development efforts and improve
the user experience. Information about the data that is
collected and how it is used, including an example of the
data collected, can be found in the
<a
target="_blank"
href="https://zwave-js.github.io/node-zwave-js/#/data-collection/data-collection?id=usage-statistics"
>Z-Wave JS data collection documentation</a
>.
</p>
</div>
</ha-card>
` `
: ``} : ``}
<button class="link dump" @click=${this._dumpDebugClicked}> <button class="link dump" @click=${this._dumpDebugClicked}>
@ -183,11 +220,22 @@ class ZWaveJSConfigDashboard extends LitElement {
if (!this.configEntryId) { if (!this.configEntryId) {
return; return;
} }
this._network = await fetchNetworkStatus(this.hass!, this.configEntryId); const [network, dataCollectionStatus] = await Promise.all([
fetchNetworkStatus(this.hass!, this.configEntryId),
fetchDataCollectionStatus(this.hass!, this.configEntryId),
]);
this._network = network;
this._status = this._network.client.state; this._status = this._network.client.state;
if (this._status === "connected") { if (this._status === "connected") {
this._icon = mdiCheckCircle; this._icon = mdiCheckCircle;
} }
this._dataCollectionOptIn =
dataCollectionStatus.opted_in === true ||
dataCollectionStatus.enabled === true;
this._fetchNodeStatus(); this._fetchNodeStatus();
} }
@ -213,6 +261,14 @@ class ZWaveJSConfigDashboard extends LitElement {
}); });
} }
private _dataCollectionToggled(ev) {
setDataCollectionPreference(
this.hass!,
this.configEntryId!,
ev.target.checked
);
}
private async _dumpDebugClicked() { private async _dumpDebugClicked() {
await this._fetchNodeStatus(); await this._fetchNodeStatus();
@ -321,8 +377,19 @@ class ZWaveJSConfigDashboard extends LitElement {
font-size: 1rem; font-size: 1rem;
} }
.card-header {
display: flex;
}
.card-header h1 {
flex: 1;
}
.card-header ha-switch {
width: 48px;
margin-top: 16px;
}
ha-card { ha-card {
margin: 0 auto; margin: 0px auto 24px;
max-width: 600px; max-width: 600px;
} }

View File

@ -7,7 +7,7 @@ import { HomeAssistant } from "../../../../../types";
import { navigate } from "../../../../../common/navigate"; import { navigate } from "../../../../../common/navigate";
import { PageNavigation } from "../../../../../layouts/hass-tabs-subpage"; import { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import { mdiServerNetwork } from "@mdi/js"; import { mdiServerNetwork, mdiMathLog } from "@mdi/js";
export const configTabs: PageNavigation[] = [ export const configTabs: PageNavigation[] = [
{ {
@ -15,6 +15,11 @@ export const configTabs: PageNavigation[] = [
path: `/config/zwave_js/dashboard`, path: `/config/zwave_js/dashboard`,
iconPath: mdiServerNetwork, iconPath: mdiServerNetwork,
}, },
{
translationKey: "ui.panel.config.zwave_js.navigation.logs",
path: `/config/zwave_js/logs`,
iconPath: mdiMathLog,
},
]; ];
@customElement("zwave_js-config-router") @customElement("zwave_js-config-router")
@ -41,6 +46,10 @@ class ZWaveJSConfigRouter extends HassRouterPage {
tag: "zwave_js-node-config", tag: "zwave_js-node-config",
load: () => import("./zwave_js-node-config"), load: () => import("./zwave_js-node-config"),
}, },
logs: {
tag: "zwave_js-logs",
load: () => import("./zwave_js-logs"),
},
}, },
}; };

View File

@ -0,0 +1,157 @@
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import {
css,
html,
property,
customElement,
LitElement,
CSSResultArray,
internalProperty,
query,
} from "lit-element";
import "@polymer/paper-listbox/paper-listbox";
import "@polymer/paper-dropdown-menu/paper-dropdown-menu";
import {
fetchZWaveJSLogConfig,
setZWaveJSLogLevel,
subscribeZWaveJSLogs,
ZWaveJSLogConfig,
} from "../../../../../data/zwave_js";
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import { HomeAssistant, Route } from "../../../../../types";
import { configTabs } from "./zwave_js-config-router";
import "../../../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../../../resources/styles";
@customElement("zwave_js-logs")
class ZWaveJSLogs extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Object }) public route!: Route;
@property({ type: Boolean }) public narrow!: boolean;
@property() public configEntryId!: string;
@internalProperty() private _logConfig?: ZWaveJSLogConfig;
@query("textarea", true) private _textarea?: HTMLTextAreaElement;
public hassSubscribe(): Array<UnsubscribeFunc | Promise<UnsubscribeFunc>> {
return [
subscribeZWaveJSLogs(this.hass, this.configEntryId, (log) => {
if (!this.hasUpdated) {
return;
}
if (Array.isArray(log.message)) {
for (const line of log.message) {
this._textarea!.value += `${line}\n`;
}
} else {
this._textarea!.value += `${log.message}\n`;
}
}),
];
}
protected render() {
return html`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.tabs=${configTabs}
>
<div class="container">
<ha-card>
<div class="card-header">
<h1>
${this.hass.localize("ui.panel.config.zwave_js.logs.title")}
</h1>
</div>
<div class="card-content">
${this._logConfig
? html`
<paper-dropdown-menu
dynamic-align
.label=${this.hass.localize(
"ui.panel.config.zwave_js.logs.log_level"
)}
>
<paper-listbox
slot="dropdown-content"
.selected=${this._logConfig.level}
attr-for-selected="value"
@iron-select=${this._dropdownSelected}
>
<paper-item value="error">Error</paper-item>
<paper-item value="warn">Warn</paper-item>
<paper-item value="info">Info</paper-item>
<paper-item value="verbose">Verbose</paper-item>
<paper-item value="debug">Debug</paper-item>
<paper-item value="silly">Silly</paper-item>
</paper-listbox>
</paper-dropdown-menu>
`
: ""}
</div>
</ha-card>
<textarea readonly></textarea>
</div>
</hass-tabs-subpage>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this._fetchData();
}
private async _fetchData() {
if (!this.configEntryId) {
return;
}
this._logConfig = await fetchZWaveJSLogConfig(
this.hass!,
this.configEntryId
);
}
private _dropdownSelected(ev) {
if (ev.target === undefined || this._logConfig === undefined) {
return;
}
if (this._logConfig.level === ev.target.selected) {
return;
}
setZWaveJSLogLevel(this.hass!, this.configEntryId, ev.target.selected);
}
static get styles(): CSSResultArray {
return [
haStyle,
css`
.container {
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
padding: 16px;
}
textarea {
flex-grow: 1;
padding: 16px;
}
ha-card {
margin: 16px 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"zwave_js-logs": ZWaveJSLogs;
}
}

View File

@ -1,3 +1,9 @@
import {
mdiCheckCircle,
mdiCircle,
mdiProgressClock,
mdiCloseCircle,
} from "@mdi/js";
import "../../../../../components/ha-settings-row"; import "../../../../../components/ha-settings-row";
import "@polymer/paper-item/paper-item"; import "@polymer/paper-item/paper-item";
import "@polymer/paper-listbox/paper-listbox"; import "@polymer/paper-listbox/paper-listbox";
@ -24,6 +30,7 @@ import {
fetchNodeConfigParameters, fetchNodeConfigParameters,
setNodeConfigParameter, setNodeConfigParameter,
ZWaveJSNodeConfigParams, ZWaveJSNodeConfigParams,
ZWaveJSSetConfigParamResult,
} from "../../../../../data/zwave_js"; } from "../../../../../data/zwave_js";
import "../../../../../layouts/hass-tabs-subpage"; import "../../../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../../../resources/styles"; import { haStyle } from "../../../../../resources/styles";
@ -38,6 +45,13 @@ import {
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { UnsubscribeFunc } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { classMap } from "lit-html/directives/class-map";
const icons = {
accepted: mdiCheckCircle,
queued: mdiProgressClock,
error: mdiCloseCircle,
};
const getDevice = memoizeOne( const getDevice = memoizeOne(
( (
@ -77,7 +91,12 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
@property({ type: Array }) @property({ type: Array })
private _deviceRegistryEntries?: DeviceRegistryEntry[]; private _deviceRegistryEntries?: DeviceRegistryEntry[];
@internalProperty() private _config?: ZWaveJSNodeConfigParams[]; @internalProperty() private _config?: ZWaveJSNodeConfigParams;
@internalProperty() private _results: Record<
string,
ZWaveJSSetConfigParamResult
> = {};
@internalProperty() private _error?: string; @internalProperty() private _error?: string;
@ -178,6 +197,7 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
} }
private _generateConfigBox(id, item): TemplateResult { private _generateConfigBox(id, item): TemplateResult {
const result = this._results[id];
const labelAndDescription = html` const labelAndDescription = html`
<span slot="heading">${item.metadata.label}</span> <span slot="heading">${item.metadata.label}</span>
<span slot="description"> <span slot="description">
@ -192,6 +212,26 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
)} )}
</em>` </em>`
: ""} : ""}
${result?.status
? html` <p
class="result ${classMap({
[result.status]: true,
})}"
>
<ha-svg-icon
.path=${icons[result.status] ? icons[result.status] : mdiCircle}
class="result-icon"
slot="item-icon"
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.zwave_js.node_config.set_param_" +
result.status
)}
${result.status === "error" && result.error
? html` <br /><em>${result.error}</em> `
: ""}
</p>`
: ""}
</span> </span>
`; `;
@ -293,6 +333,7 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
} }
private _switchToggled(ev) { private _switchToggled(ev) {
this.setResult(ev.target.key, undefined);
this._updateConfigParameter(ev.target, ev.target.checked ? 1 : 0); this._updateConfigParameter(ev.target, ev.target.checked ? 1 : 0);
} }
@ -303,6 +344,7 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
if (this._config![ev.target.key].value === ev.target.selected) { if (this._config![ev.target.key].value === ev.target.selected) {
return; return;
} }
this.setResult(ev.target.key, undefined);
this._updateConfigParameter(ev.target, Number(ev.target.selected)); this._updateConfigParameter(ev.target, Number(ev.target.selected));
} }
@ -321,12 +363,14 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
if (Number(this._config![ev.target.key].value) === value) { if (Number(this._config![ev.target.key].value) === value) {
return; return;
} }
this.setResult(ev.target.key, undefined);
this.debouncedUpdate(ev.target, value); this.debouncedUpdate(ev.target, value);
} }
private _updateConfigParameter(target, value) { private async _updateConfigParameter(target, value) {
const nodeId = getNodeId(this._device!); const nodeId = getNodeId(this._device!);
setNodeConfigParameter( try {
const result = await setNodeConfigParameter(
this.hass, this.hass,
this.configEntryId!, this.configEntryId!,
nodeId!, nodeId!,
@ -335,6 +379,25 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
target.propertyKey ? target.propertyKey : undefined target.propertyKey ? target.propertyKey : undefined
); );
this._config![target.key].value = value; this._config![target.key].value = value;
this.setResult(target.key, result.status);
} catch (error) {
this.setError(target.key, error.message);
}
}
private setResult(key: string, value: string | undefined) {
if (value === undefined) {
delete this._results[key];
this.requestUpdate();
} else {
this._results = { ...this._results, [key]: { status: value } };
}
}
private setError(key: string, message: string) {
const errorParam = { status: "error", error: message };
this._results = { ...this._results, [key]: errorParam };
} }
private get _device(): DeviceRegistryEntry | undefined { private get _device(): DeviceRegistryEntry | undefined {
@ -369,6 +432,18 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
return [ return [
haStyle, haStyle,
css` css`
.accepted {
color: var(--success-color);
}
.queued {
color: var(--warning-color);
}
.error {
color: var(--error-color);
}
.secondary { .secondary {
color: var(--secondary-text-color); color: var(--secondary-text-color);
} }

View File

@ -1,5 +1,5 @@
import "@material/mwc-icon-button/mwc-icon-button"; import "@material/mwc-icon-button/mwc-icon-button";
import { mdiClose, mdiContentCopy } from "@mdi/js"; import { mdiClose, mdiContentCopy, mdiPackageVariant } from "@mdi/js";
import "@polymer/paper-tooltip/paper-tooltip"; import "@polymer/paper-tooltip/paper-tooltip";
import { import {
css, css,
@ -21,7 +21,10 @@ import {
integrationIssuesUrl, integrationIssuesUrl,
IntegrationManifest, IntegrationManifest,
} from "../../../data/integration"; } from "../../../data/integration";
import { getLoggedErrorIntegration } from "../../../data/system_log"; import {
getLoggedErrorIntegration,
isCustomIntegrationError,
} from "../../../data/system_log";
import { haStyleDialog } from "../../../resources/styles"; import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast"; import { showToast } from "../../../util/toast";
@ -65,6 +68,12 @@ class DialogSystemLogDetail extends LitElement {
const integration = getLoggedErrorIntegration(item); const integration = getLoggedErrorIntegration(item);
const showDocumentation =
this._manifest &&
(this._manifest.is_built_in ||
// Custom components with our offical docs should not link to our docs
!this._manifest.documentation.includes("www.home-assistant.io"));
return html` return html`
<ha-dialog open @closed=${this.closeDialog} hideActions heading=${true}> <ha-dialog open @closed=${this.closeDialog} hideActions heading=${true}>
<ha-header-bar slot="heading"> <ha-header-bar slot="heading">
@ -86,6 +95,14 @@ class DialogSystemLogDetail extends LitElement {
<ha-svg-icon .path=${mdiContentCopy}></ha-svg-icon> <ha-svg-icon .path=${mdiContentCopy}></ha-svg-icon>
</mwc-icon-button> </mwc-icon-button>
</ha-header-bar> </ha-header-bar>
${this.isCustomIntegration
? html`<div class="custom">
<ha-svg-icon .path=${mdiPackageVariant}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.logs.error_from_custom_integration"
)}
</div>`
: ""}
<div class="contents"> <div class="contents">
<p> <p>
Logger: ${item.name}<br /> Logger: ${item.name}<br />
@ -96,7 +113,7 @@ class DialogSystemLogDetail extends LitElement {
Integration: ${domainToName(this.hass.localize, integration)} Integration: ${domainToName(this.hass.localize, integration)}
${!this._manifest || ${!this._manifest ||
// Can happen with custom integrations // Can happen with custom integrations
!this._manifest.documentation !showDocumentation
? "" ? ""
: html` : html`
(<a (<a
@ -144,6 +161,12 @@ class DialogSystemLogDetail extends LitElement {
`; `;
} }
private get isCustomIntegration(): boolean {
return this._manifest
? !this._manifest.is_built_in
: isCustomIntegrationError(this._params!.item);
}
private async _fetchManifest(integration: string) { private async _fetchManifest(integration: string) {
try { try {
this._manifest = await fetchIntegrationManifest(this.hass, integration); this._manifest = await fetchIntegrationManifest(this.hass, integration);
@ -157,7 +180,18 @@ class DialogSystemLogDetail extends LitElement {
".contents" ".contents"
) as HTMLElement; ) as HTMLElement;
await copyToClipboard(copyElement.innerText); let text = copyElement.innerText;
if (this.isCustomIntegration) {
text =
this.hass.localize(
"ui.panel.config.logs.error_from_custom_integration"
) +
"\n\n" +
text;
}
await copyToClipboard(text);
showToast(this, { showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"), message: this.hass.localize("ui.common.copied_clipboard"),
}); });
@ -167,6 +201,10 @@ class DialogSystemLogDetail extends LitElement {
return [ return [
haStyleDialog, haStyleDialog,
css` css`
ha-dialog {
--dialog-content-padding: 0px;
}
a { a {
color: var(--primary-color); color: var(--primary-color);
} }
@ -177,6 +215,13 @@ class DialogSystemLogDetail extends LitElement {
margin-bottom: 0; margin-bottom: 0;
font-family: var(--code-font-family, monospace); font-family: var(--code-font-family, monospace);
} }
.custom {
padding: 8px 16px;
background-color: var(--warning-color);
}
.contents {
padding: 16px;
}
.error { .error {
color: var(--error-color); color: var(--error-color);
} }

View File

@ -19,6 +19,7 @@ import { domainToName } from "../../../data/integration";
import { import {
fetchSystemLog, fetchSystemLog,
getLoggedErrorIntegration, getLoggedErrorIntegration,
isCustomIntegrationError,
LoggedError, LoggedError,
} from "../../../data/system_log"; } from "../../../data/system_log";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
@ -78,10 +79,16 @@ export class SystemLogCard extends LitElement {
)}</span )}</span
>) `} >) `}
${integrations[idx] ${integrations[idx]
? domainToName( ? `${domainToName(
this.hass!.localize, this.hass!.localize,
integrations[idx]! integrations[idx]!
) )}${
isCustomIntegrationError(item)
? ` (${this.hass.localize(
"ui.panel.config.logs.custom_integration"
)})`
: ""
}`
: item.source[0]} : item.source[0]}
${item.count > 1 ${item.count > 1
? html` ? html`

View File

@ -228,7 +228,7 @@ class HaSceneDashboard extends LitElement {
private async _activateScene(ev) { private async _activateScene(ev) {
ev.stopPropagation(); ev.stopPropagation();
const scene = ev.target.scene as SceneEntity; const scene = ev.currentTarget.scene as SceneEntity;
await activateScene(this.hass, scene.entity_id); await activateScene(this.hass, scene.entity_id);
showToast(this, { showToast(this, {
message: this.hass.localize( message: this.hass.localize(

View File

@ -556,20 +556,18 @@ export class HaSceneEditor extends SubscribeMixin(
if (this._entities.includes(entityId)) { if (this._entities.includes(entityId)) {
return; return;
} }
this._entities = [...this._entities, entityId];
this._storeState(entityId);
const entityRegistry = this._entityRegistryEntries.find( const entityRegistry = this._entityRegistryEntries.find(
(entityReg) => entityReg.entity_id === entityId (entityReg) => entityReg.entity_id === entityId
); );
if ( if (
entityRegistry?.device_id && entityRegistry?.device_id &&
!this._devices.includes(entityRegistry.device_id) !this._devices.includes(entityRegistry.device_id)
) { ) {
this._devices = [...this._devices, entityRegistry.device_id]; this._pickDevice(entityRegistry.device_id);
} else {
this._entities = [...this._entities, entityId];
this._storeState(entityId);
} }
this._dirty = true; this._dirty = true;
} }
@ -582,14 +580,12 @@ export class HaSceneEditor extends SubscribeMixin(
this._dirty = true; this._dirty = true;
} }
private _devicePicked(ev: CustomEvent) { private _pickDevice(device_id: string) {
const device = ev.detail.value; if (this._devices.includes(device_id)) {
(ev.target as any).value = "";
if (this._devices.includes(device)) {
return; return;
} }
this._devices = [...this._devices, device]; this._devices = [...this._devices, device_id];
const deviceEntities = this._deviceEntityLookup[device]; const deviceEntities = this._deviceEntityLookup[device_id];
if (!deviceEntities) { if (!deviceEntities) {
return; return;
} }
@ -600,6 +596,12 @@ export class HaSceneEditor extends SubscribeMixin(
this._dirty = true; this._dirty = true;
} }
private _devicePicked(ev: CustomEvent) {
const device = ev.detail.value;
(ev.target as any).value = "";
this._pickDevice(device);
}
private _deleteDevice(ev: Event) { private _deleteDevice(ev: Event) {
const deviceId = (ev.target as any).device; const deviceId = (ev.target as any).device;
this._devices = this._devices.filter((device) => device !== deviceId); this._devices = this._devices.filter((device) => device !== deviceId);
@ -627,7 +629,12 @@ export class HaSceneEditor extends SubscribeMixin(
if ((this._config![name] || "") === newVal) { if ((this._config![name] || "") === newVal) {
return; return;
} }
if (!newVal) {
delete this._config![name];
this._config = { ...this._config! };
} else {
this._config = { ...this._config!, [name]: newVal }; this._config = { ...this._config!, [name]: newVal };
}
this._dirty = true; this._dirty = true;
} }

View File

@ -176,7 +176,11 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
${this.narrow ${this.narrow
? html` <span slot="header">${this._config?.alias}</span> ` ? html` <span slot="header">${this._config?.alias}</span> `
: ""} : ""}
<div class="content"> <div
class="content ${classMap({
"yaml-mode": this._mode === "yaml",
})}"
>
${this._errors ${this._errors
? html` <div class="errors">${this._errors}</div> ` ? html` <div class="errors">${this._errors}</div> `
: ""} : ""}
@ -350,28 +354,15 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
` `
: this._mode === "yaml" : this._mode === "yaml"
? html` ? html`
<ha-config-section vertical .isWide=${false}>
${!this.narrow ${!this.narrow
? html`<span slot="header">${this._config?.alias}</span>`
: ``}
<ha-card>
<div class="card-content">
<ha-yaml-editor
.defaultValue=${this._preprocessYaml()}
@value-changed=${this._yamlChanged}
></ha-yaml-editor>
<mwc-button @click=${this._copyYaml}>
${this.hass.localize(
"ui.panel.config.automation.editor.copy_to_clipboard"
)}
</mwc-button>
</div>
${this.scriptEntityId
? html` ? html`
<ha-card
><div class="card-header">
${this._config?.alias}
</div>
<div <div
class="card-actions layout horizontal justified center" class="card-actions layout horizontal justified center"
> >
<span></span>
<mwc-button <mwc-button
@click=${this._runScript} @click=${this._runScript}
title="${this.hass.localize( title="${this.hass.localize(
@ -384,10 +375,22 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
)} )}
</mwc-button> </mwc-button>
</div> </div>
</ha-card>
` `
: ``} : ``}
<ha-yaml-editor
.defaultValue=${this._preprocessYaml()}
@value-changed=${this._yamlChanged}
></ha-yaml-editor>
<ha-card
><div class="card-actions">
<mwc-button @click=${this._copyYaml}>
${this.hass.localize(
"ui.panel.config.automation.editor.copy_to_clipboard"
)}
</mwc-button>
</div>
</ha-card> </ha-card>
</ha-config-section>
` `
: ``} : ``}
</div> </div>
@ -532,7 +535,12 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
if ((this._config![name] || "") === newVal) { if ((this._config![name] || "") === newVal) {
return; return;
} }
if (!newVal) {
delete this._config![name];
this._config = { ...this._config! };
} else {
this._config = { ...this._config!, [name]: newVal }; this._config = { ...this._config!, [name]: newVal };
}
this._dirty = true; this._dirty = true;
} }
@ -693,6 +701,22 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
.content { .content {
padding-bottom: 20px; padding-bottom: 20px;
} }
.yaml-mode {
height: 100%;
display: flex;
flex-direction: column;
padding-bottom: 0;
}
ha-yaml-editor {
flex-grow: 1;
--code-mirror-height: 100%;
min-height: 0;
}
.yaml-mode ha-card {
overflow: initial;
--ha-card-border-radius: 0;
border-bottom: 1px solid var(--divider-color);
}
span[slot="introduction"] a { span[slot="introduction"] a {
color: var(--primary-color); color: var(--primary-color);
} }

View File

@ -270,6 +270,7 @@ export class DialogAddUser extends LitElement {
css` css`
ha-dialog { ha-dialog {
--mdc-dialog-max-width: 500px; --mdc-dialog-max-width: 500px;
--dialog-z-index: 10;
} }
ha-switch { ha-switch {
margin-top: 8px; margin-top: 8px;

View File

@ -24,17 +24,21 @@ class HaPanelDevEvent extends EventsMixin(LocalizeMixin(PolymerElement)) {
return html` return html`
<style include="ha-style iron-flex iron-positioning"></style> <style include="ha-style iron-flex iron-positioning"></style>
<style> <style>
.content {
padding: 16px;
max-width: 1200px;
margin: auto;
}
:host { :host {
-ms-user-select: initial; -ms-user-select: initial;
-webkit-user-select: initial; -webkit-user-select: initial;
-moz-user-select: initial; -moz-user-select: initial;
@apply --paper-font-body1; @apply --paper-font-body1;
padding: 16px;
display: block; display: block;
} }
.ha-form { .inputs {
margin-right: 16px;
max-width: 400px; max-width: 400px;
} }
@ -42,14 +46,17 @@ class HaPanelDevEvent extends EventsMixin(LocalizeMixin(PolymerElement)) {
margin-top: 8px; margin-top: 8px;
} }
.code-editor {
margin-right: 16px;
}
.header { .header {
@apply --paper-font-title; @apply --paper-font-title;
} }
event-subscribe-card { event-subscribe-card {
display: block; display: block;
max-width: 800px; margin: 16px 16px 0 0;
margin: 16px auto;
} }
a { a {
@ -70,7 +77,7 @@ class HaPanelDevEvent extends EventsMixin(LocalizeMixin(PolymerElement)) {
)]] )]]
</a> </a>
</p> </p>
<div class="ha-form"> <div class="inputs">
<paper-input <paper-input
label="[[localize( label="[[localize(
'ui.panel.developer-tools.tabs.events.type' 'ui.panel.developer-tools.tabs.events.type'
@ -82,17 +89,20 @@ class HaPanelDevEvent extends EventsMixin(LocalizeMixin(PolymerElement)) {
<p> <p>
[[localize( 'ui.panel.developer-tools.tabs.events.data' )]] [[localize( 'ui.panel.developer-tools.tabs.events.data' )]]
</p> </p>
</div>
<div class="code-editor">
<ha-code-editor <ha-code-editor
mode="yaml" mode="yaml"
value="[[eventData]]" value="[[eventData]]"
error="[[!validJSON]]" error="[[!validJSON]]"
on-value-changed="_yamlChanged" on-value-changed="_yamlChanged"
></ha-code-editor> ></ha-code-editor>
</div>
<mwc-button on-click="fireEvent" raised disabled="[[!validJSON]]" <mwc-button on-click="fireEvent" raised disabled="[[!validJSON]]"
>[[localize( 'ui.panel.developer-tools.tabs.events.fire_event' >[[localize( 'ui.panel.developer-tools.tabs.events.fire_event'
)]]</mwc-button )]]</mwc-button
> >
</div> <event-subscribe-card hass="[[hass]]"></event-subscribe-card>
</div> </div>
<div> <div>
@ -106,7 +116,6 @@ class HaPanelDevEvent extends EventsMixin(LocalizeMixin(PolymerElement)) {
></events-list> ></events-list>
</div> </div>
</div> </div>
<event-subscribe-card hass="[[hass]]"></event-subscribe-card>
`; `;
} }
@ -185,7 +194,7 @@ class HaPanelDevEvent extends EventsMixin(LocalizeMixin(PolymerElement)) {
} }
computeFormClasses(narrow) { computeFormClasses(narrow) {
return narrow ? "" : "layout horizontal"; return narrow ? "content" : "content layout horizontal";
} }
} }

View File

@ -122,19 +122,23 @@ class EventSubscribeCard extends LitElement {
return css` return css`
form { form {
display: block; display: block;
padding: 16px; padding: 0 0 16px 16px;
} }
paper-input { paper-input {
display: inline-block; display: inline-block;
width: 200px; width: 200px;
} }
mwc-button {
vertical-align: middle;
}
.events { .events {
margin: -16px 0; margin: -16px 0;
padding: 0 16px; padding: 0 16px;
} }
.event { .event {
border-bottom: 1px solid var(--divider-color); border-top: 1px solid var(--divider-color);
padding-bottom: 16px; padding-top: 8px;
padding-bottom: 8px;
margin: 16px 0; margin: 16px 0;
} }
.event:last-child { .event:last-child {

View File

@ -1,3 +1,5 @@
import { mdiHelpCircle } from "@mdi/js";
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
import { safeLoad } from "js-yaml"; import { safeLoad } from "js-yaml";
import { import {
css, css,
@ -22,12 +24,18 @@ import "../../../components/ha-service-control";
import "../../../components/ha-service-picker"; import "../../../components/ha-service-picker";
import "../../../components/ha-yaml-editor"; import "../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../components/ha-yaml-editor"; import type { HaYamlEditor } from "../../../components/ha-yaml-editor";
import { forwardHaptic } from "../../../data/haptics";
import { ServiceAction } from "../../../data/script"; import { ServiceAction } from "../../../data/script";
import { callExecuteScript } from "../../../data/service"; import {
callExecuteScript,
serviceCallWillDisconnect,
} from "../../../data/service";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import "../../../styles/polymer-ha-style"; import "../../../styles/polymer-ha-style";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import "../../../util/app-localstorage-document"; import "../../../util/app-localstorage-document";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
class HaPanelDevService extends LitElement { class HaPanelDevService extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -156,12 +164,39 @@ class HaPanelDevService extends LitElement {
outlined outlined
.expanded=${this._yamlMode} .expanded=${this._yamlMode}
> >
${this._yamlMode && target ${this._yamlMode
? html`<h3> ? html` <div class="description">
<h3>
${target
? html`
${this.hass.localize( ${this.hass.localize(
"ui.panel.developer-tools.tabs.services.accepts_target" "ui.panel.developer-tools.tabs.services.accepts_target"
)} )}
</h3>` `
: ""}
</h3>
${this._serviceData?.service
? html` <a
href="${documentationUrl(
this.hass,
"/integrations/" +
computeDomain(this._serviceData?.service)
)}"
title="${this.hass.localize(
"ui.components.service-control.integration_doc"
)}"
target="_blank"
rel="noreferrer"
>
<mwc-icon-button>
<ha-svg-icon
path=${mdiHelpCircle}
class="help-icon"
></ha-svg-icon>
</mwc-icon-button>
</a>`
: ""}
</div>`
: ""} : ""}
<table class="attributes"> <table class="attributes">
<tr> <tr>
@ -267,11 +302,30 @@ class HaPanelDevService extends LitElement {
} }
); );
private _callService() { private async _callService() {
if (!this._serviceData?.service) { if (!this._serviceData?.service) {
return; return;
} }
callExecuteScript(this.hass, [this._serviceData]); try {
await callExecuteScript(this.hass, [this._serviceData]);
} catch (err) {
const [domain, service] = this._serviceData.service.split(".", 2);
if (
err.error?.code === ERR_CONNECTION_LOST &&
serviceCallWillDisconnect(domain, service)
) {
return;
}
forwardHaptic("failure");
showToast(this, {
message:
this.hass.localize(
"ui.notification_toast.service_call_failed",
"service",
this._serviceData.service
) + ` ${err.message}`,
});
}
} }
private _toggleYaml() { private _toggleYaml() {
@ -394,6 +448,15 @@ class HaPanelDevService extends LitElement {
padding: 4px; padding: 4px;
vertical-align: middle; vertical-align: middle;
} }
.help-icon {
color: var(--secondary-text-color);
}
.description {
justify-content: space-between;
display: flex;
align-items: center;
}
`, `,
]; ];
} }

View File

@ -28,7 +28,7 @@ import { stateIcon } from "../../../common/entity/state_icon";
import { isValidEntityId } from "../../../common/entity/valid_entity_id"; import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import { iconColorCSS } from "../../../common/style/icon_color_css"; import { iconColorCSS } from "../../../common/style/icon_color_css";
import "../../../components/ha-card"; import "../../../components/ha-card";
import { LightEntity } from "../../../data/light"; import { getLightRgbColor, LightEntity } from "../../../data/light";
import { ActionHandlerEvent } from "../../../data/lovelace"; import { ActionHandlerEvent } from "../../../data/lovelace";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive"; import { actionHandler } from "../common/directives/action-handler-directive";
@ -301,14 +301,14 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
} }
private _computeColor(stateObj: HassEntity | LightEntity): string { private _computeColor(stateObj: HassEntity | LightEntity): string {
if (!stateObj.attributes.hs_color || !this._config?.state_color) { if (
!this._config?.state_color ||
computeStateDomain(stateObj) !== "light"
) {
return ""; return "";
} }
const [hue, sat] = stateObj.attributes.hs_color; const rgb = getLightRgbColor(stateObj as LightEntity);
if (sat <= 10) { return rgb ? `rgb(${rgb.slice(0, 3).join(",")})` : "";
return "";
}
return `hsl(${hue}, 100%, ${100 - sat / 2}%)`;
} }
private _handleAction(ev: ActionHandlerEvent) { private _handleAction(ev: ActionHandlerEvent) {

View File

@ -31,11 +31,18 @@ export class HuiErrorCard extends LitElement implements LovelaceCard {
return html``; return html``;
} }
let dumped: string | undefined;
if (this._config.origConfig) {
try {
dumped = safeDump(this._config.origConfig);
} catch (err) {
dumped = `[Error dumping ${this._config.origConfig}]`;
}
}
return html` return html`
${this._config.error} ${this._config.error}${dumped ? html`<pre>${dumped}</pre>` : ""}
${this._config.origConfig
? html`<pre>${safeDump(this._config.origConfig)}</pre>`
: ""}
`; `;
} }

View File

@ -18,11 +18,14 @@ import { fireEvent } from "../../../common/dom/fire_event";
import { computeStateDisplay } from "../../../common/entity/compute_state_display"; import { computeStateDisplay } from "../../../common/entity/compute_state_display";
import { computeStateName } from "../../../common/entity/compute_state_name"; import { computeStateName } from "../../../common/entity/compute_state_name";
import { stateIcon } from "../../../common/entity/state_icon"; import { stateIcon } from "../../../common/entity/state_icon";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import { UNAVAILABLE, UNAVAILABLE_STATES } from "../../../data/entity"; import { UNAVAILABLE, UNAVAILABLE_STATES } from "../../../data/entity";
import { LightEntity, SUPPORT_BRIGHTNESS } from "../../../data/light"; import {
getLightRgbColor,
LightEntity,
lightSupportsDimming,
} from "../../../data/light";
import { ActionHandlerEvent } from "../../../data/lovelace"; import { ActionHandlerEvent } from "../../../data/lovelace";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive"; import { actionHandler } from "../common/directives/action-handler-directive";
@ -121,17 +124,14 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
@value-changing=${this._dragEvent} @value-changing=${this._dragEvent}
@value-changed=${this._setBrightness} @value-changed=${this._setBrightness}
style=${styleMap({ style=${styleMap({
visibility: supportsFeature(stateObj, SUPPORT_BRIGHTNESS) visibility: lightSupportsDimming(stateObj)
? "visible" ? "visible"
: "hidden", : "hidden",
})} })}
></round-slider> ></round-slider>
<ha-icon-button <ha-icon-button
class="light-button ${classMap({ class="light-button ${classMap({
"slider-center": supportsFeature( "slider-center": lightSupportsDimming(stateObj),
stateObj,
SUPPORT_BRIGHTNESS
),
"state-on": stateObj.state === "on", "state-on": stateObj.state === "on",
"state-unavailable": stateObj.state === UNAVAILABLE, "state-unavailable": stateObj.state === UNAVAILABLE,
})}" })}"
@ -244,14 +244,11 @@ export class HuiLightCard extends LitElement implements LovelaceCard {
} }
private _computeColor(stateObj: LightEntity): string { private _computeColor(stateObj: LightEntity): string {
if (stateObj.state === "off" || !stateObj.attributes.hs_color) { if (stateObj.state === "off") {
return ""; return "";
} }
const [hue, sat] = stateObj.attributes.hs_color; const rgb = getLightRgbColor(stateObj);
if (sat <= 10) { return rgb ? `rgb(${rgb.slice(0, 3).join(",")})` : "";
return "";
}
return `hsl(${hue}, 100%, ${100 - sat / 2}%)`;
} }
private _handleAction(ev: ActionHandlerEvent) { private _handleAction(ev: ActionHandlerEvent) {

Some files were not shown because too many files have changed in this diff Show More