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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -73,7 +73,7 @@ class SupervisorMetric extends LitElement {
);
}
.value {
width: 42px;
width: 48px;
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
// where we are listening for it. However, the older panel_custom will
// listen on this element for navigation events, so we need to forward them.
window.addEventListener("location-changed", (ev) =>
// Joakim - April 26, 2021
// Due to changes in behavior in Google Chrome, we changed navigate to fire on the top element
top.addEventListener("location-changed", (ev) =>
// @ts-ignore
fireEvent(this, ev.type, ev.detail, {
bubbles: false,

View File

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

View File

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

View File

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

View File

@ -8,6 +8,7 @@ import {
PropertyValues,
} from "lit-element";
import punycode from "punycode";
import { applyThemesOnElement } from "../common/dom/apply_themes_on_element";
import { extractSearchParamsObject } from "../common/url/search-params";
import {
AuthProvider,
@ -116,6 +117,20 @@ class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
this._fetchAuthProviders();
this._fetchDiscoveryInfo();
if (matchMedia("(prefers-color-scheme: dark)").matches) {
applyThemesOnElement(
document.documentElement,
{
default_theme: "default",
default_dark_theme: null,
themes: {},
darkMode: false,
},
"default",
{ dark: true }
);
}
if (!this.redirectUri) {
return;
}

View File

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

View File

@ -102,3 +102,18 @@ export const lab2hex = (lab: [number, number, number]): string => {
const rgb = lab2rgb(lab);
return rgb2hex(rgb);
};
export const rgb2hsv = (
rgb: [number, number, number]
): [number, number, number] => {
const [r, g, b] = rgb;
const v = Math.max(r, g, b);
const c = v - Math.min(r, g, b);
const h =
c && (v === r ? (g - b) / c : v === g ? 2 + (b - r) / c : 4 + (r - g) / c);
return [60 * (h < 0 ? h + 6 : h), v && c / v, v];
};
export const rgb2hs = (rgb: [number, number, number]): [number, number] => {
return rgb2hsv(rgb).slice(0, 2) as [number, number];
};

View File

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

View File

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

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.
*/
export const fuzzySequentialMatch = (filter: string, ...words: string[]) => {
export const fuzzySequentialMatch = (
filter: string,
item: ScorableTextItem
) => {
let topScore = Number.NEGATIVE_INFINITY;
for (const word of words) {
for (const word of item.strings) {
const scores = fuzzyScore(
filter,
filter.toLowerCase(),
@ -28,13 +31,9 @@ export const fuzzySequentialMatch = (filter: string, ...words: string[]) => {
continue;
}
// The VS Code implementation of filter returns a:
// - Negative score for a good match that starts in the middle of the string
// - Positive score if the match starts at the beginning of the string
// - 0 if the filter string is just barely a match
// - undefined for no match
// The "0" return is problematic since .filter() will remove that match, even though a 0 == good match.
// So, if we encounter a 0 return, set it to 1 so the match will be included, and still respect ordering.
// The VS Code implementation of filter returns a 0 for a weak match.
// But if .filter() sees a "0", it considers that a failed match and will remove it.
// So, we set score to 1 in these cases so the match will be included, and mostly respect correct ordering.
const score = scores[0] === 0 ? 1 : scores[0];
if (score > topScore) {
@ -49,10 +48,22 @@ export const fuzzySequentialMatch = (filter: string, ...words: string[]) => {
return topScore;
};
/**
* An interface that objects must extend in order to use the fuzzy sequence matcher
*
* @param {number} score - A number representing the existence and strength of a match.
* - `< 0` means a good match that starts in the middle of the string
* - `> 0` means a good match that starts at the beginning of the string
* - `0` means just barely a match
* - `undefined` means not a match
*
* @param {string} strings - Array of strings (aliases) representing the item. The filter string will be compared against each of these for a match.
*
*/
export interface ScorableTextItem {
score?: number;
filterText: string;
altText?: string;
strings: string[];
}
type FuzzyFilterSort = <T extends ScorableTextItem>(
@ -63,9 +74,7 @@ type FuzzyFilterSort = <T extends ScorableTextItem>(
export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) => {
return items
.map((item) => {
item.score = item.altText
? fuzzySequentialMatch(filter, item.filterText, item.altText)
: fuzzySequentialMatch(filter, item.filterText);
item.score = fuzzySequentialMatch(filter, item);
return item;
})
.filter((item) => item.score !== undefined)

View File

@ -58,7 +58,7 @@ export const formatNumber = (
).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));
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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;
title: string;
source: string;
state: string;
state:
| "loaded"
| "setup_error"
| "migration_error"
| "setup_retry"
| "not_loaded"
| "failed_unload";
connection_class: string;
supports_options: boolean;
supports_unload: boolean;
disabled_by: string | null;
disabled_by: "user" | null;
reason: string | null;
}
export interface ConfigEntryMutableParams {

View File

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

View File

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

View File

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

View File

@ -15,7 +15,18 @@ export interface IntegrationManifest {
ssdp?: Array<{ manufacturer?: string; modelName?: string; st?: string }>;
zeroconf?: string[];
homekit?: { models: string[] };
quality_scale?: string;
quality_scale?: "gold" | "internal" | "platinum" | "silver";
iot_class:
| "assumed_state"
| "cloud_polling"
| "cloud_push"
| "local_polling"
| "local_push";
}
export interface IntegrationSetup {
domain: string;
seconds?: number;
}
export const integrationIssuesUrl = (
@ -38,3 +49,6 @@ export const fetchIntegrationManifest = (
hass: HomeAssistant,
integration: string
) => hass.callWS<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,
} from "home-assistant-js-websocket";
export enum LightColorModes {
UNKNOWN = "unknown",
ONOFF = "onoff",
BRIGHTNESS = "brightness",
COLOR_TEMP = "color_temp",
HS = "hs",
XY = "xy",
RGB = "rgb",
RGBW = "rgbw",
RGBWW = "rgbww",
}
const modesSupportingColor = [
LightColorModes.HS,
LightColorModes.XY,
LightColorModes.RGB,
LightColorModes.RGBW,
LightColorModes.RGBWW,
];
const modesSupportingDimming = [
...modesSupportingColor,
LightColorModes.COLOR_TEMP,
LightColorModes.BRIGHTNESS,
];
export const SUPPORT_EFFECT = 4;
export const SUPPORT_FLASH = 8;
export const SUPPORT_TRANSITION = 32;
export const lightSupportsColorMode = (
entity: LightEntity,
mode: LightColorModes
) => {
return entity.attributes.supported_color_modes?.includes(mode);
};
export const lightIsInColorMode = (entity: LightEntity) => {
return modesSupportingColor.includes(entity.attributes.color_mode);
};
export const lightSupportsColor = (entity: LightEntity) => {
return entity.attributes.supported_color_modes?.some((mode) =>
modesSupportingColor.includes(mode)
);
};
export const lightSupportsDimming = (entity: LightEntity) => {
return entity.attributes.supported_color_modes?.some((mode) =>
modesSupportingDimming.includes(mode)
);
};
export const getLightRgbColor = (entity: LightEntity): number[] | undefined =>
entity.attributes.color_mode === LightColorModes.RGBWW
? entity.attributes.rgbww_color
: entity.attributes.color_mode === LightColorModes.RGBW
? entity.attributes.rgbw_color
: entity.attributes.rgb_color;
interface LightEntityAttributes extends HassEntityAttributeBase {
min_mireds: number;
max_mireds: number;
friendly_name: string;
brightness: number;
hs_color: number[];
hs_color: [number, number];
rgb_color: [number, number, number];
rgbw_color: [number, number, number, number];
rgbww_color: [number, number, number, number, number];
color_temp: number;
white_value: number;
effect?: string;
effect_list: string[] | null;
supported_color_modes: LightColorModes[];
color_mode: LightColorModes;
}
export interface LightEntity extends HassEntityBase {
attributes: LightEntityAttributes;
}
export const SUPPORT_BRIGHTNESS = 1;
export const SUPPORT_COLOR_TEMP = 2;
export const SUPPORT_EFFECT = 4;
export const SUPPORT_FLASH = 8;
export const SUPPORT_COLOR = 16;
export const SUPPORT_TRANSITION = 32;
export const SUPPORT_WHITE_VALUE = 128;

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { HassEntity } from "home-assistant-js-websocket";
import { HaFormSchema } from "../components/ha-form/ha-form";
import { HomeAssistant } from "../types";
export interface ZHAEntityReference extends HassEntity {
@ -54,6 +55,52 @@ export interface Cluster {
type: string;
}
export interface ClusterConfigurationData {
cluster_name: string;
cluster_id: number;
success: boolean;
}
export interface ClusterAttributeData {
cluster_name: string;
cluster_id: number;
attributes: AttributeConfigurationStatus[];
}
export interface AttributeConfigurationStatus {
id: number;
name: string;
success: boolean | undefined;
min: number;
max: number;
change: number;
}
export interface ClusterConfigurationStatus {
cluster: Cluster;
bindSuccess: boolean | undefined;
attributes: Map<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 {
name: string;
id: number;
@ -75,6 +122,11 @@ export interface ZHAGroup {
members: ZHADeviceEndpoint[];
}
export interface ZHAConfiguration {
data: Record<string, Record<string, unknown>>;
schemas: Record<string, HaFormSchema[]>;
}
export interface ZHAGroupMember {
ieee: string;
endpoint_id: string;
@ -83,10 +135,10 @@ export interface ZHAGroupMember {
export const reconfigureNode = (
hass: HomeAssistant,
ieeeAddress: string,
callbackFunction: any
callbackFunction: (message: ClusterConfigurationEvent) => void
) => {
return hass.connection.subscribeMessage(
(message) => callbackFunction(message),
(message: ClusterConfigurationEvent) => callbackFunction(message),
{
type: "zha/devices/reconfigure",
ieee: ieeeAddress,
@ -282,6 +334,22 @@ export const addGroup = (
members: membersToAdd,
});
export const fetchZHAConfiguration = (
hass: HomeAssistant
): Promise<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 INTERVIEW_COMPLETE = "INTERVIEW_COMPLETE";
export const CONFIGURED = "CONFIGURED";
@ -301,3 +369,7 @@ export const DEVICE_MESSAGE_TYPES = [
DEVICE_FULLY_INITIALIZED,
];
export const LOG_OUTPUT = "log_output";
export const ZHA_CHANNEL_MSG = "zha_channel_message";
export const ZHA_CHANNEL_MSG_BIND = "zha_channel_bind";
export const ZHA_CHANNEL_MSG_CFG_RPT = "zha_channel_configure_reporting";
export const ZHA_CHANNEL_CFG_DONE = "zha_channel_cfg_done";

View File

@ -1,3 +1,4 @@
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { HomeAssistant } from "../types";
import { DeviceRegistryEntry } from "./device_registry";
@ -29,6 +30,10 @@ export interface ZWaveJSNode {
}
export interface ZWaveJSNodeConfigParams {
[key: string]: ZWaveJSNodeConfigParam;
}
export interface ZWaveJSNodeConfigParam {
property: number;
value: any;
configuration_value_type: string;
@ -56,6 +61,22 @@ export interface ZWaveJSSetConfigParamData {
value: string | number;
}
export interface ZWaveJSSetConfigParamResult {
value_id?: string;
status?: string;
error?: string;
}
export interface ZWaveJSDataCollectionStatus {
enabled: boolean;
opted_in: boolean;
}
export interface ZWaveJSRefreshNodeStatusMessage {
event: string;
stage?: string;
}
export enum NodeStatus {
Unknown,
Asleep,
@ -75,6 +96,26 @@ export const fetchNetworkStatus = (
entry_id,
});
export const fetchDataCollectionStatus = (
hass: HomeAssistant,
entry_id: string
): Promise<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 = (
hass: HomeAssistant,
entry_id: string,
@ -90,7 +131,7 @@ export const fetchNodeConfigParameters = (
hass: HomeAssistant,
entry_id: string,
node_id: number
): Promise<ZWaveJSNodeConfigParams[]> =>
): Promise<ZWaveJSNodeConfigParams> =>
hass.callWS({
type: "zwave_js/get_config_parameters",
entry_id,
@ -104,7 +145,7 @@ export const setNodeConfigParameter = (
property: number,
value: number,
property_key?: number
): Promise<unknown> => {
): Promise<ZWaveJSSetConfigParamResult> => {
const data: ZWaveJSSetConfigParamData = {
type: "zwave_js/set_config_parameter",
entry_id,
@ -116,9 +157,25 @@ export const setNodeConfigParameter = (
return hass.callWS(data);
};
export const getIdentifiersFromDevice = function (
export const reinterviewNode = (
hass: HomeAssistant,
entry_id: string,
node_id: number,
callbackFunction: (message: ZWaveJSRefreshNodeStatusMessage) => void
): Promise<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
): ZWaveJSNodeIdentifiers | undefined {
): ZWaveJSNodeIdentifiers | undefined => {
if (!device) {
return undefined;
}
@ -136,3 +193,48 @@ export const getIdentifiersFromDevice = function (
home_id: identifiers[0],
};
};
export interface ZWaveJSLogMessage {
timestamp: string;
level: string;
primary_tags: string;
message: string | string[];
}
export const subscribeZWaveJSLogs = (
hass: HomeAssistant,
entry_id: string,
callback: (message: ZWaveJSLogMessage) => void
) =>
hass.connection.subscribeMessage<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,
dialogTag: string,
dialogParams: unknown,
dialogImport?: () => Promise<unknown>
dialogImport?: () => Promise<unknown>,
addHistory = true
) => {
if (!(dialogTag in LOADED)) {
if (!dialogImport) {
@ -59,36 +60,37 @@ export const showDialog = async (
});
}
history.replaceState(
{
dialog: dialogTag,
open: false,
oldState:
history.state?.open && history.state?.dialog !== dialogTag
? history.state
: null,
},
""
);
try {
history.pushState(
{ dialog: dialogTag, dialogParams: dialogParams, open: true },
""
);
} catch (err) {
// dialogParams could not be cloned, probably contains callback
history.pushState(
{ dialog: dialogTag, dialogParams: null, open: true },
if (addHistory) {
top.history.replaceState(
{
dialog: dialogTag,
open: false,
oldState:
top.history.state?.open && top.history.state?.dialog !== dialogTag
? top.history.state
: null,
},
""
);
try {
top.history.pushState(
{ dialog: dialogTag, dialogParams: dialogParams, open: true },
""
);
} catch (err) {
// dialogParams could not be cloned, probably contains callback
top.history.pushState(
{ dialog: dialogTag, dialogParams: null, open: true },
""
);
}
}
const dialogElement = await LOADED[dialogTag];
dialogElement.showDialog(dialogParams);
};
export const replaceDialog = () => {
history.replaceState({ ...history.state, replaced: true }, "");
top.history.replaceState({ ...top.history.state, replaced: true }, "");
};
export const closeDialog = async (dialogTag: string): Promise<boolean> => {

View File

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

View File

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

View File

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

View File

@ -81,9 +81,27 @@ class LightEntity extends Entity {
if (service === "turn_on") {
// eslint-disable-next-line
let { brightness, hs_color, brightness_pct } = data;
brightness = (255 * brightness_pct) / 100;
this.update("on", { ...this.attributes, brightness, hs_color });
let { hs_color, brightness_pct, rgb_color, color_temp } = data;
const attrs = { ...this.attributes };
if (brightness_pct) {
attrs.brightness = (255 * brightness_pct) / 100;
} else if (!attrs.brightness) {
attrs.brightness = 255;
}
if (hs_color) {
attrs.color_mode = "hs";
attrs.hs_color = hs_color;
}
if (rgb_color) {
attrs.color_mode = "rgb";
attrs.rgb_color = rgb_color;
}
if (color_temp) {
attrs.color_mode = "color_temp";
attrs.color_temp = color_temp;
delete attrs.rgb_color;
}
this.update("on", attrs);
} else if (service === "turn_off") {
this.update("off");
} else if (service === "toggle") {

View File

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

View File

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

View File

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

View File

@ -34,17 +34,8 @@
@media (prefers-color-scheme: dark) {
html {
color: #e1e1e1;
}
ha-onboarding {
--primary-text-color: #e1e1e1;
--secondary-text-color: #9b9b9b;
--disabled-text-color: #6f6f6f;
--mdc-theme-surface: #1e1e1e;
--ha-card-background: #1e1e1e;
}
.content {
background-color: #111111;
color: #e1e1e1;
}
}

View File

@ -32,6 +32,7 @@ import { registerServiceWorker } from "../util/register-service-worker";
import "./onboarding-create-user";
import "./onboarding-loading";
import "./onboarding-analytics";
import { applyThemesOnElement } from "../common/dom/apply_themes_on_element";
type OnboardingEvent =
| {
@ -137,6 +138,19 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
if (window.innerWidth > 450) {
import("./particles");
}
if (matchMedia("(prefers-color-scheme: dark)").matches) {
applyThemesOnElement(
document.documentElement,
{
default_theme: "default",
default_dark_theme: null,
themes: {},
darkMode: false,
},
"default",
{ dark: true }
);
}
}
protected updated(changedProps: PropertyValues) {

View File

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

View File

@ -22,12 +22,16 @@ import {
import {
computeDeviceName,
DeviceRegistryEntry,
devicesInArea,
} from "../../../data/device_registry";
import {
computeEntityRegistryName,
EntityRegistryEntry,
} from "../../../data/entity_registry";
import { findRelated, RelatedResult } from "../../../data/search";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types";
import { showEntityEditorDialog } from "../entities/show-dialog-entity-editor";
import { configSections } from "../ha-panel-config";
import {
loadAreaRegistryDetailDialog,
@ -44,6 +48,8 @@ class HaConfigAreaPage extends LitElement {
@property() public devices!: DeviceRegistryEntry[];
@property() public entities!: EntityRegistryEntry[];
@property({ type: Boolean, reflect: true }) public narrow!: boolean;
@property() public isWide!: boolean;
@ -58,9 +64,39 @@ class HaConfigAreaPage extends LitElement {
| AreaRegistryEntry
| undefined => areas.find((area) => area.area_id === areaId));
private _devices = memoizeOne(
(areaId: string, devices: DeviceRegistryEntry[]): DeviceRegistryEntry[] =>
devicesInArea(devices, areaId)
private _memberships = memoizeOne(
(
areaId: string,
registryDevices: DeviceRegistryEntry[],
registryEntities: EntityRegistryEntry[]
) => {
const devices = new Map();
for (const device of registryDevices) {
if (device.area_id === areaId) {
devices.set(device.id, device);
}
}
const entities: EntityRegistryEntry[] = [];
const indirectEntities: EntityRegistryEntry[] = [];
for (const entity of registryEntities) {
if (entity.area_id) {
if (entity.area_id === areaId) {
entities.push(entity);
}
} else if (devices.has(entity.device_id)) {
indirectEntities.push(entity);
}
}
return {
devices: Array.from(devices.values()),
entities,
indirectEntities,
};
}
);
protected firstUpdated(changedProps) {
@ -87,7 +123,11 @@ class HaConfigAreaPage extends LitElement {
`;
}
const devices = this._devices(this.areaId, this.devices);
const { devices, entities } = this._memberships(
this.areaId,
this.devices,
this.entities
);
return html`
<hass-tabs-subpage
@ -144,6 +184,33 @@ class HaConfigAreaPage extends LitElement {
>
`}
</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 class="column">
${isComponentLoaded(this.hass, "automation")
@ -299,6 +366,14 @@ class HaConfigAreaPage extends LitElement {
this._openDialog(entry);
}
private _openEntity(ev) {
const entry: EntityRegistryEntry = (ev.currentTarget as any).entity;
showEntityEditorDialog(this, {
entity_id: entry.entity_id,
entry,
});
}
private _openDialog(entry?: AreaRegistryEntry) {
showAreaRegistryDetailDialog(this, {
entry,

View File

@ -24,10 +24,8 @@ import {
AreaRegistryEntry,
createAreaRegistryEntry,
} from "../../../data/area_registry";
import {
DeviceRegistryEntry,
devicesInArea,
} from "../../../data/device_registry";
import type { DeviceRegistryEntry } from "../../../data/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity_registry";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage-data-table";
@ -53,12 +51,39 @@ export class HaConfigAreasDashboard extends LitElement {
@property() public devices!: DeviceRegistryEntry[];
@property() public entities!: EntityRegistryEntry[];
private _areas = memoizeOne(
(areas: AreaRegistryEntry[], devices: DeviceRegistryEntry[]) => {
(
areas: AreaRegistryEntry[],
devices: DeviceRegistryEntry[],
entities: EntityRegistryEntry[]
) => {
return areas.map((area) => {
const devicesInArea = new Set();
for (const device of devices) {
if (device.area_id === area.area_id) {
devicesInArea.add(device.id);
}
}
let entitiesInArea = 0;
for (const entity of entities) {
if (
entity.area_id
? entity.area_id === area.area_id
: devicesInArea.has(entity.device_id)
) {
entitiesInArea++;
}
}
return {
...area,
devices: devicesInArea(devices, area.area_id).length,
devices: devicesInArea.size,
entities: entitiesInArea,
};
});
}
@ -97,6 +122,15 @@ export class HaConfigAreasDashboard extends LitElement {
width: "20%",
direction: "asc",
},
entities: {
title: this.hass.localize(
"ui.panel.config.areas.data_table.entities"
),
sortable: true,
type: "numeric",
width: "20%",
direction: "asc",
},
}
);
@ -110,7 +144,7 @@ export class HaConfigAreasDashboard extends LitElement {
.tabs=${configSections.integrations}
.route=${this.route}
.columns=${this._columns(this.narrow)}
.data=${this._areas(this.areas, this.devices)}
.data=${this._areas(this.areas, this.devices, this.entities)}
@row-click=${this._handleRowClicked}
.noDataText=${this.hass.localize(
"ui.panel.config.areas.picker.no_areas"

View File

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

View File

@ -99,33 +99,63 @@ export class HaAutomationTracePathDetails extends LitElement {
return "This node was not executed and so no further trace information is available.";
}
const data: ActionTraceStep[] = paths[this.selected.path];
const parts: TemplateResult[][] = [];
return data.map((trace, idx) => {
const {
path,
timestamp,
result,
error,
changed_variables,
...rest
} = trace as any;
let active = false;
const childConditionsPrefix = `${this.selected.path}/conditions/`;
return html`
${data.length === 1 ? "" : html`<h3>Iteration ${idx + 1}</h3>`}
Executed:
${formatDateTimeWithSeconds(new Date(timestamp), this.hass.locale)}<br />
${result
? html`Result:
<pre>${safeDump(result)}</pre>`
: error
? html`<div class="error">Error: ${error}</div>`
: ""}
${Object.keys(rest).length === 0
? ""
: html`<pre>${safeDump(rest)}</pre>`}
`;
});
for (const curPath of Object.keys(this.trace.trace)) {
// Include all child conditions too
if (active) {
if (!curPath.startsWith(childConditionsPrefix)) {
break;
}
} else if (curPath === this.selected.path) {
active = true;
} else {
continue;
}
const data: ActionTraceStep[] = paths[curPath];
parts.push(
data.map((trace, idx) => {
const {
path,
timestamp,
result,
error,
changed_variables,
...rest
} = trace as any;
return html`
${curPath === this.selected.path
? ""
: html`<h2>
Condition ${curPath.substr(childConditionsPrefix.length)}
</h2>`}
${data.length === 1 ? "" : html`<h3>Iteration ${idx + 1}</h3>`}
Executed:
${formatDateTimeWithSeconds(
new Date(timestamp),
this.hass.locale
)}<br />
${result
? html`Result:
<pre>${safeDump(result)}</pre>`
: error
? html`<div class="error">Error: ${error}</div>`
: ""}
${Object.keys(rest).length === 0
? ""
: html`<pre>${safeDump(rest)}</pre>`}
`;
})
);
}
return parts;
}
private _renderSelectedConfig() {

View File

@ -87,12 +87,24 @@ export class HaAutomationTrace extends LitElement {
const title = stateObj?.attributes.friendly_name || this._entityId;
let devButtons: TemplateResult | string = "";
if (__DEV__) {
devButtons = html`<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`
<mwc-icon-button label="Refresh" @click=${() => this._loadTraces()}>
<ha-svg-icon .path=${mdiRefresh}></ha-svg-icon>
</mwc-icon-button>
<mwc-icon-button
.disabled=${!this._runId}
.disabled=${!this._trace}
label="Download Trace"
@click=${this._downloadTrace}
>
@ -101,6 +113,7 @@ export class HaAutomationTrace extends LitElement {
`;
return html`
${devButtons}
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
@ -410,6 +423,27 @@ export class HaAutomationTrace extends LitElement {
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) {
this._view = (ev.target as any).view;
}

View File

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

View File

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

View File

@ -79,36 +79,6 @@ class HaConfigDashboard extends LitElement {
</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
? html`
<div class="promo-advanced">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -140,7 +140,10 @@ class HaConfigInfo extends LitElement {
</div>
<div class="content">
<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>
</hass-tabs-subpage>
`;

View File

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

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 "@material/mwc-list/mwc-list-item";
import { mdiFilterVariant, mdiPlus } from "@mdi/js";
import "@polymer/app-route/app-route";
import Fuse from "fuse.js";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import {
css,
CSSResult,
@ -16,31 +15,15 @@ import {
PropertyValues,
TemplateResult,
} from "lit-element";
import { classMap } from "lit-html/directives/class-map";
import { ifDefined } from "lit-html/directives/if-defined";
import memoizeOne from "memoize-one";
import { HASSDomEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import "../../../common/search/search-input";
import { caseInsensitiveCompare } from "../../../common/string/compare";
import { LocalizeFunc } from "../../../common/translations/localize";
import { extractSearchParam } from "../../../common/url/search-params";
import { nextRender } from "../../../common/util/render-status";
import "../../../components/ha-button-menu";
import "../../../components/ha-card";
import "../../../components/ha-fab";
import "../../../components/ha-checkbox";
import "../../../components/ha-svg-icon";
import { ConfigEntry, getConfigEntries } from "../../../data/config_entries";
import {
ConfigEntry,
deleteConfigEntry,
getConfigEntries,
} from "../../../data/config_entries";
import {
ATTENTION_SOURCES,
DISCOVERY_SOURCES,
getConfigFlowInProgressCollection,
ignoreConfigFlow,
localizeConfigFlowTitle,
subscribeConfigFlowInProgress,
} from "../../../data/config_flow";
@ -55,26 +38,49 @@ import {
} from "../../../data/entity_registry";
import {
domainToName,
fetchIntegrationManifest,
fetchIntegrationManifests,
IntegrationManifest,
} from "../../../data/integration";
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { configSections } from "../ha-panel-config";
import "./ha-integration-card";
import type {
ConfigEntryRemovedEvent,
ConfigEntryUpdatedEvent,
HaIntegrationCard,
} from "./ha-integration-card";
interface DataEntryFlowProgressExtended extends DataEntryFlowProgress {
import type { HomeAssistant, Route } from "../../../types";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../common/translations/localize";
import type { HaIntegrationCard } from "./ha-integration-card";
import "../../../common/search/search-input";
import "../../../components/ha-button-menu";
import "../../../components/ha-fab";
import "../../../components/ha-checkbox";
import "../../../components/ha-svg-icon";
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-tabs-subpage";
import "./ha-integration-card";
import "./ha-config-flow-card";
import "./ha-ignored-config-entry-card";
export interface ConfigEntryUpdatedEvent {
entry: ConfigEntry;
}
export interface ConfigEntryRemovedEvent {
entryId: string;
}
declare global {
// for fire event
interface HASSDomEvents {
"entry-updated": ConfigEntryUpdatedEvent;
"entry-removed": ConfigEntryRemovedEvent;
}
}
export interface DataEntryFlowProgressExtended extends DataEntryFlowProgress {
localized_title?: string;
}
@ -119,9 +125,10 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
@internalProperty()
private _deviceRegistryEntries: DeviceRegistryEntry[] = [];
@internalProperty() private _manifests!: {
[domain: string]: IntegrationManifest;
};
@internalProperty()
private _manifests: Record<string, IntegrationManifest> = {};
private _extraFetchedManifests?: Set<string>;
@internalProperty() private _showIgnored = false;
@ -150,15 +157,14 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
this.hass.loadBackendTranslation("config", flow.handler)
);
}
this._fetchManifest(flow.handler);
});
await Promise.all(translationsPromisses);
await nextRender();
this._configEntriesInProgress = flowsInProgress.map((flow) => {
return {
...flow,
localized_title: localizeConfigFlowTitle(this.hass.localize, flow),
};
});
this._configEntriesInProgress = flowsInProgress.map((flow) => ({
...flow,
localized_title: localizeConfigFlowTitle(this.hass.localize, flow),
}));
}),
];
}
@ -217,12 +223,6 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
configEntriesInProgress: DataEntryFlowProgressExtended[],
filter?: string
): DataEntryFlowProgressExtended[] => {
configEntriesInProgress = configEntriesInProgress.map(
(flow: DataEntryFlowProgressExtended) => ({
...flow,
title: localizeConfigFlowTitle(this.hass.localize, flow),
})
);
if (!filter) {
return configEntriesInProgress;
}
@ -349,11 +349,12 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
"number",
disabledConfigEntries.size
)}
<mwc-button @click=${this._toggleShowDisabled}>
${this.hass.localize(
<mwc-button
@click=${this._toggleShowDisabled}
.label=${this.hass.localize(
"ui.panel.config.integrations.disable.show"
)}
</mwc-button>
></mwc-button>
</div>`
: ""}
${filterMenu}
@ -362,112 +363,31 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
<div
class="container"
@entry-removed=${this._handleRemoved}
@entry-updated=${this._handleUpdated}
@entry-removed=${this._handleEntryRemoved}
@entry-updated=${this._handleEntryUpdated}
>
${this._showIgnored
? ignoredConfigEntries.map(
(item: ConfigEntryExtended) => html`
<ha-card outlined class="ignored">
<div class="header">
${this.hass.localize(
"ui.panel.config.integrations.ignore.ignored"
)}
</div>
<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>
(entry: ConfigEntryExtended) => html`
<ha-ignored-config-entry-card
.hass=${this.hass}
.manifest=${this._manifests[entry.domain]}
.entry=${entry}
@change=${this._handleFlowUpdated}
></ha-ignored-config-entry-card>
`
)
: ""}
${configEntriesInProgress.length
? configEntriesInProgress.map(
(flow: DataEntryFlowProgressExtended) => {
const attention = ATTENTION_SOURCES.includes(
flow.context.source
);
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}
>
${this.hass.localize(
"ui.panel.config.integrations.ignore.ignore"
)}
</mwc-button>
`
: ""}
</div>
</div>
</ha-card>
`;
}
(flow: DataEntryFlowProgressExtended) => html`
<ha-config-flow-card
.hass=${this.hass}
.manifest=${this._manifests[flow.handler]}
.flow=${flow}
@change=${this._handleFlowUpdated}
></ha-config-flow-card>
`
)
: ""}
${this._showDisabled
@ -498,25 +418,28 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
.deviceRegistryEntries=${this._deviceRegistryEntries}
></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`
<ha-card outlined>
<div class="card-content">
<h1>
${this.hass.localize("ui.panel.config.integrations.none")}
</h1>
<p>
${this.hass.localize(
"ui.panel.config.integrations.no_integrations"
)}
</p>
<mwc-button @click=${this._createFlow} unelevated
>${this.hass.localize(
"ui.panel.config.integrations.add_integration"
)}</mwc-button
>
</div>
</ha-card>
<div class="empty-message">
<h1>
${this.hass.localize("ui.panel.config.integrations.none")}
</h1>
<p>
${this.hass.localize(
"ui.panel.config.integrations.no_integrations"
)}
</p>
<mwc-button
@click=${this._createFlow}
unelevated
.label=${this.hass.localize(
"ui.panel.config.integrations.add_integration"
)}
></mwc-button>
</div>
`
: ""}
${this._filter &&
@ -524,7 +447,7 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
!groupedConfigEntries.size &&
this._configEntries.length
? html`
<div class="none-found">
<div class="empty-message">
<h1>
${this.hass.localize(
"ui.panel.config.integrations.none_found"
@ -575,19 +498,40 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
}
private async _fetchManifests() {
const manifests = {};
const fetched = await fetchIntegrationManifests(this.hass);
// Make a copy so we can keep track of previously loaded manifests
// for discovered flows (which are not part of these results)
const manifests = { ...this._manifests };
for (const manifest of fetched) manifests[manifest.domain] = manifest;
this._manifests = manifests;
}
private _handleRemoved(ev: HASSDomEvent<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(
(entry) => entry.entry_id !== ev.detail.entryId
);
}
private _handleUpdated(ev: HASSDomEvent<ConfigEntryUpdatedEvent>) {
private _handleEntryUpdated(ev: HASSDomEvent<ConfigEntryUpdatedEvent>) {
const newEntry = ev.detail.entry;
this._configEntries = this._configEntries!.map((entry) =>
entry.entry_id === newEntry.entry_id
@ -599,6 +543,7 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
private _handleFlowUpdated() {
this._loadConfigEntries();
getConfigFlowInProgressCollection(this.hass.connection).refresh();
this._fetchManifests();
}
private _createFlow() {
@ -608,50 +553,14 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
},
showAdvanced: this.showAdvanced,
});
// For config entries. Also loading config flow ones for add integration
// For config entries. Also loading config flow ones for added integration
this.hass.loadBackendTranslation("title", undefined, true);
}
private _continueFlow(ev: Event) {
showConfigFlowDialog(this, {
continueFlowId: (ev.target! as any).flowId,
dialogClosedCallback: () => {
this._handleFlowUpdated();
},
});
}
private async _ignoreFlow(ev: Event) {
const flow = (ev.target! as any).flow;
const confirmed = await showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.integrations.ignore.confirm_ignore_title",
"name",
localizeConfigFlowTitle(this.hass.localize, flow)
),
text: this.hass!.localize(
"ui.panel.config.integrations.ignore.confirm_ignore"
),
confirmText: this.hass!.localize(
"ui.panel.config.integrations.ignore.ignore"
),
});
if (!confirmed) {
return;
}
await ignoreConfigFlow(
this.hass,
flow.flow_id,
localizeConfigFlowTitle(this.hass.localize, flow)
);
this._loadConfigEntries();
getConfigFlowInProgressCollection(this.hass.connection).refresh();
}
private _handleMenuAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
this._toggleShowIgnored();
this._showIgnored = !this._showIgnored;
break;
case 1:
this._toggleShowDisabled();
@ -659,54 +568,14 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
}
}
private _toggleShowIgnored() {
this._showIgnored = !this._showIgnored;
}
private _toggleShowDisabled() {
this._showDisabled = !this._showDisabled;
}
private async _removeIgnoredIntegration(ev: Event) {
const entry = (ev.target! as any).entry;
showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.panel.config.integrations.ignore.confirm_delete_ignore_title",
"name",
this.hass.localize(`component.${entry.domain}.title`)
),
text: this.hass!.localize(
"ui.panel.config.integrations.ignore.confirm_delete_ignore"
),
confirmText: this.hass!.localize(
"ui.panel.config.integrations.ignore.stop_ignore"
),
confirm: async () => {
const result = await deleteConfigEntry(this.hass, entry.entry_id);
if (result.require_restart) {
alert(
this.hass.localize(
"ui.panel.config.integrations.config_entry.restart_confirm"
)
);
}
this._loadConfigEntries();
},
});
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
}
private _onImageLoad(ev) {
ev.target.style.visibility = "initial";
}
private _onImageError(ev) {
ev.target.style.visibility = "hidden";
}
private async _highlightEntry() {
await nextRender();
const entryId = this._searchParms.get("config_entry")!;
@ -769,66 +638,18 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
padding: 8px 16px 16px;
margin-bottom: 64px;
}
ha-card {
.container > * {
max-width: 500px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.attention {
--ha-card-border-color: var(--error-color);
}
.attention .header {
background: var(--error-color);
color: var(--text-primary-color);
padding: 8px;
text-align: center;
}
.attention mwc-button {
--mdc-theme-primary: var(--error-color);
}
.discovered {
--ha-card-border-color: var(--primary-color);
}
.discovered .header {
background: var(--primary-color);
color: var(--text-primary-color);
padding: 8px;
text-align: center;
}
.ignored {
--ha-card-border-color: var(--light-theme-disabled-color);
}
.ignored img {
filter: grayscale(1);
}
.ignored .header {
background: var(--light-theme-disabled-color);
color: var(--text-primary-color);
padding: 8px;
text-align: center;
}
.card-content {
display: flex;
height: 100%;
margin-top: 0;
padding: 16px;
text-align: center;
flex-direction: column;
justify-content: space-between;
}
.image {
display: flex;
align-items: center;
justify-content: center;
height: 60px;
margin-bottom: 16px;
vertical-align: middle;
}
.none-found {
.empty-message {
margin: auto;
text-align: center;
}
.empty-message h1 {
margin-bottom: 0;
}
search-input.header {
display: block;
position: relative;
@ -848,27 +669,7 @@ class HaConfigIntegrations extends SubscribeMixin(LitElement) {
position: relative;
top: 2px;
}
img {
max-height: 100%;
max-width: 90%;
}
.none-found {
margin: auto;
text-align: center;
}
h1 {
margin-bottom: 0;
}
h2 {
margin-top: 0;
word-wrap: break-word;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
text-overflow: ellipsis;
white-space: normal;
}
.active-filters {
color: var(--primary-text-color);
position: relative;

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

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(
params: ZHADeviceChildrenDialogParams
): void {
public showDialog(params: ZHADeviceChildrenDialogParams): void {
this._device = params.device;
this._fetchData();
}

View File

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

View File

@ -9,6 +9,7 @@ import {
html,
LitElement,
property,
PropertyValues,
TemplateResult,
} from "lit-element";
import { computeRTL } from "../../../../../common/util/compute_rtl";
@ -20,6 +21,12 @@ import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import "../../../ha-config-section";
import "../../../../../components/ha-form/ha-form";
import {
fetchZHAConfiguration,
updateZHAConfiguration,
ZHAConfiguration,
} from "../../../../../data/zha";
export const zhaTabs: PageNavigation[] = [
{
@ -51,6 +58,15 @@ class ZHAConfigDashboard extends LitElement {
@property() public configEntryId?: string;
@property() private _configuration?: ZHAConfiguration;
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
if (this.hass) {
this._fetchConfiguration();
}
}
protected render(): TemplateResult {
return html`
<hass-tabs-subpage
@ -60,10 +76,11 @@ class ZHAConfigDashboard extends LitElement {
.tabs=${zhaTabs}
back-path="/config/integrations"
>
<ha-card header="Zigbee Network">
<div class="card-content">
In the future you can change network settings for ZHA here.
</div>
<ha-card
header=${this.hass.localize(
"ui.panel.config.zha.configuration_page.shortcuts_title"
)}
>
${this.configEntryId
? html`<div class="card-actions">
<a
@ -87,6 +104,38 @@ class ZHAConfigDashboard extends LitElement {
</div>`
: ""}
</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">
<ha-fab
.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 {
return [
haStyle,

View File

@ -159,7 +159,7 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) {
}
if (!newName && !newEntityId) {
return new Promise((resolve) => resolve());
return undefined;
}
return updateEntityRegistryEntry(this.hass!, entity.entity_id, {
@ -177,7 +177,7 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) {
});
}
private _computeEntityName(entity: EntityRegistryEntry): string {
private _computeEntityName(entity: EntityRegistryEntry): string | null {
if (this.hass.states[entity.entity_id]) {
return computeStateName(this.hass.states[entity.entity_id]);
}

View File

@ -17,8 +17,8 @@ import {
refreshTopology,
ZHADevice,
} from "../../../../../data/zha";
import "../../../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../../../types";
import "../../../../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../../../../types";
import { Network, Edge, Node, EdgeOptions } from "vis-network";
import "../../../../../common/search/search-input";
import "../../../../../components/device/ha-device-picker";
@ -29,12 +29,17 @@ import { formatAsPaddedHex } from "./functions";
import { DeviceRegistryEntry } from "../../../../../data/device_registry";
import "../../../../../components/ha-checkbox";
import type { HaCheckbox } from "../../../../../components/ha-checkbox";
import { zhaTabs } from "./zha-config-dashboard";
@customElement("zha-network-visualization-page")
export class ZHANetworkVisualizationPage extends LitElement {
@property({ type: Object }) public hass!: HomeAssistant;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public narrow!: boolean;
@property({ type: Boolean }) public isWide!: boolean;
@property()
public zoomedDeviceId?: string;
@ -133,9 +138,12 @@ export class ZHANetworkVisualizationPage extends LitElement {
protected render() {
return html`
<hass-subpage
<hass-tabs-subpage
.tabs=${zhaTabs}
.hass=${this.hass}
.narrow=${this.narrow}
.isWide=${this.isWide}
.route=${this.route}
.header=${this.hass.localize(
"ui.panel.config.zha.visualization.header"
)}
@ -172,7 +180,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
>
</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 { getSignedPath } from "../../../../../data/auth";
import {
fetchDataCollectionStatus,
fetchNetworkStatus,
fetchNodeStatus,
NodeStatus,
setDataCollectionPreference,
ZWaveJSNetwork,
ZWaveJSNode,
} from "../../../../../data/zwave_js";
@ -55,6 +57,8 @@ class ZWaveJSConfigDashboard extends LitElement {
@internalProperty() private _icon = mdiCircle;
@internalProperty() private _dataCollectionOptIn?: boolean;
protected firstUpdated() {
if (this.hass) {
this._fetchData();
@ -167,6 +171,39 @@ class ZWaveJSConfigDashboard extends LitElement {
</mwc-button>
</div>
</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}>
@ -183,11 +220,22 @@ class ZWaveJSConfigDashboard extends LitElement {
if (!this.configEntryId) {
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;
if (this._status === "connected") {
this._icon = mdiCheckCircle;
}
this._dataCollectionOptIn =
dataCollectionStatus.opted_in === true ||
dataCollectionStatus.enabled === true;
this._fetchNodeStatus();
}
@ -213,6 +261,14 @@ class ZWaveJSConfigDashboard extends LitElement {
});
}
private _dataCollectionToggled(ev) {
setDataCollectionPreference(
this.hass!,
this.configEntryId!,
ev.target.checked
);
}
private async _dumpDebugClicked() {
await this._fetchNodeStatus();
@ -321,8 +377,19 @@ class ZWaveJSConfigDashboard extends LitElement {
font-size: 1rem;
}
.card-header {
display: flex;
}
.card-header h1 {
flex: 1;
}
.card-header ha-switch {
width: 48px;
margin-top: 16px;
}
ha-card {
margin: 0 auto;
margin: 0px auto 24px;
max-width: 600px;
}

View File

@ -7,7 +7,7 @@ import { HomeAssistant } from "../../../../../types";
import { navigate } from "../../../../../common/navigate";
import { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import { mdiServerNetwork } from "@mdi/js";
import { mdiServerNetwork, mdiMathLog } from "@mdi/js";
export const configTabs: PageNavigation[] = [
{
@ -15,6 +15,11 @@ export const configTabs: PageNavigation[] = [
path: `/config/zwave_js/dashboard`,
iconPath: mdiServerNetwork,
},
{
translationKey: "ui.panel.config.zwave_js.navigation.logs",
path: `/config/zwave_js/logs`,
iconPath: mdiMathLog,
},
];
@customElement("zwave_js-config-router")
@ -41,6 +46,10 @@ class ZWaveJSConfigRouter extends HassRouterPage {
tag: "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 "@polymer/paper-item/paper-item";
import "@polymer/paper-listbox/paper-listbox";
@ -24,6 +30,7 @@ import {
fetchNodeConfigParameters,
setNodeConfigParameter,
ZWaveJSNodeConfigParams,
ZWaveJSSetConfigParamResult,
} from "../../../../../data/zwave_js";
import "../../../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../../../resources/styles";
@ -38,6 +45,13 @@ import {
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import { classMap } from "lit-html/directives/class-map";
const icons = {
accepted: mdiCheckCircle,
queued: mdiProgressClock,
error: mdiCloseCircle,
};
const getDevice = memoizeOne(
(
@ -77,7 +91,12 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
@property({ type: Array })
private _deviceRegistryEntries?: DeviceRegistryEntry[];
@internalProperty() private _config?: ZWaveJSNodeConfigParams[];
@internalProperty() private _config?: ZWaveJSNodeConfigParams;
@internalProperty() private _results: Record<
string,
ZWaveJSSetConfigParamResult
> = {};
@internalProperty() private _error?: string;
@ -178,6 +197,7 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
}
private _generateConfigBox(id, item): TemplateResult {
const result = this._results[id];
const labelAndDescription = html`
<span slot="heading">${item.metadata.label}</span>
<span slot="description">
@ -192,6 +212,26 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
)}
</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>
`;
@ -293,6 +333,7 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
}
private _switchToggled(ev) {
this.setResult(ev.target.key, undefined);
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) {
return;
}
this.setResult(ev.target.key, undefined);
this._updateConfigParameter(ev.target, Number(ev.target.selected));
}
@ -321,20 +363,41 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
if (Number(this._config![ev.target.key].value) === value) {
return;
}
this.setResult(ev.target.key, undefined);
this.debouncedUpdate(ev.target, value);
}
private _updateConfigParameter(target, value) {
private async _updateConfigParameter(target, value) {
const nodeId = getNodeId(this._device!);
setNodeConfigParameter(
this.hass,
this.configEntryId!,
nodeId!,
target.property,
value,
target.propertyKey ? target.propertyKey : undefined
);
this._config![target.key].value = value;
try {
const result = await setNodeConfigParameter(
this.hass,
this.configEntryId!,
nodeId!,
target.property,
value,
target.propertyKey ? target.propertyKey : undefined
);
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 {
@ -369,6 +432,18 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
return [
haStyle,
css`
.accepted {
color: var(--success-color);
}
.queued {
color: var(--warning-color);
}
.error {
color: var(--error-color);
}
.secondary {
color: var(--secondary-text-color);
}

View File

@ -1,5 +1,5 @@
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 {
css,
@ -21,7 +21,10 @@ import {
integrationIssuesUrl,
IntegrationManifest,
} from "../../../data/integration";
import { getLoggedErrorIntegration } from "../../../data/system_log";
import {
getLoggedErrorIntegration,
isCustomIntegrationError,
} from "../../../data/system_log";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
@ -65,6 +68,12 @@ class DialogSystemLogDetail extends LitElement {
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`
<ha-dialog open @closed=${this.closeDialog} hideActions heading=${true}>
<ha-header-bar slot="heading">
@ -86,6 +95,14 @@ class DialogSystemLogDetail extends LitElement {
<ha-svg-icon .path=${mdiContentCopy}></ha-svg-icon>
</mwc-icon-button>
</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">
<p>
Logger: ${item.name}<br />
@ -96,7 +113,7 @@ class DialogSystemLogDetail extends LitElement {
Integration: ${domainToName(this.hass.localize, integration)}
${!this._manifest ||
// Can happen with custom integrations
!this._manifest.documentation
!showDocumentation
? ""
: html`
(<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) {
try {
this._manifest = await fetchIntegrationManifest(this.hass, integration);
@ -157,7 +180,18 @@ class DialogSystemLogDetail extends LitElement {
".contents"
) 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, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
@ -167,6 +201,10 @@ class DialogSystemLogDetail extends LitElement {
return [
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: 0px;
}
a {
color: var(--primary-color);
}
@ -177,6 +215,13 @@ class DialogSystemLogDetail extends LitElement {
margin-bottom: 0;
font-family: var(--code-font-family, monospace);
}
.custom {
padding: 8px 16px;
background-color: var(--warning-color);
}
.contents {
padding: 16px;
}
.error {
color: var(--error-color);
}

View File

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

View File

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

View File

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

View File

@ -176,7 +176,11 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
${this.narrow
? html` <span slot="header">${this._config?.alias}</span> `
: ""}
<div class="content">
<div
class="content ${classMap({
"yaml-mode": this._mode === "yaml",
})}"
>
${this._errors
? html` <div class="errors">${this._errors}</div> `
: ""}
@ -350,44 +354,43 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
`
: this._mode === "yaml"
? html`
<ha-config-section vertical .isWide=${false}>
${!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`
<div
class="card-actions layout horizontal justified center"
${!this.narrow
? html`
<ha-card
><div class="card-header">
${this._config?.alias}
</div>
<div
class="card-actions layout horizontal justified center"
>
<mwc-button
@click=${this._runScript}
title="${this.hass.localize(
"ui.panel.config.script.picker.run_script"
)}"
?disabled=${this._dirty}
>
<span></span>
<mwc-button
@click=${this._runScript}
title="${this.hass.localize(
"ui.panel.config.script.picker.run_script"
)}"
?disabled=${this._dirty}
>
${this.hass.localize(
"ui.panel.config.script.picker.run_script"
)}
</mwc-button>
</div>
`
: ``}
</ha-card>
</ha-config-section>
${this.hass.localize(
"ui.panel.config.script.picker.run_script"
)}
</mwc-button>
</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>
`
: ``}
</div>
@ -532,7 +535,12 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
if ((this._config![name] || "") === newVal) {
return;
}
this._config = { ...this._config!, [name]: newVal };
if (!newVal) {
delete this._config![name];
this._config = { ...this._config! };
} else {
this._config = { ...this._config!, [name]: newVal };
}
this._dirty = true;
}
@ -693,6 +701,22 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
.content {
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 {
color: var(--primary-color);
}

View File

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

View File

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

View File

@ -122,19 +122,23 @@ class EventSubscribeCard extends LitElement {
return css`
form {
display: block;
padding: 16px;
padding: 0 0 16px 16px;
}
paper-input {
display: inline-block;
width: 200px;
}
mwc-button {
vertical-align: middle;
}
.events {
margin: -16px 0;
padding: 0 16px;
}
.event {
border-bottom: 1px solid var(--divider-color);
padding-bottom: 16px;
border-top: 1px solid var(--divider-color);
padding-top: 8px;
padding-bottom: 8px;
margin: 16px 0;
}
.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 {
css,
@ -22,12 +24,18 @@ import "../../../components/ha-service-control";
import "../../../components/ha-service-picker";
import "../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../components/ha-yaml-editor";
import { forwardHaptic } from "../../../data/haptics";
import { ServiceAction } from "../../../data/script";
import { callExecuteScript } from "../../../data/service";
import {
callExecuteScript,
serviceCallWillDisconnect,
} from "../../../data/service";
import { haStyle } from "../../../resources/styles";
import "../../../styles/polymer-ha-style";
import { HomeAssistant } from "../../../types";
import "../../../util/app-localstorage-document";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
class HaPanelDevService extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@ -156,12 +164,39 @@ class HaPanelDevService extends LitElement {
outlined
.expanded=${this._yamlMode}
>
${this._yamlMode && target
? html`<h3>
${this.hass.localize(
"ui.panel.developer-tools.tabs.services.accepts_target"
)}
</h3>`
${this._yamlMode
? html` <div class="description">
<h3>
${target
? html`
${this.hass.localize(
"ui.panel.developer-tools.tabs.services.accepts_target"
)}
`
: ""}
</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">
<tr>
@ -267,11 +302,30 @@ class HaPanelDevService extends LitElement {
}
);
private _callService() {
private async _callService() {
if (!this._serviceData?.service) {
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() {
@ -394,6 +448,15 @@ class HaPanelDevService extends LitElement {
padding: 4px;
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 { iconColorCSS } from "../../../common/style/icon_color_css";
import "../../../components/ha-card";
import { LightEntity } from "../../../data/light";
import { getLightRgbColor, LightEntity } from "../../../data/light";
import { ActionHandlerEvent } from "../../../data/lovelace";
import { HomeAssistant } from "../../../types";
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 {
if (!stateObj.attributes.hs_color || !this._config?.state_color) {
if (
!this._config?.state_color ||
computeStateDomain(stateObj) !== "light"
) {
return "";
}
const [hue, sat] = stateObj.attributes.hs_color;
if (sat <= 10) {
return "";
}
return `hsl(${hue}, 100%, ${100 - sat / 2}%)`;
const rgb = getLightRgbColor(stateObj as LightEntity);
return rgb ? `rgb(${rgb.slice(0, 3).join(",")})` : "";
}
private _handleAction(ev: ActionHandlerEvent) {

View File

@ -31,11 +31,18 @@ export class HuiErrorCard extends LitElement implements LovelaceCard {
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`
${this._config.error}
${this._config.origConfig
? html`<pre>${safeDump(this._config.origConfig)}</pre>`
: ""}
${this._config.error}${dumped ? html`<pre>${dumped}</pre>` : ""}
`;
}

View File

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

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