Compare commits

..

97 Commits

Author SHA1 Message Date
Joakim Sørensen
f69bce534a Update src/dialogs/analytics/dialog-analytics-optin.ts
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2021-04-27 16:30:52 +02:00
Joakim Sørensen
575f58bd88 Update src/dialogs/analytics/dialog-analytics-optin.ts
Co-authored-by: Charles Garwood <cgarwood@gmail.com>
2021-04-27 15:28:54 +02:00
Ludeeus
35535628fc reword 2021-04-27 11:33:39 +00:00
Ludeeus
8e018c9cfe add anonymized word 2021-04-27 11:23:53 +00:00
Ludeeus
5ae268b792 add analyticsLearnMore 2021-04-27 11:09:02 +00:00
Ludeeus
329732ac30 change button wording 2021-04-27 11:08:24 +00:00
Ludeeus
7f88bab552 Add analytics dialog 2021-04-27 11:06:25 +00:00
GitHub Action
9f3bb7f4d6 Translation update 2021-04-27 00:48:52 +00:00
Charles Garwood
73bb346c00 Show feedback for setting Z-Wave JS config parameters (#8956) 2021-04-27 01:20:23 +02:00
Philip Allgaier
33703a3b53 Add link to integration docs from service control (#8290)
* Add link to integration help to dev tool services

* Adjust to new service control

* Update src/translations/en.json

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Make icon less noticable + correct translation

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2021-04-27 00:28:22 +02:00
Charles Garwood
b7a4f97eca Add opt-in toggle for zwave-js telemetry to config panel (#8958) 2021-04-27 00:19:48 +02:00
Bram Kragten
dd4efe0f51 Apply dark style on init when prefers-color-scheme: dark (#8997) 2021-04-26 14:54:47 -07:00
Bram Kragten
7e0522c3b3 Don't do migration of service data in public prop (#8949)
Fixes #8879
2021-04-26 14:52:18 -07:00
Franck Nijhof
e682abfb75 Tweak inputs for GitHub issue form (#8999) 2021-04-26 23:48:21 +02:00
Paulus Schoutsen
24e202a3d7 Use translations for config entry reason (#8981) 2021-04-26 17:50:23 +02:00
David F. Mulcahey
ac9a881ab5 Fix ZHA network visualization page navigation (#8994)
* Fix ZHA visualization page navigation

* Update src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2021-04-26 10:48:11 -04:00
Joakim Sørensen
4d287a1f83 Use top.history in dialogs and navigate (#8995) 2021-04-26 16:41:30 +02:00
Paulus Schoutsen
b8d6b1ebdd Fetch manifests for discovered flows (#8987) 2021-04-26 07:33:00 -07:00
David F. Mulcahey
8ca1b9320d Initial custom configuration for ZHA (#8737) 2021-04-26 16:25:02 +02:00
Philip Allgaier
cba3992d2b Make "Events" dev tools use screen space better (#7449) 2021-04-26 12:09:50 +02:00
Paulus Schoutsen
96d6e337be Document last step (#8979)
Co-authored-by: Philip Allgaier <mail@spacegaier.de>
2021-04-26 12:02:56 +02:00
GitHub Action
959f7ae046 Translation update 2021-04-25 00:48:30 +00:00
GitHub Action
9572a58764 Translation update 2021-04-24 00:48:31 +00:00
Paulus Schoutsen
393ae9e5dc Bumped version to 20210423.0 2021-04-23 15:23:32 -07:00
Paulus Schoutsen
63e10314bd Sketch out strategies (#8959)
Co-authored-by: Zack Arnett <arnett.zackary@gmail.com>
2021-04-23 09:36:45 -07:00
Paulus Schoutsen
b599417a37 Improve rendering status text on integration cards (#8973) 2021-04-23 09:30:17 +02:00
Philip Allgaier
899eab4e5c Ensure 0 does not get formatted to empty string (#8971) 2021-04-23 09:29:03 +02:00
Paulus Schoutsen
3f21c87a3d Allow config entries to show the reason (#8974) 2021-04-23 09:25:09 +02:00
GitHub Action
c296a60bab Translation update 2021-04-23 00:48:28 +00:00
Paulus Schoutsen
5f78f18cb4 Fix rendering of a choose without any action taken (#8952) 2021-04-22 21:01:09 +02:00
Paulus Schoutsen
0b8d356865 Clean up HUI-VIEW (#8967) 2021-04-22 09:46:15 -07:00
Bram Kragten
e8d1318a5b Bump codemirror (#8953)
Fixes #8557
2021-04-21 19:22:56 +02:00
GitHub Action
07ce07c4a5 Translation update 2021-04-21 00:48:45 +00:00
Franck Nijhof
a07220f383 Update GitHub issue form (#8954) 2021-04-20 12:37:59 +02:00
J. Nick Koston
f21ed24a49 Make error optional in connection lost service check (#8937) 2021-04-20 10:58:39 +02:00
GitHub Action
e3c38b93f4 Translation update 2021-04-20 00:48:22 +00:00
Aaron Godfrey
b398727413 Allow falsey values for attribute value in a picture-elements card element. (#8943) 2021-04-19 18:51:55 +02:00
uvjustin
9bc2ab29a1 Version bump hls.js to v1.0.1 (#8951)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2021-04-19 17:25:19 +02:00
GitHub Action
51f1ff26f1 Translation update 2021-04-19 00:48:51 +00:00
Bram Kragten
97d5e6512d Fix link to config panels (#8936) 2021-04-17 22:12:07 -07:00
GitHub Action
b76c67fc9b Translation update 2021-04-18 00:48:53 +00:00
Paulus Schoutsen
b96a70cd55 Make the integration header banner smaller (#8935) 2021-04-16 23:51:48 -07:00
Paulus Schoutsen
982ab93cdb Do not vertically align integration icon (#8934) 2021-04-16 23:16:20 -07:00
Paulus Schoutsen
c7f4e1152d Pass manifest to config flow card (#8933) 2021-04-16 23:02:29 -07:00
J. Nick Koston
519988326b Do not throw warnings when a service calls disconnects the websocket (#8932) 2021-04-16 20:59:10 -07:00
GitHub Action
b518f4b03c Translation update 2021-04-17 00:48:32 +00:00
Paulus Schoutsen
5493fdfcb7 Bumped version to 20210416.0 2021-04-16 12:27:18 -07:00
Paulus Schoutsen
179767e9f8 Align layout of all cards (#8931)
* Align layout of all cards

* Make ignore card have normal button
2021-04-16 12:27:01 -07:00
Paulus Schoutsen
25b3bb1285 Fixes for integration cards (#8930) 2021-04-16 20:22:22 +02:00
Bram Kragten
841c8ab1f1 Update script editor (#8919) 2021-04-16 08:57:07 -07:00
Philip Allgaier
1ce17e2847 Remove non effective CSS for CM6 search panel input (#8921) 2021-04-16 16:29:36 +02:00
Philip Allgaier
a09b206b0e Added missing <ul> to beta join dialog (#8927) 2021-04-16 16:06:52 +02:00
Carlos Garcia Saura
bb4617c53b Correct two swapped supervisor beta join action/confirm texts (#8922) 2021-04-16 14:54:39 +02:00
Philip Allgaier
cfd18bfb74 Corrected "not loaded" state string (#8925) 2021-04-16 14:45:40 +02:00
Philip Allgaier
e225d6f546 Correct wording from "component" to "integration" on new integration page (#8924) 2021-04-16 14:41:38 +02:00
Paulus Schoutsen
60fe48d355 Show config entry state on card (#8911) 2021-04-16 13:16:59 +02:00
GitHub Action
2dcd0d2b0a Translation update 2021-04-16 00:48:38 +00:00
Bram Kragten
8e11aa9130 Fix activate scene button + allow removing icon (#8916) 2021-04-15 13:02:09 +02:00
Philip Allgaier
f6e223c18d Use const everywhere for "group.default_view" (#8918) 2021-04-15 09:54:32 +02:00
Bram Kragten
9d29b55bee Add z-index to add user dialog (#8917) 2021-04-15 09:46:19 +02:00
GitHub Action
92aa8580db Translation update 2021-04-15 00:48:36 +00:00
Donnie
538028a003 Refactor sequence matching to accept item rather than word array (#8866)
* Refactor sequence matching to require an item rather than array of words to filter against

* change 'words' to 'strings'. Add tsdoc description for ScorableTextItem

* Replace type checking with 'as' to clean up code
2021-04-14 15:29:10 -07:00
Carlos Garcia Saura
c53575a74f Set standard name for Cancel button, to align translations (#8914) 2021-04-14 23:09:31 +02:00
Bram Kragten
193016a46a Fix time selector + base am/pm on user language (#8908)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2021-04-14 21:51:29 +02:00
Bram Kragten
aaa50b4d1d Don't add toast to history (#8915) 2021-04-14 12:01:42 -07:00
Bram Kragten
a43120320e Bump typescript to 4.2.4 (#8876) 2021-04-14 12:00:24 -07:00
Paulus Schoutsen
b8bb0c038d Highlight if log comes from custom component (#8912)
Co-authored-by: Joakim Sørensen <joasoe@gmail.com>
2021-04-14 11:59:00 -07:00
GitHub Action
dc79fc2919 Translation update 2021-04-14 00:48:24 +00:00
Philip Allgaier
30787fef60 Hide new light color mode attributes in more-info (#8895) 2021-04-13 20:23:58 +02:00
J. Nick Koston
445ae156ef Unsubscribe when dismissing during wrap up (#8909) 2021-04-13 20:18:37 +02:00
Jakub Dąbrowski
62a0cfb0f6 Fix computing cards (#8894)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2021-04-13 16:25:48 +02:00
LJU
96bc3ef99a Improve spelling (#8901) 2021-04-13 15:49:53 +02:00
GitHub Action
1d3b95d24f Translation update 2021-04-13 00:49:02 +00:00
Bram Kragten
56fe4b07f3 Show toast with call service error (#8904) 2021-04-12 17:10:25 -07:00
Jakub Dąbrowski
ea60f7005b Fix saving entities of the device in scene editor (#8884) 2021-04-12 23:04:35 +02:00
Philip Allgaier
9eb59062aa Increase supervisor metric value span width to account for blank (#8885) 2021-04-12 23:02:09 +02:00
Bram Kragten
d00927c31f Update codemirror (#8903) 2021-04-12 22:04:58 +02:00
Charles Garwood
c03017208d Remove link/text about ZHA/Z-Wave config panels moving to integration page (#8867) 2021-04-12 20:17:31 +02:00
GitHub Action
73f945458a Translation update 2021-04-12 00:48:46 +00:00
GitHub Action
db12234611 Translation update 2021-04-11 00:48:30 +00:00
GitHub Action
ed1cd4632f Translation update 2021-04-10 00:48:37 +00:00
Paulus Schoutsen
9833accc79 Fix failed conditions reason (#8870) 2021-04-08 23:01:12 -07:00
GitHub Action
d46123771a Translation update 2021-04-09 00:48:50 +00:00
Charles Garwood
87fe84b1ac Add units to Z-Wave JS Node Config inputs (#8869)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2021-04-08 16:32:47 -07:00
Bram Kragten
21140f437e Update value of date input (#8865) 2021-04-08 16:31:46 -07:00
Paulus Schoutsen
ba9e410393 Pass narrow (#8864) 2021-04-08 22:59:24 +02:00
Paulus Schoutsen
587fb2a170 Add logbook note (#8843)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2021-04-08 20:52:37 +02:00
Bram Kragten
7d801ff84c Handle choose being null (#8859)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2021-04-08 20:48:49 +02:00
Bram Kragten
d69accd9a5 Add dev import buttons for debugging traces (#8860) 2021-04-08 11:32:31 -07:00
J. Nick Koston
1127750c5e Show which integrations are being setup at startup (#8834)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2021-04-08 07:30:47 -10:00
Bram Kragten
7758bd89c1 Check if logbook component loaded when fetching trace (#8861) 2021-04-08 09:04:08 -07:00
Philip Allgaier
de7264327a Do not use "media_play_pause" but atomic services instead (#8845) 2021-04-08 16:47:04 +02:00
Philip Allgaier
c3f0932794 Use number format setting for attribute rows (#8844) 2021-04-08 10:52:10 +02:00
Philip Allgaier
367907e037 Mention unique ID requirement in trace button tooltip (#8853) 2021-04-08 09:47:25 +02:00
Donnie
2d15bd651e Fix spinner regression and remove unnecessary twoline config (#8847) 2021-04-07 21:18:55 -07:00
GitHub Action
4b1d7863f8 Translation update 2021-04-08 00:48:34 +00:00
Paulus Schoutsen
e425d768dd Remove owner guard from analytics (#8842) 2021-04-07 18:41:39 +02:00
141 changed files with 5149 additions and 2057 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

@@ -17,7 +17,7 @@ import { DemoTrace } from "../data/traces/types";
const traces: DemoTrace[] = [
mockDemoTrace({ state: "running" }),
mockDemoTrace({ state: "debugged" }),
mockDemoTrace({ state: "stopped", script_execution: "failed_condition" }),
mockDemoTrace({ state: "stopped", script_execution: "failed_conditions" }),
mockDemoTrace({ state: "stopped", script_execution: "failed_single" }),
mockDemoTrace({ state: "stopped", script_execution: "failed_max_runs" }),
mockDemoTrace({ state: "stopped", script_execution: "finished" }),

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

@@ -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",
@@ -168,7 +167,6 @@
"@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,7 +226,7 @@
"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",

View File

@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
setup(
name="home-assistant-frontend",
version="20210407.2",
version="20210423.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

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

@@ -118,6 +118,8 @@ export class HaDateInput extends LitElement {
!this.value ||
(this._inited && !this._compareStringDates(ev.detail.value, this.value))
) {
this.value = ev.detail.value;
fireEvent(this, "change");
fireEvent(this, "value-changed", { value: ev.detail.value });
}
}

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;
@@ -475,7 +477,7 @@ export class HaAutomationTracer extends LitElement {
let extra: TemplateResult | undefined;
switch (this.trace.script_execution) {
case "failed_condition":
case "failed_conditions":
reason = "a condition failed";
break;
case "failed_single":

View File

@@ -9,6 +9,7 @@ export interface AnalyticsPreferences {
export interface Analytics {
preferences: AnalyticsPreferences;
onboarded: boolean;
}
export const getAnalyticsDetails = (hass: HomeAssistant) =>

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,13 @@ 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 const integrationIssuesUrl = (

View File

@@ -19,6 +19,10 @@ export interface LovelacePanelConfig {
export interface LovelaceConfig {
title?: string;
strategy?: {
name: string;
options?: Record<string, unknown>;
};
views: LovelaceViewConfig[];
background?: string;
}
@@ -77,6 +81,10 @@ export interface LovelaceViewConfig {
index?: number;
title?: string;
type?: string;
strategy?: {
name: 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

@@ -66,7 +66,7 @@ export interface AutomationTrace {
};
script_execution:
| // The script was not executed because the automation's condition failed
"failed_condition"
"failed_conditions"
// The script was not executed because the run mode is single
| "failed_single"
// The script was not executed because max parallel runs would be exceeded
@@ -80,8 +80,7 @@ export interface AutomationTrace {
| "error"
// The exception is in the trace itself or in the last element of the trace
// Script execution stopped by async_stop called on the script run because home assistant is shutting down, script mode is SCRIPT_MODE_RESTART etc:
| "cancelled"
| string;
| "cancelled";
// Automation only, should become it's own type when we support script in frontend
trigger: string;
}

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 {
@@ -75,6 +76,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;
@@ -282,6 +288,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";

View File

@@ -29,6 +29,10 @@ export interface ZWaveJSNode {
}
export interface ZWaveJSNodeConfigParams {
[key: string]: ZWaveJSNodeConfigParam;
}
export interface ZWaveJSNodeConfigParam {
property: number;
value: any;
configuration_value_type: string;
@@ -56,6 +60,17 @@ 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 enum NodeStatus {
Unknown,
Asleep,
@@ -75,6 +90,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 +125,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 +139,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,

View File

@@ -0,0 +1,165 @@
import "../../components/ha-analytics";
import "@material/mwc-button/mwc-button";
import {
css,
CSSResult,
customElement,
html,
internalProperty,
LitElement,
property,
TemplateResult,
} from "lit-element";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-dialog";
import { Analytics, setAnalyticsPreferences } from "../../data/analytics";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { DialogAnalyticsOptInParams } from "./show-dialog-analytics-optin";
import { analyticsLearnMore } from "../../components/ha-analytics-learn-more";
@customElement("dialog-analytics-optin")
class DialogAnalyticsOptIn extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@internalProperty() private _error?: string;
@internalProperty() private _submitting = false;
@internalProperty() private _showPreferences = false;
@internalProperty() private _analyticsDetails?: Analytics;
public showDialog(params: DialogAnalyticsOptInParams): void {
this._error = undefined;
this._submitting = false;
this._analyticsDetails = params.analytics;
}
public closeDialog(): void {
this._error = undefined;
this._submitting = false;
this._showPreferences = false;
this._analyticsDetails = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render(): TemplateResult {
if (!this._analyticsDetails) {
return html``;
}
return html`
<ha-dialog
open
heading="Analytics"
scrimClickAction
escapeKeyAction
hideActions
>
<div class="content">
${this._error ? html` <div class="error">${this._error}</div> ` : ""}
${this._showPreferences
? html`<ha-analytics
@analytics-preferences-changed=${this._preferencesChanged}
.hass=${this.hass}
.analytics=${this._analyticsDetails!}
></ha-analytics>`
: html` <div class="introduction">
To help us better understand how you use Home Assistant, and to
ensure our priorities align with yours, we ask that you share
anonymized information from your installation. This will help make Home
Assistant better and help us convince manufacturers to add local
control and privacy-focused features.
<p>
If you want to change what you share, you can find this in
under "General" here in the configuration panel
</p>
</div>`}
${analyticsLearnMore(this.hass)}
</div>
<div class="dialog-actions">
<mwc-button @click=${this._ignore} .disabled=${this._submitting}>
Ignore
</mwc-button>
<mwc-button
@click=${this._customize}
.disabled=${this._submitting || this._showPreferences}
>
Customize
</mwc-button>
<mwc-button @click=${this._submit} .disabled=${this._submitting}>
${this._showPreferences ? "Submit" : "Enable analytics"}
</mwc-button>
</div>
</ha-dialog>
`;
}
private _preferencesChanged(event: CustomEvent): void {
this._analyticsDetails = {
...this._analyticsDetails!,
preferences: event.detail.preferences,
};
}
private async _ignore() {
this._submitting = true;
try {
await setAnalyticsPreferences(this.hass, {});
} catch (err) {
this._error = err.message;
this._submitting = false;
return;
}
this.closeDialog();
}
private async _customize() {
this._showPreferences = true;
}
private async _submit() {
this._submitting = true;
try {
await setAnalyticsPreferences(
this.hass,
this._showPreferences
? this._analyticsDetails!.preferences
: { base: true, usage: true, statistics: true }
);
} catch (err) {
this._error = err.message;
this._submitting = false;
return;
}
this.closeDialog();
}
static get styles(): CSSResult[] {
return [
haStyleDialog,
css`
.error {
color: var(--error-color);
}
.content {
padding-bottom: 54px;
}
.dialog-actions {
display: flex;
justify-content: space-between;
bottom: 16px;
position: absolute;
width: calc(100% - 48px);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-analytics-optin": DialogAnalyticsOptIn;
}
}

View File

@@ -0,0 +1,20 @@
import { fireEvent } from "../../common/dom/fire_event";
import { Analytics } from "../../data/analytics";
export interface DialogAnalyticsOptInParams {
analytics: Analytics;
}
export const loadConfigEntrySystemOptionsDialog = () =>
import("./dialog-analytics-optin");
export const showDialogAnalyticsOptIn = (
element: HTMLElement,
dialogParams: DialogAnalyticsOptInParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-analytics-optin",
dialogImport: loadConfigEntrySystemOptionsDialog,
dialogParams,
});
};

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

@@ -16,7 +16,6 @@ class DatetimeInput extends PolymerElement {
<div>
<ha-date-input
id="dateInput"
on-value-changed="dateTimeChanged"
label="Date"
value="{{selectedDate}}"
></ha-date-input>

View File

@@ -151,7 +151,7 @@ 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,xy_color,min_mireds,max_mireds,entity_id,supported_color_modes,color_mode"
></ha-attributes>
</div>
`;

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

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

@@ -27,6 +27,7 @@ class OnboardingAnalytics extends LitElement {
@internalProperty() private _analyticsDetails: Analytics = {
preferences: {},
onboarded: false,
};
protected render(): TemplateResult {

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}
@@ -242,6 +255,7 @@ export class HaAutomationTrace extends LitElement {
? html`
<ha-automation-trace-logbook
.hass=${this.hass}
.narrow=${this.narrow}
.logbookEntries=${this._logbookEntries}
></ha-automation-trace-logbook>
`
@@ -409,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

@@ -15,7 +15,9 @@ import "../../../components/ha-card";
import "../../../components/ha-icon-next";
import "../../../components/ha-menu-button";
import "../../../components/ha-svg-icon";
import { getAnalyticsDetails } from "../../../data/analytics";
import { CloudStatus } from "../../../data/cloud";
import { showDialogAnalyticsOptIn } from "../../../dialogs/analytics/show-dialog-analytics-optin";
import "../../../layouts/ha-app-layout";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
@@ -36,6 +38,15 @@ class HaConfigDashboard extends LitElement {
@property() public showAdvanced!: boolean;
protected firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
getAnalyticsDetails(this.hass).then((analytics) => {
if (!analytics.onboarded) {
showDialogAnalyticsOptIn(this, { analytics });
}
});
}
protected render(): TemplateResult {
const content = html` <ha-config-section
.narrow=${this.narrow}
@@ -79,36 +90,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

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

@@ -30,8 +30,9 @@ class IntegrationsCard extends LitElement {
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();
});

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

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

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

@@ -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>
`;
@@ -230,6 +270,9 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
.disabled=${!item.metadata.writeable}
@value-changed=${this._numericInputChanged}
>
${item.metadata.unit
? html`<span slot="suffix">${item.metadata.unit}</span>`
: ""}
</paper-input> `;
}
@@ -290,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);
}
@@ -300,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));
}
@@ -318,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 {
@@ -366,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

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

@@ -666,6 +666,7 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard {
ha-icon-button[action="media_play"],
ha-icon-button[action="media_play_pause"],
ha-icon-button[action="media_pause"],
ha-icon-button[action="media_stop"],
ha-icon-button[action="turn_on"],
ha-icon-button[action="turn_off"] {
@@ -743,6 +744,7 @@ export class HuiMediaControlCard extends LitElement implements LovelaceCard {
.narrow ha-icon-button[action="media_play"],
.narrow ha-icon-button[action="media_play_pause"],
.narrow ha-icon-button[action="media_pause"],
.narrow ha-icon-button[action="turn_on"] {
--mdc-icon-button-size: 50px;
--mdc-icon-size: 36px;

View File

@@ -1,40 +1,16 @@
import {
HassEntities,
HassEntity,
STATE_NOT_RUNNING,
} from "home-assistant-js-websocket";
import { HassEntities, HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeObjectId } from "../../../common/entity/compute_object_id";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { extractViews } from "../../../common/entity/extract_views";
import { getViewEntities } from "../../../common/entity/get_view_entities";
import { splitByGroups } from "../../../common/entity/split_by_groups";
import { compare } from "../../../common/string/compare";
import { LocalizeFunc } from "../../../common/translations/localize";
import { subscribeOne } from "../../../common/util/subscribe-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import {
AreaRegistryEntry,
subscribeAreaRegistry,
} from "../../../data/area_registry";
import {
DeviceRegistryEntry,
subscribeDeviceRegistry,
} from "../../../data/device_registry";
import {
EntityRegistryEntry,
subscribeEntityRegistry,
} from "../../../data/entity_registry";
import { GroupEntity } from "../../../data/group";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import type { DeviceRegistryEntry } from "../../../data/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity_registry";
import { domainToName } from "../../../data/integration";
import {
LovelaceCardConfig,
LovelaceConfig,
LovelaceViewConfig,
} from "../../../data/lovelace";
import { LovelaceCardConfig, LovelaceViewConfig } from "../../../data/lovelace";
import { SENSOR_DEVICE_CLASS_BATTERY } from "../../../data/sensor";
import { HomeAssistant } from "../../../types";
import {
AlarmPanelCardConfig,
EntitiesCardConfig,
@@ -45,7 +21,6 @@ import {
} from "../cards/types";
import { LovelaceRowConfig } from "../entity-rows/types";
const DEFAULT_VIEW_ENTITY_ID = "group.default_view";
const HIDE_DOMAIN = new Set([
"automation",
"configurator",
@@ -57,8 +32,6 @@ const HIDE_DOMAIN = new Set([
const HIDE_PLATFORM = new Set(["mobile_app"]);
let subscribedRegistries = false;
interface SplittedByAreas {
areasWithEntities: Array<[AreaRegistryEntry, HassEntity[]]>;
otherEntities: HassEntities;
@@ -181,7 +154,8 @@ export const computeCards = (
titlePrefix &&
stateObj &&
// eslint-disable-next-line no-cond-assign
(name = computeStateName(stateObj)).startsWith(titlePrefix)
(name = computeStateName(stateObj)) !== titlePrefix &&
name.startsWith(titlePrefix)
? {
entity: entityId,
name: adjustName(name.substr(titlePrefix.length)),
@@ -238,7 +212,7 @@ const computeDefaultViewStates = (
return states;
};
const generateViewConfig = (
export const generateViewConfig = (
localize: LocalizeFunc,
path: string,
title: string | undefined,
@@ -372,141 +346,3 @@ export const generateDefaultViewConfig = (
return config;
};
export const generateLovelaceConfigFromData = async (
hass: HomeAssistant,
areaEntries: AreaRegistryEntry[],
deviceEntries: DeviceRegistryEntry[],
entityEntries: EntityRegistryEntry[],
entities: HassEntities,
localize: LocalizeFunc
): Promise<LovelaceConfig> => {
if (hass.config.safe_mode) {
return {
title: hass.config.location_name,
views: [
{
cards: [{ type: "safe-mode" }],
},
],
};
}
const viewEntities = extractViews(entities);
const views = viewEntities.map((viewEntity: GroupEntity) => {
const states = getViewEntities(entities, viewEntity);
// In the case of a normal view, we use group order as specified in view
const groupOrders = {};
Object.keys(states).forEach((entityId, idx) => {
groupOrders[entityId] = idx;
});
return generateViewConfig(
localize,
computeObjectId(viewEntity.entity_id),
computeStateName(viewEntity),
viewEntity.attributes.icon,
states,
groupOrders
);
});
let title = hass.config.location_name;
// User can override default view. If they didn't, we will add one
// that contains all entities.
if (
viewEntities.length === 0 ||
viewEntities[0].entity_id !== DEFAULT_VIEW_ENTITY_ID
) {
views.unshift(
generateDefaultViewConfig(
areaEntries,
deviceEntries,
entityEntries,
entities,
localize
)
);
// Add map of geo locations to default view if loaded
if (isComponentLoaded(hass, "geo_location")) {
if (views[0] && views[0].cards) {
views[0].cards.push({
type: "map",
geo_location_sources: ["all"],
});
}
}
// Make sure we don't have Home as title and first tab.
if (views.length > 1 && title === "Home") {
title = "Home Assistant";
}
}
// User has no entities
if (views.length === 1 && views[0].cards!.length === 0) {
views[0].cards!.push({
type: "empty-state",
});
}
return {
title,
views,
};
};
export const generateLovelaceConfigFromHass = async (
hass: HomeAssistant,
localize?: LocalizeFunc
): Promise<LovelaceConfig> => {
if (hass.config.state === STATE_NOT_RUNNING) {
return {
title: hass.config.location_name,
views: [
{
cards: [{ type: "starting" }],
},
],
};
}
if (hass.config.safe_mode) {
return {
title: hass.config.location_name,
views: [
{
cards: [{ type: "safe-mode" }],
},
],
};
}
// We want to keep the registry subscriptions alive after generating the UI
// so that we don't serve up stale data after changing areas.
if (!subscribedRegistries) {
subscribedRegistries = true;
subscribeAreaRegistry(hass.connection, () => undefined);
subscribeDeviceRegistry(hass.connection, () => undefined);
subscribeEntityRegistry(hass.connection, () => undefined);
}
const [areaEntries, deviceEntries, entityEntries] = await Promise.all([
subscribeOne(hass.connection, subscribeAreaRegistry),
subscribeOne(hass.connection, subscribeDeviceRegistry),
subscribeOne(hass.connection, subscribeEntityRegistry),
]);
return generateLovelaceConfigFromData(
hass,
areaEntries,
deviceEntries,
entityEntries,
hass.states,
localize || hass.localize
);
};

View File

@@ -129,7 +129,8 @@ class HuiGenericEntityRow extends LitElement {
stateObj.attributes.brightness
? html`${Math.round(
(stateObj.attributes.brightness / 255) * 100
)} %`
)}
%`
: "")}
</div>
`

View File

@@ -2,6 +2,7 @@ import {
LovelaceViewConfig,
LovelaceViewElement,
} from "../../../data/lovelace";
import { HuiErrorCard } from "../cards/hui-error-card";
import "../views/hui-masonry-view";
import { createLovelaceElement } from "./create-element-base";
@@ -13,7 +14,7 @@ const LAZY_LOAD_LAYOUTS = {
export const createViewElement = (
config: LovelaceViewConfig
): LovelaceViewElement => {
): LovelaceViewElement | HuiErrorCard => {
return createLovelaceElement(
"view",
config,

View File

@@ -19,13 +19,15 @@ import "../../../components/ha-formfield";
import "../../../components/ha-svg-icon";
import "../../../components/ha-switch";
import "../../../components/ha-yaml-editor";
import type { LovelaceConfig } from "../../../data/lovelace";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { expandLovelaceConfigStrategies } from "../strategies/get-strategy";
import type { SaveDialogParams } from "./show-save-config-dialog";
const EMPTY_CONFIG = { views: [] };
const EMPTY_CONFIG: LovelaceConfig = { views: [{ title: "Home" }] };
@customElement("hui-dialog-save-config")
export class HuiSaveConfig extends LitElement implements HassDialog {
@@ -125,14 +127,17 @@ export class HuiSaveConfig extends LitElement implements HassDialog {
</div>
${this._params.mode === "storage"
? html`
<mwc-button slot="primaryAction" @click=${this.closeDialog}
>${this.hass!.localize(
"ui.panel.lovelace.editor.save_config.cancel"
)}
</mwc-button>
<mwc-button
slot="primaryAction"
.label=${this.hass!.localize("ui.common.cancel")}
@click=${this.closeDialog}
></mwc-button>
<mwc-button
slot="primaryAction"
?disabled=${this._saving}
aria-label=${this.hass!.localize(
"ui.panel.lovelace.editor.save_config.save"
)}
@click=${this._saveConfig}
>
${this._saving
@@ -148,11 +153,13 @@ export class HuiSaveConfig extends LitElement implements HassDialog {
</mwc-button>
`
: html`
<mwc-button slot="primaryAction" @click=${this.closeDialog}
>${this.hass!.localize(
<mwc-button
slot="primaryAction"
.label=${this.hass!.localize(
"ui.panel.lovelace.editor.save_config.close"
)}
</mwc-button>
@click=${this.closeDialog}
></mwc-button>
`}
</ha-dialog>
`;
@@ -177,7 +184,13 @@ export class HuiSaveConfig extends LitElement implements HassDialog {
try {
const lovelace = this._params!.lovelace;
await lovelace.saveConfig(
this._emptyConfig ? EMPTY_CONFIG : lovelace.config
this._emptyConfig
? EMPTY_CONFIG
: await expandLovelaceConfigStrategies({
config: lovelace.config,
hass: this.hass!,
narrow: this._params!.narrow,
})
);
lovelace.setEditMode(true);
this._saving = false;

View File

@@ -14,6 +14,7 @@ const dialogTag = "hui-dialog-save-config";
export interface SaveDialogParams {
lovelace: Lovelace;
mode: "yaml" | "storage";
narrow: boolean;
}
let registeredDialog = false;

View File

@@ -57,7 +57,7 @@ class HuiStateLabelElement extends LitElement implements LovelaceElement {
if (
this._config.attribute &&
!stateObj.attributes[this._config.attribute]
!(this._config.attribute in stateObj.attributes)
) {
return html`
<hui-warning-element

View File

@@ -115,7 +115,7 @@ class HuiMediaPlayerEntityRow extends LitElement implements LovelaceRow {
? html`
<ha-icon-button
icon=${this._computeControlIcon(stateObj)}
@click=${this._playPause}
@click=${this._playPauseStop}
></ha-icon-button>
`
: ""}
@@ -256,8 +256,17 @@ class HuiMediaPlayerEntityRow extends LitElement implements LovelaceRow {
);
}
private _playPause(): void {
this.hass!.callService("media_player", "media_play_pause", {
private _playPauseStop(): void {
const stateObj = this.hass!.states[this._config!.entity];
const service =
stateObj.state !== "playing"
? "media_play"
: supportsFeature(stateObj, SUPPORT_PAUSE)
? "media_pause"
: "media_stop";
this.hass!.callService("media_player", service, {
entity_id: this._config!.entity,
});
}

View File

@@ -7,6 +7,11 @@ import {
property,
TemplateResult,
} from "lit-element";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
addSearchParam,
removeSearchParam,
} from "../../common/url/search-params";
import { domainToName } from "../../data/integration";
import {
deleteConfig,
@@ -21,14 +26,16 @@ import "../../layouts/hass-error-screen";
import "../../layouts/hass-loading-screen";
import { HomeAssistant, PanelInfo, Route } from "../../types";
import { showToast } from "../../util/toast";
import { generateLovelaceConfigFromHass } from "./common/generate-lovelace-config";
import { loadLovelaceResources } from "./common/load-resources";
import { showSaveDialog } from "./editor/show-save-config-dialog";
import "./hui-root";
import { generateLovelaceDashboardStrategy } from "./strategies/get-strategy";
import { Lovelace } from "./types";
(window as any).loadCardHelpers = () => import("./custom-card-helpers");
const DEFAULT_STRATEGY = "original-states";
interface LovelacePanelConfig {
mode: "yaml" | "storage";
}
@@ -71,7 +78,11 @@ class LovelacePanel extends LitElement {
this.lovelace.locale !== this.hass.locale
) {
// language has been changed, rebuild UI
this._setLovelaceConfig(this.lovelace.config, this.lovelace.mode);
this._setLovelaceConfig(
this.lovelace.config,
this.lovelace.rawConfig,
this.lovelace.mode
);
} else if (this.lovelace && this.lovelace.mode === "generated") {
// When lovelace is generated, we re-generate each time a user goes
// to the states panel to make sure new entities are shown.
@@ -139,7 +150,9 @@ class LovelacePanel extends LitElement {
`;
}
protected firstUpdated() {
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this._fetchConfig(false);
if (!this._unsubUpdates) {
this._subscribeUpdates();
@@ -153,8 +166,14 @@ class LovelacePanel extends LitElement {
}
private async _regenerateConfig() {
const conf = await generateLovelaceConfigFromHass(this.hass!);
this._setLovelaceConfig(conf, "generated");
const conf = await generateLovelaceDashboardStrategy(
{
hass: this.hass!,
narrow: this.narrow,
},
DEFAULT_STRATEGY
);
this._setLovelaceConfig(conf, undefined, "generated");
this._state = "loaded";
}
@@ -202,6 +221,7 @@ class LovelacePanel extends LitElement {
private async _fetchConfig(forceDiskRefresh: boolean) {
let conf: LovelaceConfig;
let rawConf: LovelaceConfig | undefined;
let confMode: Lovelace["mode"] = this.panel!.config.mode;
let confProm: Promise<LovelaceConfig> | undefined;
const llWindow = window as WindowWithLovelaceProm;
@@ -236,7 +256,18 @@ class LovelacePanel extends LitElement {
}
try {
conf = await confProm!;
rawConf = await confProm!;
// If strategy defined, apply it here.
if (rawConf.strategy) {
conf = await generateLovelaceDashboardStrategy({
config: rawConf,
hass: this.hass!,
narrow: this.narrow,
});
} else {
conf = rawConf;
}
} catch (err) {
if (err.code !== "config_not_found") {
// eslint-disable-next-line
@@ -245,8 +276,13 @@ class LovelacePanel extends LitElement {
this._errorMsg = err.message;
return;
}
const localize = await this.hass!.loadBackendTranslation("title");
conf = await generateLovelaceConfigFromHass(this.hass!, localize);
conf = await generateLovelaceDashboardStrategy(
{
hass: this.hass!,
narrow: this.narrow,
},
DEFAULT_STRATEGY
);
confMode = "generated";
} finally {
// Ignore updates for another 2 seconds.
@@ -258,7 +294,7 @@ class LovelacePanel extends LitElement {
}
this._state = this._state === "yaml-editor" ? this._state : "loaded";
this._setLovelaceConfig(conf, confMode);
this._setLovelaceConfig(conf, rawConf, confMode);
}
private _checkLovelaceConfig(config: LovelaceConfig) {
@@ -277,11 +313,16 @@ class LovelacePanel extends LitElement {
return checkedConfig ? deepFreeze(checkedConfig) : config;
}
private _setLovelaceConfig(config: LovelaceConfig, mode: Lovelace["mode"]) {
private _setLovelaceConfig(
config: LovelaceConfig,
rawConfig: LovelaceConfig | undefined,
mode: Lovelace["mode"]
) {
config = this._checkLovelaceConfig(config);
const urlPath = this.urlPath;
this.lovelace = {
config,
rawConfig,
mode,
urlPath: this.urlPath,
editMode: this.lovelace ? this.lovelace.editMode : false,
@@ -294,22 +335,39 @@ class LovelacePanel extends LitElement {
this._state = "yaml-editor";
},
setEditMode: (editMode: boolean) => {
// If we use a strategy for dashboard, we cannot show the edit UI
// So go straight to the YAML editor
if (
this.lovelace!.rawConfig &&
this.lovelace!.rawConfig !== this.lovelace!.config
) {
this.lovelace!.enableFullEditMode();
return;
}
if (!editMode || this.lovelace!.mode !== "generated") {
this._updateLovelace({ editMode });
return;
}
showSaveDialog(this, {
lovelace: this.lovelace!,
mode: this.panel!.config.mode,
narrow: this.narrow!,
});
},
saveConfig: async (newConfig: LovelaceConfig): Promise<void> => {
const { config: previousConfig, mode: previousMode } = this.lovelace!;
const {
config: previousConfig,
rawConfig: previousRawConfig,
mode: previousMode,
} = this.lovelace!;
newConfig = this._checkLovelaceConfig(newConfig);
try {
// Optimistic update
this._updateLovelace({
config: newConfig,
rawConfig: undefined,
mode: "storage",
});
this._ignoreNextUpdateEvent = true;
@@ -320,18 +378,30 @@ class LovelacePanel extends LitElement {
// Rollback the optimistic update
this._updateLovelace({
config: previousConfig,
rawConfig: previousRawConfig,
mode: previousMode,
});
throw err;
}
},
deleteConfig: async (): Promise<void> => {
const { config: previousConfig, mode: previousMode } = this.lovelace!;
const {
config: previousConfig,
rawConfig: previousRawConfig,
mode: previousMode,
} = this.lovelace!;
try {
// Optimistic update
const localize = await this.hass!.loadBackendTranslation("title");
const generatedConf = await generateLovelaceDashboardStrategy(
{
hass: this.hass!,
narrow: this.narrow,
},
DEFAULT_STRATEGY
);
this._updateLovelace({
config: await generateLovelaceConfigFromHass(this.hass!, localize),
config: generatedConf,
rawConfig: undefined,
mode: "generated",
editMode: false,
});
@@ -343,6 +413,7 @@ class LovelacePanel extends LitElement {
// Rollback the optimistic update
this._updateLovelace({
config: previousConfig,
rawConfig: previousRawConfig,
mode: previousMode,
});
throw err;
@@ -356,6 +427,18 @@ class LovelacePanel extends LitElement {
...this.lovelace!,
...props,
};
if ("editMode" in props) {
window.history.replaceState(
null,
"",
constructUrlCurrentPath(
props.editMode
? addSearchParam({ edit: "1" })
: removeSearchParam("edit")
)
);
}
}
}

View File

@@ -106,7 +106,7 @@ class LovelaceFullConfigEditor extends LitElement {
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this.yamlEditor.value = safeDump(this.lovelace!.config);
this.yamlEditor.value = safeDump(this.lovelace!.rawConfig);
}
protected updated(changedProps: PropertyValues) {

View File

@@ -43,9 +43,7 @@ import { navigate } from "../../common/navigate";
import {
addSearchParam,
extractSearchParam,
removeSearchParam,
} from "../../common/url/search-params";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import { computeRTLDirection } from "../../common/util/compute_rtl";
import { debounce } from "../../common/util/debounce";
import { afterNextRender } from "../../common/util/render-status";
@@ -539,7 +537,7 @@ class HUIRoot extends LitElement {
protected firstUpdated() {
// Check for requested edit mode
if (extractSearchParam("edit") === "1") {
this._enableEditMode();
this.lovelace!.setEditMode(true);
}
}
@@ -613,7 +611,7 @@ class HUIRoot extends LitElement {
}
if (!force && huiView) {
huiView.lovelace = this.lovelace;
huiView.lovelace = this.lovelace!;
}
}
@@ -715,25 +713,11 @@ class HUIRoot extends LitElement {
});
return;
}
this._enableEditMode();
}
private _enableEditMode(): void {
this.lovelace!.setEditMode(true);
window.history.replaceState(
null,
"",
constructUrlCurrentPath(addSearchParam({ edit: "1" }))
);
}
private _editModeDisable(): void {
this.lovelace!.setEditMode(false);
window.history.replaceState(
null,
"",
constructUrlCurrentPath(removeSearchParam("edit"))
);
}
private _editLovelace() {
@@ -837,7 +821,7 @@ class HUIRoot extends LitElement {
const viewConfig = this.config.views[viewIndex];
if (!viewConfig) {
this._enableEditMode();
this.lovelace!.setEditMode(true);
return;
}

View File

@@ -0,0 +1,158 @@
import { LovelaceConfig, LovelaceViewConfig } from "../../../data/lovelace";
import { AsyncReturnType, HomeAssistant } from "../../../types";
import { OriginalStatesStrategy } from "./original-states-strategy";
const MAX_WAIT_STRATEGY_LOAD = 5000;
const CUSTOM_PREFIX = "custom:";
export interface LovelaceDashboardStrategy {
generateDashboard(info: {
config?: LovelaceConfig;
hass: HomeAssistant;
narrow: boolean | undefined;
}): Promise<LovelaceConfig>;
}
export interface LovelaceViewStrategy {
generateView(info: {
view: LovelaceViewConfig;
config: LovelaceConfig;
hass: HomeAssistant;
narrow: boolean | undefined;
}): Promise<LovelaceViewConfig>;
}
const strategies: Record<
string,
LovelaceDashboardStrategy & LovelaceViewStrategy
> = {
"original-states": OriginalStatesStrategy,
};
const getLovelaceStrategy = async <
T extends LovelaceDashboardStrategy | LovelaceViewStrategy
>(
name: string
): Promise<T> => {
if (name in strategies) {
return strategies[name] as T;
}
if (!name.startsWith(CUSTOM_PREFIX)) {
throw new Error("Unknown strategy");
}
const tag = `ll-strategy-${name.substr(CUSTOM_PREFIX.length)}`;
if (
(await Promise.race([
customElements.whenDefined(tag),
new Promise((resolve) =>
setTimeout(() => resolve(true), MAX_WAIT_STRATEGY_LOAD)
),
])) === true
) {
throw new Error(
`Timeout waiting for strategy element ${tag} to be registered`
);
}
return customElements.get(tag);
};
interface GenerateMethods {
generateDashboard: LovelaceDashboardStrategy["generateDashboard"];
generateView: LovelaceViewStrategy["generateView"];
}
const generateStrategy = async <T extends keyof GenerateMethods>(
generateMethod: T,
renderError: (err: string | Error) => AsyncReturnType<GenerateMethods[T]>,
info: Parameters<GenerateMethods[T]>[0],
name: string | undefined
): Promise<ReturnType<GenerateMethods[T]>> => {
if (!name) {
return renderError("No strategy name found");
}
try {
const strategy = (await getLovelaceStrategy(name)) as any;
return await strategy[generateMethod](info);
} catch (err) {
if (err.message !== "timeout") {
// eslint-disable-next-line
console.error(err);
}
return renderError(err);
}
};
export const generateLovelaceDashboardStrategy = async (
info: Parameters<LovelaceDashboardStrategy["generateDashboard"]>[0],
name?: string
): ReturnType<LovelaceDashboardStrategy["generateDashboard"]> =>
generateStrategy(
"generateDashboard",
(err) => ({
views: [
{
title: "Error",
cards: [
{
type: "markdown",
content: `Error loading the dashboard strategy:\n> ${err}`,
},
],
},
],
}),
info,
name || info.config?.strategy?.name
);
export const generateLovelaceViewStrategy = async (
info: Parameters<LovelaceViewStrategy["generateView"]>[0],
name?: string
): ReturnType<LovelaceViewStrategy["generateView"]> =>
generateStrategy(
"generateView",
(err) => ({
cards: [
{
type: "markdown",
content: `Error loading the view strategy:\n> ${err}`,
},
],
}),
info,
name || info.view?.strategy?.name
);
/**
* Find all references to strategies and replaces them with the generated output
*/
export const expandLovelaceConfigStrategies = async (
info: Parameters<LovelaceDashboardStrategy["generateDashboard"]>[0] & {
config: LovelaceConfig;
}
): Promise<LovelaceConfig> => {
const config = info.config.strategy
? await generateLovelaceDashboardStrategy(info)
: { ...info.config };
config.views = await Promise.all(
config.views.map((view) =>
view.strategy
? generateLovelaceViewStrategy({
hass: info.hass,
narrow: info.narrow,
config,
view,
})
: view
)
);
return config;
};

View File

@@ -0,0 +1,94 @@
import { STATE_NOT_RUNNING } from "home-assistant-js-websocket";
import { subscribeOne } from "../../../common/util/subscribe-one";
import { subscribeAreaRegistry } from "../../../data/area_registry";
import { subscribeDeviceRegistry } from "../../../data/device_registry";
import { subscribeEntityRegistry } from "../../../data/entity_registry";
import { generateDefaultViewConfig } from "../common/generate-lovelace-config";
import {
LovelaceViewStrategy,
LovelaceDashboardStrategy,
} from "./get-strategy";
let subscribedRegistries = false;
export class OriginalStatesStrategy {
static async generateView(
info: Parameters<LovelaceViewStrategy["generateView"]>[0]
): ReturnType<LovelaceViewStrategy["generateView"]> {
const hass = info.hass;
if (hass.config.state === STATE_NOT_RUNNING) {
return {
cards: [{ type: "starting" }],
};
}
if (hass.config.safe_mode) {
return {
cards: [{ type: "safe-mode" }],
};
}
// We leave this here so we always have the freshest data.
if (!subscribedRegistries) {
subscribedRegistries = true;
subscribeAreaRegistry(hass.connection, () => undefined);
subscribeDeviceRegistry(hass.connection, () => undefined);
subscribeEntityRegistry(hass.connection, () => undefined);
}
const [
areaEntries,
deviceEntries,
entityEntries,
localize,
] = await Promise.all([
subscribeOne(hass.connection, subscribeAreaRegistry),
subscribeOne(hass.connection, subscribeDeviceRegistry),
subscribeOne(hass.connection, subscribeEntityRegistry),
hass.loadBackendTranslation("title"),
]);
// User can override default view. If they didn't, we will add one
// that contains all entities.
const view = generateDefaultViewConfig(
areaEntries,
deviceEntries,
entityEntries,
hass.states,
localize
);
// Add map of geo locations to default view if loaded
if (hass.config.components.includes("geo_location")) {
if (view && view.cards) {
view.cards.push({
type: "map",
geo_location_sources: ["all"],
});
}
}
// User has no entities
if (view.cards!.length === 0) {
view.cards!.push({
type: "empty-state",
});
}
return view;
}
static async generateDashboard(
info: Parameters<LovelaceDashboardStrategy["generateDashboard"]>[0]
): ReturnType<LovelaceDashboardStrategy["generateDashboard"]> {
return {
views: [
{
strategy: { name: "original-states" },
title: info.hass.config.location_name,
},
],
};
}
}

View File

@@ -18,6 +18,8 @@ declare global {
export interface Lovelace {
config: LovelaceConfig;
// If not set, a strategy was used to generate everything
rawConfig: LovelaceConfig | undefined;
editMode: boolean;
urlPath: string | null;
mode: "generated" | "yaml" | "storage";

View File

@@ -53,6 +53,8 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
@property({ type: Number }) public index?: number;
@property({ type: Boolean }) public isStrategy = false;
@property({ attribute: false }) public cards: Array<
LovelaceCard | HuiErrorCard
> = [];
@@ -228,7 +230,7 @@ export class MasonryView extends LitElement implements LovelaceViewElement {
private _addCardToColumn(columnEl, index, editMode) {
const card: LovelaceCard = this.cards[index];
if (!editMode) {
if (!editMode || this.isStrategy) {
card.editMode = false;
columnEl.appendChild(card);
} else {

View File

@@ -31,6 +31,8 @@ export class PanelView extends LitElement implements LovelaceViewElement {
@property({ type: Number }) public index?: number;
@property({ type: Boolean }) public isStrategy = false;
@property({ attribute: false }) public cards: Array<
LovelaceCard | HuiErrorCard
> = [];
@@ -101,10 +103,15 @@ export class PanelView extends LitElement implements LovelaceViewElement {
}
private _createCard(): void {
if (this.cards.length === 0) {
this._card = undefined;
return;
}
const card: LovelaceCard = this.cards[0];
card.isPanel = true;
if (!this.lovelace?.editMode) {
if (this.isStrategy || !this.lovelace?.editMode) {
card.editMode = false;
this._card = card;
return;

View File

@@ -23,6 +23,7 @@ import { createViewElement } from "../create-element/create-view-element";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { confDeleteCard } from "../editor/delete-card";
import { generateLovelaceViewStrategy } from "../strategies/get-strategy";
import type { Lovelace, LovelaceBadge, LovelaceCard } from "../types";
const DEFAULT_VIEW_LAYOUT = "masonry";
@@ -39,13 +40,13 @@ declare global {
@customElement("hui-view")
export class HUIView extends UpdatingElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public lovelace?: Lovelace;
@property({ attribute: false }) public lovelace!: Lovelace;
@property({ type: Boolean }) public narrow!: boolean;
@property({ type: Number }) public index?: number;
@property({ type: Number }) public index!: number;
@internalProperty() private _cards: Array<LovelaceCard | HuiErrorCard> = [];
@@ -55,6 +56,8 @@ export class HUIView extends UpdatingElement {
private _layoutElement?: LovelaceViewElement;
private _viewConfigTheme?: string;
// Public to make demo happy
public createCardElement(cardConfig: LovelaceCardConfig) {
const element = createCardElement(cardConfig) as LovelaceCard;
@@ -89,129 +92,142 @@ export class HUIView extends UpdatingElement {
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
const hass = this.hass!;
const lovelace = this.lovelace!;
/*
We need to handle the following use cases:
- initialization: create layout element, populate
- config changed to view with same layout element
- config changed to view with different layout element
- forwarded properties hass/narrow/lovelace/cards/badges change
- cards/badges change if one is rebuild when it was loaded later
- lovelace changes if edit mode is enabled or config has changed
*/
const hassChanged = changedProperties.has("hass");
const oldLovelace = changedProperties.get("lovelace") as Lovelace;
let editModeChanged = false;
let configChanged = false;
if (changedProperties.has("index")) {
configChanged = true;
} else if (changedProperties.has("lovelace")) {
editModeChanged =
oldLovelace && lovelace.editMode !== oldLovelace.editMode;
configChanged = !oldLovelace || lovelace.config !== oldLovelace.config;
}
let viewConfig: LovelaceViewConfig | undefined;
if (configChanged) {
viewConfig = lovelace.config.views[this.index!];
viewConfig = {
...viewConfig,
type: viewConfig.panel
? PANEL_VIEW_LAYOUT
: viewConfig.type || DEFAULT_VIEW_LAYOUT,
};
}
let replace = false;
const oldLovelace = changedProperties.get("lovelace") as this["lovelace"];
// If config has changed, create element if necessary and set all values.
if (
configChanged &&
(!this._layoutElement || this._layoutElementType !== viewConfig!.type)
changedProperties.has("index") ||
(changedProperties.has("lovelace") &&
(!oldLovelace ||
this.lovelace.config.views[this.index] !==
oldLovelace.config.views[this.index]))
) {
replace = true;
this._layoutElement = createViewElement(viewConfig!);
this._layoutElementType = viewConfig!.type;
this._layoutElement.addEventListener("ll-create-card", () => {
showCreateCardDialog(this, {
lovelaceConfig: this.lovelace!.config,
saveConfig: this.lovelace!.saveConfig,
path: [this.index!],
this._initializeConfig();
return;
}
// If no layout element, we're still creating one
if (this._layoutElement) {
// Config has not changed. Just props
if (changedProperties.has("hass")) {
this._badges.forEach((badge) => {
badge.hass = this.hass;
});
});
this._layoutElement.addEventListener("ll-edit-card", (ev) => {
showEditCardDialog(this, {
lovelaceConfig: this.lovelace!.config,
saveConfig: this.lovelace!.saveConfig,
path: ev.detail.path,
this._cards.forEach((element) => {
element.hass = this.hass;
});
});
this._layoutElement.addEventListener("ll-delete-card", (ev) => {
confDeleteCard(this, this.hass!, this.lovelace!, ev.detail.path);
});
}
if (configChanged) {
this._createBadges(viewConfig!);
this._createCards(viewConfig!);
this._layoutElement!.hass = this.hass;
this._layoutElement!.narrow = this.narrow;
this._layoutElement!.lovelace = lovelace;
this._layoutElement!.index = this.index;
}
if (hassChanged) {
this._badges.forEach((badge) => {
badge.hass = hass;
});
this._cards.forEach((element) => {
element.hass = hass;
});
this._layoutElement!.hass = this.hass;
}
if (changedProperties.has("narrow")) {
this._layoutElement!.narrow = this.narrow;
}
if (editModeChanged) {
this._layoutElement!.lovelace = lovelace;
}
if (
configChanged ||
hassChanged ||
editModeChanged ||
changedProperties.has("_cards") ||
changedProperties.has("_badges")
) {
this._layoutElement!.cards = this._cards;
this._layoutElement!.badges = this._badges;
this._layoutElement.hass = this.hass;
}
if (changedProperties.has("narrow")) {
this._layoutElement.narrow = this.narrow;
}
if (changedProperties.has("lovelace")) {
this._layoutElement.lovelace = this.lovelace;
}
if (changedProperties.has("_cards")) {
this._layoutElement.cards = this._cards;
}
if (changedProperties.has("_badges")) {
this._layoutElement.badges = this._badges;
}
}
const oldHass = changedProperties.get("hass") as this["hass"] | undefined;
if (
configChanged ||
editModeChanged ||
(hassChanged &&
oldHass &&
(hass.themes !== oldHass.themes ||
hass.selectedTheme !== oldHass.selectedTheme))
changedProperties.has("hass") &&
(!oldHass ||
this.hass.themes !== oldHass.themes ||
this.hass.selectedTheme !== oldHass.selectedTheme)
) {
applyThemesOnElement(
this,
hass.themes,
lovelace.config.views[this.index!].theme
);
applyThemesOnElement(this, this.hass.themes, this._viewConfigTheme);
}
}
private async _initializeConfig() {
let viewConfig = this.lovelace.config.views[this.index];
let isStrategy = false;
if (viewConfig.strategy) {
isStrategy = true;
viewConfig = await generateLovelaceViewStrategy({
hass: this.hass,
config: this.lovelace.config,
narrow: this.narrow,
view: viewConfig,
});
}
if (this._layoutElement && replace) {
viewConfig = {
...viewConfig,
type: viewConfig.panel
? PANEL_VIEW_LAYOUT
: viewConfig.type || DEFAULT_VIEW_LAYOUT,
};
// Create a new layout element if necessary.
let addLayoutElement = false;
if (!this._layoutElement || this._layoutElementType !== viewConfig.type) {
addLayoutElement = true;
this._createLayoutElement(viewConfig);
}
this._createBadges(viewConfig);
this._createCards(viewConfig);
this._layoutElement!.isStrategy = isStrategy;
this._layoutElement!.hass = this.hass;
this._layoutElement!.narrow = this.narrow;
this._layoutElement!.lovelace = this.lovelace;
this._layoutElement!.index = this.index;
this._layoutElement!.cards = this._cards;
this._layoutElement!.badges = this._badges;
applyThemesOnElement(this, this.hass.themes, viewConfig.theme);
this._viewConfigTheme = viewConfig.theme;
if (addLayoutElement) {
while (this.lastChild) {
this.removeChild(this.lastChild);
}
this.appendChild(this._layoutElement);
this.appendChild(this._layoutElement!);
}
}
private _createLayoutElement(config: LovelaceViewConfig): void {
this._layoutElement = createViewElement(config) as LovelaceViewElement;
this._layoutElementType = config.type;
this._layoutElement.addEventListener("ll-create-card", () => {
showCreateCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: [this.index],
});
});
this._layoutElement.addEventListener("ll-edit-card", (ev) => {
showEditCardDialog(this, {
lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig,
path: ev.detail.path,
});
});
this._layoutElement.addEventListener("ll-delete-card", (ev) => {
confDeleteCard(this, this.hass!, this.lovelace!, ev.detail.path);
});
}
private _createBadges(config: LovelaceViewConfig): void {
if (!config || !config.badges || !Array.isArray(config.badges)) {
this._badges = [];

View File

@@ -72,8 +72,6 @@ export const theme = EditorView.theme({
".cm-panels.top": { borderBottom: "1px solid var(--divider-color)" },
".cm-panels.bottom": { borderTop: "1px solid var(--divider-color)" },
".cm-panel.search input": { margin: "4px 4px 0" },
".cm-button": {
border: "1px solid var(--primary-color)",
padding: "0px 16px",

View File

@@ -3,6 +3,7 @@ import {
callService,
Connection,
ERR_INVALID_AUTH,
ERR_CONNECTION_LOST,
HassConfig,
subscribeConfig,
subscribeEntities,
@@ -13,6 +14,7 @@ import { broadcastConnectionStatus } from "../data/connection-status";
import { subscribeFrontendUserData } from "../data/frontend";
import { forwardHaptic } from "../data/haptics";
import { DEFAULT_PANEL } from "../data/panel";
import { serviceCallWillDisconnect } from "../data/service";
import { NumberFormat } from "../data/translation";
import { subscribePanels } from "../data/ws-panels";
import { translationMetadata } from "../resources/translations-metadata";
@@ -78,6 +80,12 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
target
)) as Promise<ServiceCallResponse>;
} catch (err) {
if (
err.error?.code === ERR_CONNECTION_LOST &&
serviceCallWillDisconnect(domain, service)
) {
throw err;
}
if (__DEV__) {
// eslint-disable-next-line no-console
console.error(

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