Merge pull request #8433 from home-assistant/dev

This commit is contained in:
Bram Kragten 2021-02-22 20:18:48 +01:00 committed by GitHub
commit 216526e391
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
127 changed files with 4595 additions and 1898 deletions

View File

@ -4,7 +4,7 @@
"dockerfile": "Dockerfile", "dockerfile": "Dockerfile",
"context": ".." "context": ".."
}, },
"appPort": 8123, "appPort": "8124:8123",
"context": "..", "context": "..",
"postCreateCommand": "script/bootstrap", "postCreateCommand": "script/bootstrap",
"extensions": [ "extensions": [

138
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@ -0,0 +1,138 @@
name: Report a bug with the UI, Frontend or Lovelace
about: Report an issue related to the Home Assistant frontend.
labels: bug
title: ""
issue_body: true
body:
- type: markdown
attributes:
value: |
Make sure you are running the [latest version of Home Assistant][releases] before reporting an issue.
If you have a feature or enhancement request for the frontend, please [start an discussion][fr] instead of creating an issue.
**Please not not report issues for custom Lovelace cards.**
[fr]: https://github.com/home-assistant/frontend/discussions
[releases]: https://github.com/home-assistant/home-assistant/releases
- type: checkboxes
attributes:
label: Checklist
description: Please verify that you've followed these steps
options:
- label: I have updated to the latest available Home Assistant version.
required: true
- label: I have cleared the cache of my browser.
required: true
- label: I have tried a different browser to see if it is related to my browser.
required: true
- type: markdown
attributes:
value: |
## The problem
- type: textarea
validations:
required: true
attributes:
label: Describe the issue you are experiencing
description: Provide a clear and concise description of what the bug is.
- type: textarea
validations:
required: true
attributes:
label: Describe the behavior you expected
description: Describe what you expected to happen or it should look/behave.
- type: textarea
validations:
required: true
attributes:
label: Steps to reproduce the issue
description: |
Please tell us exactly how to reproduce your issue.
Provide clear and concise step by step instructions and add code snippets if needed.
value: |
1.
2.
3.
...
- type: markdown
attributes:
value: |
## Environment
- type: input
validations:
required: true
attributes:
label: What version of Home Assistant Core has the issue?
placeholder: core-
description: >
Can be found in the Configuration panel -> Info.
- type: input
attributes:
label: What was the last working version of Home Assistant Core?
placeholder: core-
description: >
If known, otherwise leave blank.
- type: input
attributes:
label: In which browser are you experiencing the issue with?
placeholder: Google Chrome 88.0.4324.150
description: >
Provide the full name and don't forget to add the version!
- type: input
attributes:
label: Which operating system are you using to run this browser?
placeholder: macOS Big Sur (1.11)
description: >
Don't forget to add the version!
- type: markdown
attributes:
value: |
# Details
- type: textarea
attributes:
label: State of relevant entities
description: >
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.
```
- type: textarea
attributes:
label: Problem-relevant frontend configuration
description: >
An example configuration that caused the problem for you, e.g., the YAML
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.
```
- 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
attributes:
value: |
## Additional information
- type: markdown
attributes:
value: |
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.

View File

@ -1,7 +1,7 @@
const webpack = require("webpack"); const webpack = require("webpack");
const path = require("path"); const path = require("path");
const TerserPlugin = require("terser-webpack-plugin"); const TerserPlugin = require("terser-webpack-plugin");
const ManifestPlugin = require("webpack-manifest-plugin"); const { WebpackManifestPlugin } = require("webpack-manifest-plugin");
const paths = require("./paths.js"); const paths = require("./paths.js");
const bundle = require("./bundle"); const bundle = require("./bundle");
const log = require("fancy-log"); const log = require("fancy-log");
@ -68,7 +68,7 @@ const createWebpackConfig = ({
], ],
}, },
plugins: [ plugins: [
new ManifestPlugin({ new WebpackManifestPlugin({
// Only include the JS of entrypoints // Only include the JS of entrypoints
filter: (file) => file.isInitial && !file.name.endsWith(".map"), filter: (file) => file.isInitial && !file.name.endsWith(".map"),
}), }),

View File

@ -48,7 +48,7 @@ class HcCast extends LitElement {
protected render(): TemplateResult { protected render(): TemplateResult {
if (this.lovelaceConfig === undefined) { if (this.lovelaceConfig === undefined) {
return html` <hass-loading-screen no-toolbar></hass-loading-screen>> `; return html`<hass-loading-screen no-toolbar></hass-loading-screen>`;
} }
const error = const error =

View File

@ -11,19 +11,18 @@ import {
PropertyValues, PropertyValues,
} from "lit-element"; } from "lit-element";
import { html, TemplateResult } from "lit-html"; import { html, TemplateResult } from "lit-html";
import memoizeOne from "memoize-one";
import { atLeastVersion } from "../../../src/common/config/version"; import { atLeastVersion } from "../../../src/common/config/version";
import { fireEvent } from "../../../src/common/dom/fire_event"; import { fireEvent } from "../../../src/common/dom/fire_event";
import "../../../src/common/search/search-input"; import "../../../src/common/search/search-input";
import "../../../src/components/ha-button-menu"; import "../../../src/components/ha-button-menu";
import "../../../src/components/ha-svg-icon"; import "../../../src/components/ha-svg-icon";
import { import {
fetchHassioAddonsInfo,
HassioAddonInfo, HassioAddonInfo,
HassioAddonRepository, HassioAddonRepository,
reloadHassioAddons, reloadHassioAddons,
} from "../../../src/data/hassio/addon"; } from "../../../src/data/hassio/addon";
import { extractApiErrorMessage } from "../../../src/data/hassio/common"; import { Supervisor } from "../../../src/data/supervisor/supervisor";
import { fetchHassioSupervisorInfo } from "../../../src/data/hassio/supervisor";
import "../../../src/layouts/hass-loading-screen"; import "../../../src/layouts/hass-loading-screen";
import "../../../src/layouts/hass-tabs-subpage"; import "../../../src/layouts/hass-tabs-subpage";
import { HomeAssistant, Route } from "../../../src/types"; import { HomeAssistant, Route } from "../../../src/types";
@ -51,46 +50,27 @@ const sortRepos = (a: HassioAddonRepository, b: HassioAddonRepository) => {
class HassioAddonStore extends LitElement { class HassioAddonStore extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public supervisor!: Supervisor;
@property({ type: Boolean }) public narrow!: boolean; @property({ type: Boolean }) public narrow!: boolean;
@property({ attribute: false }) public route!: Route; @property({ attribute: false }) public route!: Route;
@property({ attribute: false }) private _addons?: HassioAddonInfo[];
@property({ attribute: false }) private _repos?: HassioAddonRepository[];
@internalProperty() private _filter?: string; @internalProperty() private _filter?: string;
public async refreshData() { public async refreshData() {
this._repos = undefined;
this._addons = undefined;
this._filter = undefined;
await reloadHassioAddons(this.hass); await reloadHassioAddons(this.hass);
await this._loadData(); await this._loadData();
} }
protected render(): TemplateResult { protected render(): TemplateResult {
const repos: TemplateResult[] = []; let repos: TemplateResult[] = [];
if (this._repos) { if (this.supervisor.addon.repositories) {
for (const repo of this._repos) { repos = this.addonRepositories(
const addons = this._addons!.filter( this.supervisor.addon.repositories,
(addon) => addon.repository === repo.slug this.supervisor.addon.addons
); );
if (addons.length === 0) {
continue;
}
repos.push(html`
<hassio-addon-repository
.hass=${this.hass}
.repo=${repo}
.addons=${addons}
.filter=${this._filter!}
></hassio-addon-repository>
`);
}
} }
return html` return html`
@ -159,6 +139,27 @@ class HassioAddonStore extends LitElement {
this._loadData(); this._loadData();
} }
private addonRepositories = memoizeOne(
(repositories: HassioAddonRepository[], addons: HassioAddonInfo[]) => {
return repositories.sort(sortRepos).map((repo) => {
const filteredAddons = addons.filter(
(addon) => addon.repository === repo.slug
);
return filteredAddons.length !== 0
? html`
<hassio-addon-repository
.hass=${this.hass}
.repo=${repo}
.addons=${filteredAddons}
.filter=${this._filter!}
></hassio-addon-repository>
`
: html``;
});
}
);
private _handleAction(ev: CustomEvent<ActionDetail>) { private _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) { switch (ev.detail.index) {
case 0: case 0:
@ -181,7 +182,7 @@ class HassioAddonStore extends LitElement {
private async _manageRepositories() { private async _manageRepositories() {
showRepositoriesDialog(this, { showRepositoriesDialog(this, {
repos: this._repos!, repos: this.supervisor.addon.repositories,
loadData: () => this._loadData(), loadData: () => this._loadData(),
}); });
} }
@ -191,18 +192,8 @@ class HassioAddonStore extends LitElement {
} }
private async _loadData() { private async _loadData() {
try { fireEvent(this, "supervisor-store-refresh", { store: "addon" });
const [addonsInfo, supervisor] = await Promise.all([ fireEvent(this, "supervisor-store-refresh", { store: "supervisor" });
fetchHassioAddonsInfo(this.hass),
fetchHassioSupervisorInfo(this.hass),
]);
fireEvent(this, "supervisor-update", { supervisor });
this._repos = addonsInfo.repositories;
this._repos.sort(sortRepos);
this._addons = addonsInfo.addons;
} catch (err) {
alert(extractApiErrorMessage(err));
}
} }
private async _filterChanged(e) { private async _filterChanged(e) {

View File

@ -29,7 +29,7 @@ class HassioAddonConfigDashboard extends LitElement {
const hasOptions = const hasOptions =
this.addon.options && Object.keys(this.addon.options).length; this.addon.options && Object.keys(this.addon.options).length;
const hasSchema = const hasSchema =
this.addon.schema && Object.keys(this.addon.schema).length; hasOptions && this.addon.schema && Object.keys(this.addon.schema).length;
return html` return html`
<div class="content"> <div class="content">

View File

@ -109,8 +109,8 @@ class HassioAddonConfig extends LitElement {
protected firstUpdated(changedProps) { protected firstUpdated(changedProps) {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
this._canShowSchema = this._canShowSchema =
this.addon.schema !== null && Object.keys(this.addon.options).length !== 0 &&
!this.addon.schema.find( !this.addon.schema!.find(
// @ts-ignore // @ts-ignore
(entry) => !SUPPORTED_UI_TYPES.includes(entry.type) || entry.multiple (entry) => !SUPPORTED_UI_TYPES.includes(entry.type) || entry.multiple
); );
@ -150,13 +150,11 @@ class HassioAddonConfig extends LitElement {
if (this.addon.schema && this._canShowSchema && !this._yamlMode) { if (this.addon.schema && this._canShowSchema && !this._yamlMode) {
this._valid = true; this._valid = true;
this._configHasChanged = true; this._configHasChanged = true;
this._options! = ev.detail.value;
} else { } else {
this._configHasChanged = true; this._configHasChanged = true;
this._valid = ev.detail.isValid; this._valid = ev.detail.isValid;
} }
if (this._valid) {
this._options! = ev.detail.value;
}
} }
private async _resetTapped(ev: CustomEvent): Promise<void> { private async _resetTapped(ev: CustomEvent): Promise<void> {
@ -204,8 +202,9 @@ class HassioAddonConfig extends LitElement {
try { try {
await setHassioAddonOption(this.hass, this.addon.slug, { await setHassioAddonOption(this.hass, this.addon.slug, {
options: this._options!, options: this._yamlMode ? this._editor?.value : this._options,
}); });
this._configHasChanged = false; this._configHasChanged = false;
const eventdata = { const eventdata = {
success: true, success: true,

View File

@ -9,17 +9,24 @@ import {
CSSResult, CSSResult,
customElement, customElement,
html, html,
internalProperty,
LitElement, LitElement,
property, property,
TemplateResult, TemplateResult,
} from "lit-element"; } from "lit-element";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../../src/common/dom/fire_event";
import { navigate } from "../../../src/common/navigate";
import { extractSearchParam } from "../../../src/common/url/search-params";
import "../../../src/components/ha-circular-progress"; import "../../../src/components/ha-circular-progress";
import { import {
fetchHassioAddonInfo, fetchHassioAddonInfo,
HassioAddonDetails, HassioAddonDetails,
} from "../../../src/data/hassio/addon"; } from "../../../src/data/hassio/addon";
import { extractApiErrorMessage } from "../../../src/data/hassio/common";
import { Supervisor } from "../../../src/data/supervisor/supervisor"; import { Supervisor } from "../../../src/data/supervisor/supervisor";
import "../../../src/layouts/hass-error-screen";
import "../../../src/layouts/hass-loading-screen";
import "../../../src/layouts/hass-tabs-subpage"; import "../../../src/layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../../src/layouts/hass-tabs-subpage"; import type { PageNavigation } from "../../../src/layouts/hass-tabs-subpage";
import { haStyle } from "../../../src/resources/styles"; import { haStyle } from "../../../src/resources/styles";
@ -44,6 +51,8 @@ class HassioAddonDashboard extends LitElement {
@property({ type: Boolean }) public narrow!: boolean; @property({ type: Boolean }) public narrow!: boolean;
@internalProperty() _error?: string;
private _computeTail = memoizeOne((route: Route) => { private _computeTail = memoizeOne((route: Route) => {
const dividerPos = route.path.indexOf("/", 1); const dividerPos = route.path.indexOf("/", 1);
return dividerPos === -1 return dividerPos === -1
@ -58,8 +67,14 @@ class HassioAddonDashboard extends LitElement {
}); });
protected render(): TemplateResult { protected render(): TemplateResult {
if (this._error) {
return html`<hass-error-screen
.error=${this._error}
></hass-error-screen>`;
}
if (!this.addon) { if (!this.addon) {
return html`<ha-circular-progress active></ha-circular-progress>`; return html`<hass-loading-screen></hass-loading-screen>`;
} }
const addonTabs: PageNavigation[] = [ const addonTabs: PageNavigation[] = [
@ -156,30 +171,51 @@ class HassioAddonDashboard extends LitElement {
} }
protected async firstUpdated(): Promise<void> { protected async firstUpdated(): Promise<void> {
await this._routeDataChanged(this.route); if (this.route.path === "") {
const addon = extractSearchParam("addon");
if (addon) {
navigate(this, `/hassio/addon/${addon}`, true);
}
}
this.addEventListener("hass-api-called", (ev) => this._apiCalled(ev)); this.addEventListener("hass-api-called", (ev) => this._apiCalled(ev));
} }
private async _apiCalled(ev): Promise<void> { private async _apiCalled(ev): Promise<void> {
const path: string = ev.detail.path; const pathSplit: string[] = ev.detail.path?.split("/");
if (!path) { if (!pathSplit || pathSplit.length === 0) {
return; return;
} }
const path: string = pathSplit[pathSplit.length - 1];
if (["uninstall", "install", "update", "start", "stop"].includes(path)) {
fireEvent(this, "supervisor-store-refresh", { store: "supervisor" });
}
if (path === "uninstall") { if (path === "uninstall") {
history.back(); window.history.back();
} else { } else {
await this._routeDataChanged(this.route); await this._routeDataChanged();
} }
} }
private async _routeDataChanged(routeData: Route): Promise<void> { protected updated(changedProperties) {
const addon = routeData.path.split("/")[1]; if (changedProperties.has("route") && !this.addon) {
this._routeDataChanged();
}
}
private async _routeDataChanged(): Promise<void> {
const addon = this.route.path.split("/")[1];
if (!addon) {
return;
}
try { try {
const addoninfo = await fetchHassioAddonInfo(this.hass, addon); const addoninfo = await fetchHassioAddonInfo(this.hass, addon);
this.addon = addoninfo; this.addon = addoninfo;
} catch { } catch (err) {
this._error = `Error fetching addon info: ${extractApiErrorMessage(err)}`;
this.addon = undefined; this.addon = undefined;
} }
} }

View File

@ -43,10 +43,13 @@ import {
HassioAddonSetOptionParams, HassioAddonSetOptionParams,
HassioAddonSetSecurityParams, HassioAddonSetSecurityParams,
installHassioAddon, installHassioAddon,
restartHassioAddon,
setHassioAddonOption, setHassioAddonOption,
setHassioAddonSecurity, setHassioAddonSecurity,
startHassioAddon, startHassioAddon,
stopHassioAddon,
uninstallHassioAddon, uninstallHassioAddon,
updateHassioAddon,
validateHassioAddonOption, validateHassioAddonOption,
} from "../../../../src/data/hassio/addon"; } from "../../../../src/data/hassio/addon";
import { import {
@ -196,13 +199,9 @@ class HassioAddonInfo extends LitElement {
: ""} : ""}
</div> </div>
<div class="card-actions"> <div class="card-actions">
<ha-call-api-button <ha-progress-button @click=${this._updateClicked}>
.hass=${this.hass}
.disabled=${!this.addon.available}
path="hassio/addons/${this.addon.slug}/update"
>
Update Update
</ha-call-api-button> </ha-progress-button>
${this.addon.changelog ${this.addon.changelog
? html` ? html`
<mwc-button @click=${this._openChangelog}> <mwc-button @click=${this._openChangelog}>
@ -579,20 +578,18 @@ class HassioAddonInfo extends LitElement {
${this.addon.version ${this.addon.version
? this._computeIsRunning ? this._computeIsRunning
? html` ? html`
<ha-call-api-button <ha-progress-button
class="warning" class="warning"
.hass=${this.hass} @click=${this._stopClicked}
.path="hassio/addons/${this.addon.slug}/stop"
> >
Stop Stop
</ha-call-api-button> </ha-progress-button>
<ha-call-api-button <ha-progress-button
class="warning" class="warning"
.hass=${this.hass} @click=${this._restartClicked}
.path="hassio/addons/${this.addon.slug}/restart"
> >
Restart Restart
</ha-call-api-button> </ha-progress-button>
` `
: html` : html`
<ha-progress-button @click=${this._startClicked}> <ha-progress-button @click=${this._startClicked}>
@ -883,6 +880,82 @@ class HassioAddonInfo extends LitElement {
button.progress = false; button.progress = false;
} }
private async _stopClicked(ev: CustomEvent): Promise<void> {
const button = ev.currentTarget as any;
button.progress = true;
try {
await stopHassioAddon(this.hass, this.addon.slug);
const eventdata = {
success: true,
response: undefined,
path: "stop",
};
fireEvent(this, "hass-api-called", eventdata);
} catch (err) {
showAlertDialog(this, {
title: "Failed to stop addon",
text: extractApiErrorMessage(err),
});
}
button.progress = false;
}
private async _restartClicked(ev: CustomEvent): Promise<void> {
const button = ev.currentTarget as any;
button.progress = true;
try {
await restartHassioAddon(this.hass, this.addon.slug);
const eventdata = {
success: true,
response: undefined,
path: "stop",
};
fireEvent(this, "hass-api-called", eventdata);
} catch (err) {
showAlertDialog(this, {
title: "Failed to restart addon",
text: extractApiErrorMessage(err),
});
}
button.progress = false;
}
private async _updateClicked(ev: CustomEvent): Promise<void> {
const button = ev.currentTarget as any;
button.progress = true;
const confirmed = await showConfirmationDialog(this, {
title: this.addon.name,
text: "Are you sure you want to update this add-on?",
confirmText: "update add-on",
dismissText: "no",
});
if (!confirmed) {
button.progress = false;
return;
}
this._error = undefined;
try {
await updateHassioAddon(this.hass, this.addon.slug);
const eventdata = {
success: true,
response: undefined,
path: "update",
};
fireEvent(this, "hass-api-called", eventdata);
} catch (err) {
showAlertDialog(this, {
title: "Failed to update addon",
text: extractApiErrorMessage(err),
});
}
button.progress = false;
}
private async _startClicked(ev: CustomEvent): Promise<void> { private async _startClicked(ev: CustomEvent): Promise<void> {
const button = ev.currentTarget as any; const button = ev.currentTarget as any;
button.progress = true; button.progress = true;
@ -891,10 +964,10 @@ class HassioAddonInfo extends LitElement {
this.hass, this.hass,
this.addon.slug this.addon.slug
); );
if (!validate.data.valid) { if (!validate.valid) {
await showConfirmationDialog(this, { await showConfirmationDialog(this, {
title: "Failed to start addon - configuration validation failed!", title: "Failed to start addon - configuration validation failed!",
text: validate.data.message.split(" Got ")[0], text: validate.message.split(" Got ")[0],
confirm: () => this._openConfiguration(), confirm: () => this._openConfiguration(),
confirmText: "Go to configuration", confirmText: "Go to configuration",
dismissText: "Cancel", dismissText: "Cancel",
@ -914,6 +987,12 @@ class HassioAddonInfo extends LitElement {
try { try {
await startHassioAddon(this.hass, this.addon.slug); await startHassioAddon(this.hass, this.addon.slug);
this.addon = await fetchHassioAddonInfo(this.hass, this.addon.slug); this.addon = await fetchHassioAddonInfo(this.hass, this.addon.slug);
const eventdata = {
success: true,
response: undefined,
path: "start",
};
fireEvent(this, "hass-api-called", eventdata);
} catch (err) { } catch (err) {
showAlertDialog(this, { showAlertDialog(this, {
title: "Failed to start addon", title: "Failed to start addon",

View File

@ -10,6 +10,7 @@ import {
TemplateResult, TemplateResult,
} from "lit-element"; } from "lit-element";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../../src/common/dom/fire_event";
import "../../../src/components/buttons/ha-progress-button"; import "../../../src/components/buttons/ha-progress-button";
import "../../../src/components/ha-card"; import "../../../src/components/ha-card";
import "../../../src/components/ha-svg-icon"; import "../../../src/components/ha-svg-icon";
@ -64,6 +65,7 @@ export class HassioUpdate extends LitElement {
<div class="card-group"> <div class="card-group">
${this._renderUpdateCard( ${this._renderUpdateCard(
"Home Assistant Core", "Home Assistant Core",
"core",
this.supervisor.core, this.supervisor.core,
"hassio/homeassistant/update", "hassio/homeassistant/update",
`https://${ `https://${
@ -72,6 +74,7 @@ export class HassioUpdate extends LitElement {
)} )}
${this._renderUpdateCard( ${this._renderUpdateCard(
"Supervisor", "Supervisor",
"supervisor",
this.supervisor.supervisor, this.supervisor.supervisor,
"hassio/supervisor/update", "hassio/supervisor/update",
`https://github.com//home-assistant/hassio/releases/tag/${this.supervisor.supervisor.version_latest}` `https://github.com//home-assistant/hassio/releases/tag/${this.supervisor.supervisor.version_latest}`
@ -79,6 +82,7 @@ export class HassioUpdate extends LitElement {
${this.supervisor.host.features.includes("hassos") ${this.supervisor.host.features.includes("hassos")
? this._renderUpdateCard( ? this._renderUpdateCard(
"Operating System", "Operating System",
"os",
this.supervisor.os, this.supervisor.os,
"hassio/os/update", "hassio/os/update",
`https://github.com//home-assistant/hassos/releases/tag/${this.supervisor.os.version_latest}` `https://github.com//home-assistant/hassos/releases/tag/${this.supervisor.os.version_latest}`
@ -91,6 +95,7 @@ export class HassioUpdate extends LitElement {
private _renderUpdateCard( private _renderUpdateCard(
name: string, name: string,
key: string,
object: HassioHomeAssistantInfo | HassioSupervisorInfo | HassioHassOSInfo, object: HassioHomeAssistantInfo | HassioSupervisorInfo | HassioHassOSInfo,
apiPath: string, apiPath: string,
releaseNotesUrl: string releaseNotesUrl: string
@ -116,6 +121,7 @@ export class HassioUpdate extends LitElement {
<ha-progress-button <ha-progress-button
.apiPath=${apiPath} .apiPath=${apiPath}
.name=${name} .name=${name}
.key=${key}
.version=${object.version_latest} .version=${object.version_latest}
@click=${this._confirmUpdate} @click=${this._confirmUpdate}
> >
@ -142,6 +148,7 @@ export class HassioUpdate extends LitElement {
} }
try { try {
await this.hass.callApi<HassioResponse<void>>("POST", item.apiPath); await this.hass.callApi<HassioResponse<void>>("POST", item.apiPath);
fireEvent(this, "supervisor-store-refresh", { store: item.key });
} catch (err) { } catch (err) {
// Only show an error if the status code was not expected (user behind proxy) // Only show an error if the status code was not expected (user behind proxy)
// or no status at all(connection terminated) // or no status at all(connection terminated)

View File

@ -22,7 +22,11 @@ import {
fetchHassioSnapshotInfo, fetchHassioSnapshotInfo,
HassioSnapshotDetail, HassioSnapshotDetail,
} from "../../../../src/data/hassio/snapshot"; } from "../../../../src/data/hassio/snapshot";
import { showConfirmationDialog } from "../../../../src/dialogs/generic/show-dialog-box"; import { Supervisor } from "../../../../src/data/supervisor/supervisor";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../../src/dialogs/generic/show-dialog-box";
import { PolymerChangedEvent } from "../../../../src/polymer-types"; import { PolymerChangedEvent } from "../../../../src/polymer-types";
import { haStyle, haStyleDialog } from "../../../../src/resources/styles"; import { haStyle, haStyleDialog } from "../../../../src/resources/styles";
import { HomeAssistant } from "../../../../src/types"; import { HomeAssistant } from "../../../../src/types";
@ -75,6 +79,8 @@ interface FolderItem {
class HassioSnapshotDialog extends LitElement { class HassioSnapshotDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public supervisor?: Supervisor;
@internalProperty() private _error?: string; @internalProperty() private _error?: string;
@internalProperty() private _onboarding = false; @internalProperty() private _onboarding = false;
@ -102,6 +108,7 @@ class HassioSnapshotDialog extends LitElement {
this._dialogParams = params; this._dialogParams = params;
this._onboarding = params.onboarding ?? false; this._onboarding = params.onboarding ?? false;
this.supervisor = params.supervisor;
} }
protected render(): TemplateResult { protected render(): TemplateResult {
@ -298,6 +305,16 @@ class HassioSnapshotDialog extends LitElement {
} }
private async _partialRestoreClicked() { private async _partialRestoreClicked() {
if (
this.supervisor !== undefined &&
this.supervisor.info.state !== "running"
) {
await showAlertDialog(this, {
title: "Could not restore snapshot",
text: `Restoring a snapshot is not possible right now because the system is in ${this.supervisor.info.state} state.`,
});
return;
}
if ( if (
!(await showConfirmationDialog(this, { !(await showConfirmationDialog(this, {
title: "Are you sure you want partially to restore this snapshot?", title: "Are you sure you want partially to restore this snapshot?",
@ -359,6 +376,16 @@ class HassioSnapshotDialog extends LitElement {
} }
private async _fullRestoreClicked() { private async _fullRestoreClicked() {
if (
this.supervisor !== undefined &&
this.supervisor.info.state !== "running"
) {
await showAlertDialog(this, {
title: "Could not restore snapshot",
text: `Restoring a snapshot is not possible right now because the system is in ${this.supervisor.info.state} state.`,
});
return;
}
if ( if (
!(await showConfirmationDialog(this, { !(await showConfirmationDialog(this, {
title: title:

View File

@ -1,9 +1,11 @@
import { fireEvent } from "../../../../src/common/dom/fire_event"; import { fireEvent } from "../../../../src/common/dom/fire_event";
import { Supervisor } from "../../../../src/data/supervisor/supervisor";
export interface HassioSnapshotDialogParams { export interface HassioSnapshotDialogParams {
slug: string; slug: string;
onDelete?: () => void; onDelete?: () => void;
onboarding?: boolean; onboarding?: boolean;
supervisor?: Supervisor;
} }
export const showHassioSnapshotDialog = ( export const showHassioSnapshotDialog = (

View File

@ -3,7 +3,9 @@ import { atLeastVersion } from "../../src/common/config/version";
import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element";
import { fireEvent } from "../../src/common/dom/fire_event"; import { fireEvent } from "../../src/common/dom/fire_event";
import { HassioPanelInfo } from "../../src/data/hassio/supervisor"; import { HassioPanelInfo } from "../../src/data/hassio/supervisor";
import { supervisorStore } from "../../src/data/supervisor/supervisor";
import { makeDialogManager } from "../../src/dialogs/make-dialog-manager"; import { makeDialogManager } from "../../src/dialogs/make-dialog-manager";
import "../../src/layouts/hass-loading-screen";
import { HomeAssistant, Route } from "../../src/types"; import { HomeAssistant, Route } from "../../src/types";
import "./hassio-router"; import "./hassio-router";
import { SupervisorBaseElement } from "./supervisor-base-element"; import { SupervisorBaseElement } from "./supervisor-base-element";
@ -71,8 +73,15 @@ export class HassioMain extends SupervisorBaseElement {
protected render() { protected render() {
if (!this.supervisor || !this.hass) { if (!this.supervisor || !this.hass) {
return html``; return html`<hass-loading-screen></hass-loading-screen>`;
} }
if (
Object.keys(supervisorStore).some((store) => !this.supervisor![store])
) {
return html`<hass-loading-screen></hass-loading-screen>`;
}
return html` return html`
<hassio-router <hassio-router
.hass=${this.hass} .hass=${this.hass}

View File

@ -0,0 +1,128 @@
import {
customElement,
html,
internalProperty,
LitElement,
property,
TemplateResult,
} from "lit-element";
import { sanitizeUrl } from "@braintree/sanitize-url";
import {
createSearchParam,
extractSearchParamsObject,
} from "../../src/common/url/search-params";
import "../../src/layouts/hass-error-screen";
import {
ParamType,
Redirect,
Redirects,
} from "../../src/panels/my/ha-panel-my";
import { navigate } from "../../src/common/navigate";
import { HomeAssistant, Route } from "../../src/types";
const REDIRECTS: Redirects = {
supervisor_logs: {
redirect: "/hassio/system",
},
supervisor_info: {
redirect: "/hassio/system",
},
supervisor_snapshots: {
redirect: "/hassio/snapshots",
},
supervisor_store: {
redirect: "/hassio/store",
},
supervisor: {
redirect: "/hassio/dashboard",
},
supervisor_addon: {
redirect: "/hassio/addon",
params: {
addon: "string",
},
},
};
@customElement("hassio-my-redirect")
class HassioMyRedirect extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public route!: Route;
@internalProperty() public _error?: TemplateResult | string;
connectedCallback() {
super.connectedCallback();
const path = this.route.path.substr(1);
const redirect = REDIRECTS[path];
if (!redirect) {
this._error = html`This redirect is not supported by your Home Assistant
instance. Check the
<a
target="_blank"
rel="noreferrer noopener"
href="https://my.home-assistant.io/faq.html#supported-pages"
>My Home Assistant FAQ</a
>
for the supported redirects and the version they where introduced.`;
return;
}
let url: string;
try {
url = this._createRedirectUrl(redirect);
} catch (err) {
this._error = "An unknown error occured";
return;
}
navigate(this, url, true);
}
protected render(): TemplateResult {
if (this._error) {
return html`<hass-error-screen
.error=${this._error}
></hass-error-screen>`;
}
return html``;
}
private _createRedirectUrl(redirect: Redirect): string {
const params = this._createRedirectParams(redirect);
return `${redirect.redirect}${params}`;
}
private _createRedirectParams(redirect: Redirect): string {
const params = extractSearchParamsObject();
if (!redirect.params && !Object.keys(params).length) {
return "";
}
const resultParams = {};
Object.entries(redirect.params || {}).forEach(([key, type]) => {
if (!params[key] || !this._checkParamType(type, params[key])) {
throw Error();
}
resultParams[key] = params[key];
});
return `?${createSearchParam(resultParams)}`;
}
private _checkParamType(type: ParamType, value: string) {
if (type === "string") {
return true;
}
if (type === "url") {
return value && value === sanitizeUrl(value);
}
return false;
}
}
declare global {
interface HTMLElementTagNameMap {
"hassio-my-redirect": HassioMyRedirect;
}
}

View File

@ -41,6 +41,10 @@ class HassioRouter extends HassRouterPage {
tag: "hassio-ingress-view", tag: "hassio-ingress-view",
load: () => import("./ingress-view/hassio-ingress-view"), load: () => import("./ingress-view/hassio-ingress-view"),
}, },
_my_redirect: {
tag: "hassio-my-redirect",
load: () => import("./hassio-my-redirect"),
},
}, },
}; };
@ -49,12 +53,13 @@ class HassioRouter extends HassRouterPage {
const route = el.nodeName === "HASSIO-PANEL" ? this.route : this.routeTail; const route = el.nodeName === "HASSIO-PANEL" ? this.route : this.routeTail;
el.hass = this.hass; el.hass = this.hass;
el.supervisor = this.supervisor;
el.narrow = this.narrow; el.narrow = this.narrow;
el.route = route; el.route = route;
if (el.localName === "hassio-ingress-view") { if (el.localName === "hassio-ingress-view") {
el.ingressPanel = this.panel.config && this.panel.config.ingress; el.ingressPanel = this.panel.config && this.panel.config.ingress;
} else {
el.supervisor = this.supervisor;
} }
} }

View File

@ -41,6 +41,7 @@ import {
reloadHassioSnapshots, reloadHassioSnapshots,
} from "../../../src/data/hassio/snapshot"; } from "../../../src/data/hassio/snapshot";
import { Supervisor } from "../../../src/data/supervisor/supervisor"; import { Supervisor } from "../../../src/data/supervisor/supervisor";
import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box";
import "../../../src/layouts/hass-tabs-subpage"; import "../../../src/layouts/hass-tabs-subpage";
import { PolymerChangedEvent } from "../../../src/polymer-types"; import { PolymerChangedEvent } from "../../../src/polymer-types";
import { haStyle } from "../../../src/resources/styles"; import { haStyle } from "../../../src/resources/styles";
@ -211,7 +212,13 @@ class HassioSnapshots extends LitElement {
: undefined} : undefined}
</div> </div>
<div class="card-actions"> <div class="card-actions">
<ha-progress-button @click=${this._createSnapshot}> <ha-progress-button
@click=${this._createSnapshot}
title="${this.supervisor.info.state !== "running"
? `Creating a snapshot is not possible right now because the system is in ${this.supervisor.info.state} state.`
: ""}"
.disabled=${this.supervisor.info.state !== "running"}
>
Create Create
</ha-progress-button> </ha-progress-button>
</div> </div>
@ -325,6 +332,12 @@ class HassioSnapshots extends LitElement {
} }
private async _createSnapshot(ev: CustomEvent): Promise<void> { private async _createSnapshot(ev: CustomEvent): Promise<void> {
if (this.supervisor.info.state !== "running") {
await showAlertDialog(this, {
title: "Could not create snapshot",
text: `Creating a snapshot is not possible right now because the system is in ${this.supervisor.info.state} state.`,
});
}
const button = ev.currentTarget as any; const button = ev.currentTarget as any;
button.progress = true; button.progress = true;
@ -386,6 +399,7 @@ class HassioSnapshots extends LitElement {
private _snapshotClicked(ev) { private _snapshotClicked(ev) {
showHassioSnapshotDialog(this, { showHassioSnapshotDialog(this, {
slug: ev.currentTarget!.snapshot.slug, slug: ev.currentTarget!.snapshot.slug,
supervisor: this.supervisor,
onDelete: () => this._updateSnapshots(), onDelete: () => this._updateSnapshots(),
}); });
} }
@ -395,6 +409,7 @@ class HassioSnapshots extends LitElement {
showSnapshot: (slug: string) => showSnapshot: (slug: string) =>
showHassioSnapshotDialog(this, { showHassioSnapshotDialog(this, {
slug, slug,
supervisor: this.supervisor,
onDelete: () => this._updateSnapshots(), onDelete: () => this._updateSnapshots(),
}), }),
reloadSnapshot: () => this.refreshData(), reloadSnapshot: () => this.refreshData(),

View File

@ -1,4 +1,13 @@
import { LitElement, property, PropertyValues } from "lit-element"; import { Collection, UnsubscribeFunc } from "home-assistant-js-websocket";
import {
internalProperty,
LitElement,
property,
PropertyValues,
} from "lit-element";
import { atLeastVersion } from "../../src/common/config/version";
import { fetchHassioAddonsInfo } from "../../src/data/hassio/addon";
import { HassioResponse } from "../../src/data/hassio/common";
import { import {
fetchHassioHassOsInfo, fetchHassioHassOsInfo,
fetchHassioHostInfo, fetchHassioHostInfo,
@ -10,13 +19,20 @@ import {
fetchHassioInfo, fetchHassioInfo,
fetchHassioSupervisorInfo, fetchHassioSupervisorInfo,
} from "../../src/data/hassio/supervisor"; } from "../../src/data/hassio/supervisor";
import { Supervisor } from "../../src/data/supervisor/supervisor"; import {
getSupervisorEventCollection,
subscribeSupervisorEvents,
Supervisor,
SupervisorObject,
supervisorStore,
} from "../../src/data/supervisor/supervisor";
import { ProvideHassLitMixin } from "../../src/mixins/provide-hass-lit-mixin"; import { ProvideHassLitMixin } from "../../src/mixins/provide-hass-lit-mixin";
import { urlSyncMixin } from "../../src/state/url-sync-mixin"; import { urlSyncMixin } from "../../src/state/url-sync-mixin";
declare global { declare global {
interface HASSDomEvents { interface HASSDomEvents {
"supervisor-update": Partial<Supervisor>; "supervisor-update": Partial<Supervisor>;
"supervisor-store-refresh": { store: SupervisorObject };
} }
} }
@ -25,6 +41,20 @@ export class SupervisorBaseElement extends urlSyncMixin(
) { ) {
@property({ attribute: false }) public supervisor?: Supervisor; @property({ attribute: false }) public supervisor?: Supervisor;
@internalProperty() private _unsubs: Record<string, UnsubscribeFunc> = {};
@internalProperty() private _collections: Record<
string,
Collection<unknown>
> = {};
public disconnectedCallback() {
super.disconnectedCallback();
Object.keys(this._unsubs).forEach((unsub) => {
this._unsubs[unsub]();
});
}
protected _updateSupervisor(obj: Partial<Supervisor>): void { protected _updateSupervisor(obj: Partial<Supervisor>): void {
this.supervisor = { ...this.supervisor!, ...obj }; this.supervisor = { ...this.supervisor!, ...obj };
} }
@ -32,13 +62,59 @@ export class SupervisorBaseElement extends urlSyncMixin(
protected firstUpdated(changedProps: PropertyValues): void { protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
this._initSupervisor(); this._initSupervisor();
this.addEventListener("supervisor-update", (ev) => }
this._updateSupervisor(ev.detail)
private async _handleSupervisorStoreRefreshEvent(ev) {
const store = ev.detail.store;
if (atLeastVersion(this.hass.config.version, 2021, 2, 4)) {
this._collections[store].refresh();
return;
}
const response = await this.hass.callApi<HassioResponse<any>>(
"GET",
`hassio${supervisorStore[store]}`
); );
this._updateSupervisor({ [store]: response.data });
} }
private async _initSupervisor(): Promise<void> { private async _initSupervisor(): Promise<void> {
this.addEventListener(
"supervisor-store-refresh",
this._handleSupervisorStoreRefreshEvent
);
if (atLeastVersion(this.hass.config.version, 2021, 2, 4)) {
Object.keys(supervisorStore).forEach((store) => {
this._unsubs[store] = subscribeSupervisorEvents(
this.hass,
(data) => this._updateSupervisor({ [store]: data }),
store,
supervisorStore[store]
);
if (this._collections[store]) {
this._collections[store].refresh();
} else {
this._collections[store] = getSupervisorEventCollection(
this.hass.connection,
store,
supervisorStore[store]
);
}
});
if (this.supervisor === undefined) {
Object.keys(this._collections).forEach((collection) =>
this._updateSupervisor({
[collection]: this._collections[collection].state,
})
);
}
return;
}
const [ const [
addon,
supervisor, supervisor,
host, host,
core, core,
@ -47,6 +123,7 @@ export class SupervisorBaseElement extends urlSyncMixin(
network, network,
resolution, resolution,
] = await Promise.all([ ] = await Promise.all([
fetchHassioAddonsInfo(this.hass),
fetchHassioSupervisorInfo(this.hass), fetchHassioSupervisorInfo(this.hass),
fetchHassioHostInfo(this.hass), fetchHassioHostInfo(this.hass),
fetchHassioHomeAssistantInfo(this.hass), fetchHassioHomeAssistantInfo(this.hass),
@ -57,6 +134,7 @@ export class SupervisorBaseElement extends urlSyncMixin(
]); ]);
this.supervisor = { this.supervisor = {
addon,
supervisor, supervisor,
host, host,
core, core,
@ -65,5 +143,9 @@ export class SupervisorBaseElement extends urlSyncMixin(
network, network,
resolution, resolution,
}; };
this.addEventListener("supervisor-update", (ev) =>
this._updateSupervisor(ev.detail)
);
} }
} }

View File

@ -10,6 +10,7 @@ import {
property, property,
TemplateResult, TemplateResult,
} from "lit-element"; } from "lit-element";
import { fireEvent } from "../../../src/common/dom/fire_event";
import "../../../src/components/buttons/ha-progress-button"; import "../../../src/components/buttons/ha-progress-button";
import "../../../src/components/ha-button-menu"; import "../../../src/components/ha-button-menu";
import "../../../src/components/ha-card"; import "../../../src/components/ha-card";
@ -166,6 +167,7 @@ class HassioCoreInfo extends LitElement {
try { try {
await updateCore(this.hass); await updateCore(this.hass);
fireEvent(this, "supervisor-store-refresh", { store: "core" });
} catch (err) { } catch (err) {
showAlertDialog(this, { showAlertDialog(this, {
title: "Failed to update Home Assistant Core", title: "Failed to update Home Assistant Core",

View File

@ -13,6 +13,7 @@ import {
TemplateResult, TemplateResult,
} from "lit-element"; } from "lit-element";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { atLeastVersion } from "../../../src/common/config/version";
import { fireEvent } from "../../../src/common/dom/fire_event"; import { fireEvent } from "../../../src/common/dom/fire_event";
import "../../../src/components/buttons/ha-progress-button"; import "../../../src/components/buttons/ha-progress-button";
import "../../../src/components/ha-button-menu"; import "../../../src/components/ha-button-menu";
@ -26,7 +27,6 @@ import { fetchHassioHardwareInfo } from "../../../src/data/hassio/hardware";
import { import {
changeHostOptions, changeHostOptions,
configSyncOS, configSyncOS,
fetchHassioHostInfo,
rebootHost, rebootHost,
shutdownHost, shutdownHost,
updateOS, updateOS,
@ -340,6 +340,7 @@ class HassioHostInfo extends LitElement {
try { try {
await updateOS(this.hass); await updateOS(this.hass);
fireEvent(this, "supervisor-store-refresh", { store: "os" });
} catch (err) { } catch (err) {
showAlertDialog(this, { showAlertDialog(this, {
title: "Failed to update", title: "Failed to update",
@ -368,8 +369,7 @@ class HassioHostInfo extends LitElement {
if (hostname && hostname !== curHostname) { if (hostname && hostname !== curHostname) {
try { try {
await changeHostOptions(this.hass, { hostname }); await changeHostOptions(this.hass, { hostname });
const host = await fetchHassioHostInfo(this.hass); fireEvent(this, "supervisor-store-refresh", { store: "host" });
fireEvent(this, "supervisor-update", { host });
} catch (err) { } catch (err) {
showAlertDialog(this, { showAlertDialog(this, {
title: "Setting hostname failed", title: "Setting hostname failed",
@ -382,8 +382,7 @@ class HassioHostInfo extends LitElement {
private async _importFromUSB(): Promise<void> { private async _importFromUSB(): Promise<void> {
try { try {
await configSyncOS(this.hass); await configSyncOS(this.hass);
const host = await fetchHassioHostInfo(this.hass); fireEvent(this, "supervisor-store-refresh", { store: "host" });
fireEvent(this, "supervisor-update", { host });
} catch (err) { } catch (err) {
showAlertDialog(this, { showAlertDialog(this, {
title: "Failed to import from USB", title: "Failed to import from USB",
@ -393,8 +392,12 @@ class HassioHostInfo extends LitElement {
} }
private async _loadData(): Promise<void> { private async _loadData(): Promise<void> {
const network = await fetchNetworkInfo(this.hass); if (atLeastVersion(this.hass.config.version, 2021, 2, 4)) {
fireEvent(this, "supervisor-update", { network }); fireEvent(this, "supervisor-store-refresh", { store: "network" });
} else {
const network = await fetchNetworkInfo(this.hass);
fireEvent(this, "supervisor-update", { network });
}
} }
static get styles(): CSSResult[] { static get styles(): CSSResult[] {

View File

@ -19,7 +19,6 @@ import {
HassioStats, HassioStats,
} from "../../../src/data/hassio/common"; } from "../../../src/data/hassio/common";
import { import {
fetchHassioSupervisorInfo,
reloadSupervisor, reloadSupervisor,
restartSupervisor, restartSupervisor,
setSupervisorOption, setSupervisorOption,
@ -318,8 +317,7 @@ class HassioSupervisorInfo extends LitElement {
private async _reloadSupervisor(): Promise<void> { private async _reloadSupervisor(): Promise<void> {
await reloadSupervisor(this.hass); await reloadSupervisor(this.hass);
const supervisor = await fetchHassioSupervisorInfo(this.hass); fireEvent(this, "supervisor-store-refresh", { store: "supervisor" });
fireEvent(this, "supervisor-update", { supervisor });
} }
private async _supervisorRestart(ev: CustomEvent): Promise<void> { private async _supervisorRestart(ev: CustomEvent): Promise<void> {
@ -368,6 +366,7 @@ class HassioSupervisorInfo extends LitElement {
try { try {
await updateSupervisor(this.hass); await updateSupervisor(this.hass);
fireEvent(this, "supervisor-store-refresh", { store: "supervisor" });
} catch (err) { } catch (err) {
showAlertDialog(this, { showAlertDialog(this, {
title: "Failed to update the supervisor", title: "Failed to update the supervisor",

View File

@ -101,7 +101,7 @@
"fuse.js": "^6.0.0", "fuse.js": "^6.0.0",
"google-timezones-json": "^1.0.2", "google-timezones-json": "^1.0.2",
"hls.js": "^0.13.2", "hls.js": "^0.13.2",
"home-assistant-js-websocket": "^5.4.1", "home-assistant-js-websocket": "^5.9.0",
"idb-keyval": "^3.2.0", "idb-keyval": "^3.2.0",
"intl-messageformat": "^8.3.9", "intl-messageformat": "^8.3.9",
"js-yaml": "^3.13.1", "js-yaml": "^3.13.1",
@ -110,7 +110,7 @@
"lit-element": "^2.4.0", "lit-element": "^2.4.0",
"lit-html": "^1.3.0", "lit-html": "^1.3.0",
"lit-virtualizer": "^0.4.2", "lit-virtualizer": "^0.4.2",
"marked": "^1.1.1", "marked": "2.0.0",
"mdn-polyfills": "^5.16.0", "mdn-polyfills": "^5.16.0",
"memoize-one": "^5.0.2", "memoize-one": "^5.0.2",
"node-vibrant": "3.2.1-alpha.1", "node-vibrant": "3.2.1-alpha.1",
@ -161,7 +161,7 @@
"@types/js-yaml": "^3.12.1", "@types/js-yaml": "^3.12.1",
"@types/leaflet": "^1.4.3", "@types/leaflet": "^1.4.3",
"@types/leaflet-draw": "^1.0.1", "@types/leaflet-draw": "^1.0.1",
"@types/marked": "^1.1.0", "@types/marked": "^1.2.2",
"@types/memoize-one": "4.1.0", "@types/memoize-one": "4.1.0",
"@types/mocha": "^7.0.2", "@types/mocha": "^7.0.2",
"@types/resize-observer-browser": "^0.1.3", "@types/resize-observer-browser": "^0.1.3",
@ -222,7 +222,7 @@
"webpack": "5.1.3", "webpack": "5.1.3",
"webpack-cli": "4.1.0", "webpack-cli": "4.1.0",
"webpack-dev-server": "^3.11.0", "webpack-dev-server": "^3.11.0",
"webpack-manifest-plugin": "3.0.0-rc.0", "webpack-manifest-plugin": "~3.0.0",
"workbox-build": "^5.1.3" "workbox-build": "^5.1.3"
}, },
"_comment": "Polymer fixed to 3.1 because 3.2 throws on logbook page", "_comment": "Polymer fixed to 3.1 because 3.2 throws on logbook page",

View File

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

View File

@ -1,11 +1,15 @@
export const atLeastVersion = ( export const atLeastVersion = (
version: string, version: string,
major: number, major: number,
minor: number minor: number,
patch?: number
): boolean => { ): boolean => {
const [haMajor, haMinor] = version.split(".", 2); const [haMajor, haMinor, haPatch] = version.split(".", 3);
return ( return (
Number(haMajor) > major || Number(haMajor) > major ||
(Number(haMajor) === major && Number(haMinor) >= minor) (Number(haMajor) === major && Number(haMinor) >= minor) ||
(patch !== undefined &&
Number(haMajor) === major && Number(haMinor) === minor &&
Number(haPatch) >= patch)
); );
}; };

View File

@ -1,16 +1,12 @@
import "@material/mwc-icon-button/mwc-icon-button";
import { mdiClose, mdiMenuDown, mdiMenuUp } from "@mdi/js";
import "@polymer/paper-input/paper-input";
import "@polymer/paper-item/paper-item"; import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body"; import "@polymer/paper-item/paper-item-body";
import "@polymer/paper-listbox/paper-listbox";
import "@vaadin/vaadin-combo-box/theme/material/vaadin-combo-box-light";
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { import {
css, css,
CSSResult, CSSResult,
customElement, customElement,
html, html,
internalProperty,
LitElement, LitElement,
property, property,
PropertyValues, PropertyValues,
@ -38,7 +34,7 @@ import {
import { SubscribeMixin } from "../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import { PolymerChangedEvent } from "../../polymer-types"; import { PolymerChangedEvent } from "../../polymer-types";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import "../ha-svg-icon"; import { HaComboBox } from "../ha-combo-box";
interface Device { interface Device {
name: string; name: string;
@ -112,10 +108,11 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
@property() public deviceFilter?: HaDevicePickerDeviceFilterFunc; @property() public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ type: Boolean }) @property({ type: Boolean }) public disabled?: boolean;
private _opened?: boolean;
@query("vaadin-combo-box-light", true) private _comboBox!: HTMLElement; @internalProperty() private _opened?: boolean;
@query("ha-combo-box", true) private _comboBox!: HaComboBox;
private _init = false; private _init = false;
@ -244,15 +241,11 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
); );
public open() { public open() {
this.updateComplete.then(() => { this._comboBox?.open();
(this.shadowRoot?.querySelector("vaadin-combo-box-light") as any)?.open();
});
} }
public focus() { public focus() {
this.updateComplete.then(() => { this._comboBox?.focus();
this.shadowRoot?.querySelector("paper-input")?.focus();
});
} }
public hassSubscribe(): UnsubscribeFunc[] { public hassSubscribe(): UnsubscribeFunc[] {
@ -292,70 +285,29 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
return html``; return html``;
} }
return html` return html`
<vaadin-combo-box-light <ha-combo-box
.hass=${this.hass}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.device-picker.device")
: this.label}
.value=${this._value}
.renderer=${rowRenderer}
.disabled=${this.disabled}
item-value-path="id" item-value-path="id"
item-id-path="id" item-id-path="id"
item-label-path="name" item-label-path="name"
.value=${this._value}
.renderer=${rowRenderer}
@opened-changed=${this._openedChanged} @opened-changed=${this._openedChanged}
@value-changed=${this._deviceChanged} @value-changed=${this._deviceChanged}
> ></ha-combo-box>
<paper-input
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.device-picker.device")
: this.label}
class="input"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
spellcheck="false"
>
${this.value
? html`
<mwc-icon-button
.label=${this.hass.localize(
"ui.components.device-picker.clear"
)}
slot="suffix"
class="clear-button"
@click=${this._clearValue}
>
<ha-svg-icon .path=${mdiClose}></ha-svg-icon>
</mwc-icon-button>
`
: ""}
<mwc-icon-button
.label=${this.hass.localize(
"ui.components.device-picker.show_devices"
)}
slot="suffix"
class="toggle-button"
>
<ha-svg-icon
.path=${this._opened ? mdiMenuUp : mdiMenuDown}
></ha-svg-icon>
</mwc-icon-button>
</paper-input>
</vaadin-combo-box-light>
`; `;
} }
private _clearValue(ev: Event) {
ev.stopPropagation();
this._setValue("");
}
private get _value() { private get _value() {
return this.value || ""; return this.value || "";
} }
private _openedChanged(ev: PolymerChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _deviceChanged(ev: PolymerChangedEvent<string>) { private _deviceChanged(ev: PolymerChangedEvent<string>) {
ev.stopPropagation();
const newValue = ev.detail.value; const newValue = ev.detail.value;
if (newValue !== this._value) { if (newValue !== this._value) {
@ -363,6 +315,10 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
} }
} }
private _openedChanged(ev: PolymerChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _setValue(value: string) { private _setValue(value: string) {
this.value = value; this.value = value;
setTimeout(() => { setTimeout(() => {

View File

@ -117,6 +117,8 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) {
@property() public entityFilter?: (entity: EntityRegistryEntry) => boolean; @property() public entityFilter?: (entity: EntityRegistryEntry) => boolean;
@property({ type: Boolean }) public disabled?: boolean;
@internalProperty() private _areas?: AreaRegistryEntry[]; @internalProperty() private _areas?: AreaRegistryEntry[];
@internalProperty() private _devices?: DeviceRegistryEntry[]; @internalProperty() private _devices?: DeviceRegistryEntry[];
@ -339,6 +341,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) {
item-label-path="name" item-label-path="name"
.value=${this._value} .value=${this._value}
.renderer=${rowRenderer} .renderer=${rowRenderer}
.disabled=${this.disabled}
@opened-changed=${this._openedChanged} @opened-changed=${this._openedChanged}
@value-changed=${this._areaChanged} @value-changed=${this._areaChanged}
> >
@ -349,6 +352,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) {
.placeholder=${this.placeholder .placeholder=${this.placeholder
? this._area(this.placeholder)?.name ? this._area(this.placeholder)?.name
: undefined} : undefined}
.disabled=${this.disabled}
class="input" class="input"
autocapitalize="none" autocapitalize="none"
autocomplete="off" autocomplete="off"

View File

@ -1,116 +0,0 @@
import "@polymer/paper-input/paper-input";
import "@polymer/paper-item/paper-item";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "@vaadin/vaadin-combo-box/theme/material/vaadin-combo-box-light";
import { EventsMixin } from "../mixins/events-mixin";
import "./ha-icon-button";
class HaComboBox extends EventsMixin(PolymerElement) {
static get template() {
return html`
<style>
paper-input > ha-icon-button {
--mdc-icon-button-size: 24px;
padding: 2px;
color: var(--secondary-text-color);
}
[hidden] {
display: none;
}
</style>
<vaadin-combo-box-light
items="[[_items]]"
item-value-path="[[itemValuePath]]"
item-label-path="[[itemLabelPath]]"
value="{{value}}"
opened="{{opened}}"
allow-custom-value="[[allowCustomValue]]"
on-change="_fireChanged"
>
<paper-input
autofocus="[[autofocus]]"
label="[[label]]"
class="input"
value="[[value]]"
>
<ha-icon-button
slot="suffix"
class="clear-button"
icon="hass:close"
hidden$="[[!value]]"
>Clear</ha-icon-button
>
<ha-icon-button
slot="suffix"
class="toggle-button"
icon="[[_computeToggleIcon(opened)]]"
hidden$="[[!items.length]]"
>Toggle</ha-icon-button
>
</paper-input>
<template>
<style>
paper-item {
margin: -5px -10px;
padding: 0;
}
</style>
<paper-item>[[_computeItemLabel(item, itemLabelPath)]]</paper-item>
</template>
</vaadin-combo-box-light>
`;
}
static get properties() {
return {
allowCustomValue: Boolean,
items: {
type: Object,
observer: "_itemsChanged",
},
_items: Object,
itemLabelPath: String,
itemValuePath: String,
autofocus: Boolean,
label: String,
opened: {
type: Boolean,
value: false,
observer: "_openedChanged",
},
value: {
type: String,
notify: true,
},
};
}
_openedChanged(newVal) {
if (!newVal) {
this._items = this.items;
}
}
_itemsChanged(newVal) {
if (!this.opened) {
this._items = newVal;
}
}
_computeToggleIcon(opened) {
return opened ? "hass:menu-up" : "hass:menu-down";
}
_computeItemLabel(item, itemLabelPath) {
return itemLabelPath ? item[itemLabelPath] : item;
}
_fireChanged(ev) {
ev.stopPropagation();
this.fire("change");
}
}
customElements.define("ha-combo-box", HaComboBox);

View File

@ -0,0 +1,181 @@
import "@material/mwc-icon-button/mwc-icon-button";
import { mdiClose, mdiMenuDown, mdiMenuUp } from "@mdi/js";
import "@polymer/paper-input/paper-input";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import "@polymer/paper-listbox/paper-listbox";
import "@vaadin/vaadin-combo-box/theme/material/vaadin-combo-box-light";
import {
css,
CSSResult,
customElement,
html,
internalProperty,
LitElement,
property,
query,
TemplateResult,
} from "lit-element";
import { fireEvent } from "../common/dom/fire_event";
import { PolymerChangedEvent } from "../polymer-types";
import { HomeAssistant } from "../types";
import "./ha-svg-icon";
const defaultRowRenderer = (
root: HTMLElement,
_owner,
model: { item: any }
) => {
if (!root.firstElementChild) {
root.innerHTML = `
<style>
paper-item {
margin: -5px -10px;
padding: 0;
}
</style>
<paper-item></paper-item>
`;
}
root.querySelector("paper-item")!.textContent = model.item;
};
@customElement("ha-combo-box")
export class HaComboBox extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@property() public items?: [];
@property() public filteredItems?: [];
@property({ attribute: "allow-custom-value", type: Boolean })
public allowCustomValue?: boolean;
@property({ attribute: "item-value-path" }) public itemValuePath?: string;
@property({ attribute: "item-label-path" }) public itemLabelPath?: string;
@property({ attribute: "item-id-path" }) public itemIdPath?: string;
@property() public renderer?: (
root: HTMLElement,
owner: HTMLElement,
model: { item: any }
) => void;
@property({ type: Boolean }) public disabled?: boolean;
@internalProperty() private _opened?: boolean;
@query("vaadin-combo-box-light", true) private _comboBox!: HTMLElement;
public open() {
this.updateComplete.then(() => {
(this._comboBox as any)?.open();
});
}
public focus() {
this.updateComplete.then(() => {
this.shadowRoot?.querySelector("paper-input")?.focus();
});
}
protected render(): TemplateResult {
return html`
<vaadin-combo-box-light
.itemValuePath=${this.itemValuePath}
.itemIdPath=${this.itemIdPath}
.itemLabelPath=${this.itemLabelPath}
.value=${this.value}
.items=${this.items}
.filteredItems=${this.filteredItems}
.renderer=${this.renderer || defaultRowRenderer}
.allowCustomValue=${this.allowCustomValue}
.disabled=${this.disabled}
@opened-changed=${this._openedChanged}
@filter-changed=${this._filterChanged}
@value-changed=${this._valueChanged}
>
<paper-input
.label=${this.label}
.disabled=${this.disabled}
class="input"
autocapitalize="none"
autocomplete="off"
autocorrect="off"
spellcheck="false"
>
${this.value
? html`
<mwc-icon-button
.label=${this.hass.localize("ui.components.combo-box.clear")}
slot="suffix"
class="clear-button"
@click=${this._clearValue}
>
<ha-svg-icon .path=${mdiClose}></ha-svg-icon>
</mwc-icon-button>
`
: ""}
<mwc-icon-button
.label=${this.hass.localize("ui.components.combo-box.show")}
slot="suffix"
class="toggle-button"
>
<ha-svg-icon
.path=${this._opened ? mdiMenuUp : mdiMenuDown}
></ha-svg-icon>
</mwc-icon-button>
</paper-input>
</vaadin-combo-box-light>
`;
}
private _clearValue(ev: Event) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: undefined });
}
private _openedChanged(ev: PolymerChangedEvent<boolean>) {
this._opened = ev.detail.value;
// @ts-ignore
fireEvent(this, ev.type, ev.detail);
}
private _filterChanged(ev: PolymerChangedEvent<boolean>) {
// @ts-ignore
fireEvent(this, ev.type, ev.detail);
}
private _valueChanged(ev: PolymerChangedEvent<string>) {
ev.stopPropagation();
const newValue = ev.detail.value;
if (newValue !== this.value) {
fireEvent(this, "value-changed", { value: newValue });
}
}
static get styles(): CSSResult {
return css`
paper-input > mwc-icon-button {
--mdc-icon-button-size: 24px;
padding: 2px;
color: var(--secondary-text-color);
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-combo-box": HaComboBox;
}
}

View File

@ -1,6 +1,9 @@
import { mdiEye, mdiEyeOff } from "@mdi/js";
import "@polymer/paper-input/paper-input"; import "@polymer/paper-input/paper-input";
import type { PaperInputElement } from "@polymer/paper-input/paper-input"; import type { PaperInputElement } from "@polymer/paper-input/paper-input";
import { import {
css,
CSSResult,
customElement, customElement,
html, html,
internalProperty, internalProperty,
@ -10,12 +13,13 @@ import {
TemplateResult, TemplateResult,
} from "lit-element"; } from "lit-element";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import "../ha-icon-button"; import "../ha-svg-icon";
import type { import type {
HaFormElement, HaFormElement,
HaFormStringData, HaFormStringData,
HaFormStringSchema, HaFormStringSchema,
} from "./ha-form"; } from "./ha-form";
import "@material/mwc-icon-button/mwc-icon-button";
@customElement("ha-form-string") @customElement("ha-form-string")
export class HaFormString extends LitElement implements HaFormElement { export class HaFormString extends LitElement implements HaFormElement {
@ -48,16 +52,17 @@ export class HaFormString extends LitElement implements HaFormElement {
.autoValidate=${this.schema.required} .autoValidate=${this.schema.required}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
<ha-icon-button <mwc-icon-button
toggles toggles
slot="suffix" slot="suffix"
.icon=${this._unmaskedPassword ? "hass:eye-off" : "hass:eye"}
id="iconButton" id="iconButton"
title="Click to toggle between masked and clear password" title="Click to toggle between masked and clear password"
@click=${this._toggleUnmaskedPassword} @click=${this._toggleUnmaskedPassword}
tabindex="-1" tabindex="-1"
> ><ha-svg-icon
</ha-icon-button> .path=${this._unmaskedPassword ? mdiEyeOff : mdiEye}
></ha-svg-icon>
</mwc-icon-button>
</paper-input> </paper-input>
` `
: html` : html`
@ -98,6 +103,15 @@ export class HaFormString extends LitElement implements HaFormElement {
} }
return "text"; return "text";
} }
static get styles(): CSSResult {
return css`
mwc-icon-button {
--mdc-icon-button-size: 24px;
color: var(--secondary-text-color);
}
`;
}
} }
declare global { declare global {

View File

@ -202,9 +202,8 @@ export class HaForm extends LitElement implements HaFormElement {
ev.stopPropagation(); ev.stopPropagation();
const schema = (ev.target as HaFormElement).schema as HaFormSchema; const schema = (ev.target as HaFormElement).schema as HaFormSchema;
const data = this.data as HaFormDataContainer; const data = this.data as HaFormDataContainer;
data[schema.name] = ev.detail.value;
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
value: { ...data }, value: { ...data, [schema.name]: ev.detail.value },
}); });
} }

View File

@ -21,8 +21,11 @@ export class HaActionSelector extends LitElement {
@property() public label?: string; @property() public label?: string;
@property({ type: Boolean, reflect: true }) public disabled = false;
protected render() { protected render() {
return html`<ha-automation-action return html`<ha-automation-action
.disabled=${this.disabled}
.actions=${this.value || []} .actions=${this.value || []}
.hass=${this.hass} .hass=${this.hass}
></ha-automation-action>`; ></ha-automation-action>`;
@ -34,6 +37,10 @@ export class HaActionSelector extends LitElement {
display: block; display: block;
margin-bottom: 16px; margin-bottom: 16px;
} }
:host([disabled]) ha-automation-action {
opacity: var(--light-disabled-opacity);
pointer-events: none;
}
`; `;
} }
} }

View File

@ -24,6 +24,8 @@ export class HaAreaSelector extends LitElement {
@internalProperty() public _configEntries?: ConfigEntry[]; @internalProperty() public _configEntries?: ConfigEntry[];
@property({ type: Boolean }) public disabled = false;
protected updated(changedProperties) { protected updated(changedProperties) {
if (changedProperties.has("selector")) { if (changedProperties.has("selector")) {
const oldSelector = changedProperties.get("selector"); const oldSelector = changedProperties.get("selector");
@ -50,6 +52,7 @@ export class HaAreaSelector extends LitElement {
.includeDomains=${this.selector.area.entity?.domain .includeDomains=${this.selector.area.entity?.domain
? [this.selector.area.entity.domain] ? [this.selector.area.entity.domain]
: undefined} : undefined}
.disabled=${this.disabled}
></ha-area-picker>`; ></ha-area-picker>`;
} }

View File

@ -19,11 +19,14 @@ export class HaBooleanSelector extends LitElement {
@property() public label?: string; @property() public label?: string;
@property({ type: Boolean }) public disabled = false;
protected render() { protected render() {
return html` <ha-formfield alignEnd spaceBetween .label=${this.label}> return html` <ha-formfield alignEnd spaceBetween .label=${this.label}>
<ha-switch <ha-switch
.checked=${this.value} .checked=${this.value}
@change=${this._handleChange} @change=${this._handleChange}
.disabled=${this.disabled}
></ha-switch> ></ha-switch>
</ha-formfield>`; </ha-formfield>`;
} }

View File

@ -23,10 +23,12 @@ export class HaDeviceSelector extends LitElement {
@internalProperty() public _configEntries?: ConfigEntry[]; @internalProperty() public _configEntries?: ConfigEntry[];
@property({ type: Boolean }) public disabled = false;
protected updated(changedProperties) { protected updated(changedProperties) {
if (changedProperties.has("selector")) { if (changedProperties.has("selector")) {
const oldSelector = changedProperties.get("selector"); const oldSelector = changedProperties.get("selector");
if (oldSelector !== this.selector && this.selector.device.integration) { if (oldSelector !== this.selector && this.selector.device?.integration) {
this._loadConfigEntries(); this._loadConfigEntries();
} }
} }
@ -44,24 +46,25 @@ export class HaDeviceSelector extends LitElement {
.includeDomains=${this.selector.device.entity?.domain .includeDomains=${this.selector.device.entity?.domain
? [this.selector.device.entity.domain] ? [this.selector.device.entity.domain]
: undefined} : undefined}
.disabled=${this.disabled}
allow-custom-entity allow-custom-entity
></ha-device-picker>`; ></ha-device-picker>`;
} }
private _filterDevices(device: DeviceRegistryEntry): boolean { private _filterDevices(device: DeviceRegistryEntry): boolean {
if ( if (
this.selector.device.manufacturer && this.selector.device?.manufacturer &&
device.manufacturer !== this.selector.device.manufacturer device.manufacturer !== this.selector.device.manufacturer
) { ) {
return false; return false;
} }
if ( if (
this.selector.device.model && this.selector.device?.model &&
device.model !== this.selector.device.model device.model !== this.selector.device.model
) { ) {
return false; return false;
} }
if (this.selector.device.integration) { if (this.selector.device?.integration) {
if ( if (
this._configEntries && this._configEntries &&
!this._configEntries.some((entry) => !this._configEntries.some((entry) =>

View File

@ -25,12 +25,15 @@ export class HaEntitySelector extends SubscribeMixin(LitElement) {
@property() public label?: string; @property() public label?: string;
@property({ type: Boolean }) public disabled = false;
protected render() { protected render() {
return html`<ha-entity-picker return html`<ha-entity-picker
.hass=${this.hass} .hass=${this.hass}
.value=${this.value} .value=${this.value}
.label=${this.label} .label=${this.label}
.entityFilter=${(entity) => this._filterEntities(entity)} .entityFilter=${(entity) => this._filterEntities(entity)}
.disabled=${this.disabled}
allow-custom-entity allow-custom-entity
></ha-entity-picker>`; ></ha-entity-picker>`;
} }
@ -51,12 +54,12 @@ export class HaEntitySelector extends SubscribeMixin(LitElement) {
} }
private _filterEntities(entity: HassEntity): boolean { private _filterEntities(entity: HassEntity): boolean {
if (this.selector.entity.domain) { if (this.selector.entity?.domain) {
if (computeStateDomain(entity) !== this.selector.entity.domain) { if (computeStateDomain(entity) !== this.selector.entity.domain) {
return false; return false;
} }
} }
if (this.selector.entity.device_class) { if (this.selector.entity?.device_class) {
if ( if (
!entity.attributes.device_class || !entity.attributes.device_class ||
entity.attributes.device_class !== this.selector.entity.device_class entity.attributes.device_class !== this.selector.entity.device_class
@ -64,7 +67,7 @@ export class HaEntitySelector extends SubscribeMixin(LitElement) {
return false; return false;
} }
} }
if (this.selector.entity.integration) { if (this.selector.entity?.integration) {
if ( if (
!this._entityPlaformLookup || !this._entityPlaformLookup ||
this._entityPlaformLookup[entity.entity_id] !== this._entityPlaformLookup[entity.entity_id] !==

View File

@ -21,8 +21,12 @@ export class HaNumberSelector extends LitElement {
@property() public value?: number; @property() public value?: number;
@property() public placeholder?: number;
@property() public label?: string; @property() public label?: string;
@property({ type: Boolean }) public disabled = false;
protected render() { protected render() {
return html`${this.label} return html`${this.label}
${this.selector.number.mode === "slider" ${this.selector.number.mode === "slider"
@ -31,6 +35,7 @@ export class HaNumberSelector extends LitElement {
.max=${this.selector.number.max} .max=${this.selector.number.max}
.value=${this._value} .value=${this._value}
.step=${this.selector.number.step} .step=${this.selector.number.step}
.disabled=${this.disabled}
pin pin
ignore-bar-touch ignore-bar-touch
@change=${this._handleSliderChange} @change=${this._handleSliderChange}
@ -42,12 +47,14 @@ export class HaNumberSelector extends LitElement {
.label=${this.selector.number.mode === "slider" .label=${this.selector.number.mode === "slider"
? undefined ? undefined
: this.label} : this.label}
.placeholder=${this.placeholder}
.noLabelFloat=${this.selector.number.mode === "slider"} .noLabelFloat=${this.selector.number.mode === "slider"}
class=${classMap({ single: this.selector.number.mode === "box" })} class=${classMap({ single: this.selector.number.mode === "box" })}
.min=${this.selector.number.min} .min=${this.selector.number.min}
.max=${this.selector.number.max} .max=${this.selector.number.max}
.value=${this._value} .value=${this.value}
.step=${this.selector.number.step} .step=${this.selector.number.step}
.disabled=${this.disabled}
type="number" type="number"
auto-validate auto-validate
@value-changed=${this._handleInputChange} @value-changed=${this._handleInputChange}
@ -65,16 +72,21 @@ export class HaNumberSelector extends LitElement {
} }
private _handleInputChange(ev) { private _handleInputChange(ev) {
const value = ev.detail.value; ev.stopPropagation();
if (this._value === value) { const value =
ev.detail.value === "" || isNaN(ev.detail.value)
? undefined
: Number(ev.detail.value);
if (this.value === value) {
return; return;
} }
fireEvent(this, "value-changed", { value }); fireEvent(this, "value-changed", { value });
} }
private _handleSliderChange(ev) { private _handleSliderChange(ev) {
const value = ev.target.value; ev.stopPropagation();
if (this._value === value) { const value = Number(ev.target.value);
if (this.value === value) {
return; return;
} }
fireEvent(this, "value-changed", { value }); fireEvent(this, "value-changed", { value });

View File

@ -11,8 +11,14 @@ export class HaObjectSelector extends LitElement {
@property() public label?: string; @property() public label?: string;
@property() public placeholder?: string;
@property({ type: Boolean }) public disabled = false;
protected render() { protected render() {
return html`<ha-yaml-editor return html`<ha-yaml-editor
.disabled=${this.disabled}
.placeholder=${this.placeholder}
.defaultValue=${this.value} .defaultValue=${this.value}
@value-changed=${this._handleChange} @value-changed=${this._handleChange}
></ha-yaml-editor>`; ></ha-yaml-editor>`;

View File

@ -0,0 +1,78 @@
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
} from "lit-element";
import { fireEvent } from "../../common/dom/fire_event";
import { HomeAssistant } from "../../types";
import { SelectSelector } from "../../data/selector";
import "../ha-paper-dropdown-menu";
@customElement("ha-selector-select")
export class HaSelectSelector extends LitElement {
@property() public hass!: HomeAssistant;
@property() public selector!: SelectSelector;
@property() public value?: string;
@property() public label?: string;
@property({ type: Boolean }) public disabled = false;
protected render() {
return html`<ha-paper-dropdown-menu
.disabled=${this.disabled}
.label=${this.label}
>
<paper-listbox
slot="dropdown-content"
attr-for-selected="item-value"
.selected=${this.value}
@selected-item-changed=${this._valueChanged}
>
${this.selector.select.options.map(
(item: string) => html`
<paper-item .itemValue=${item}>
${item}
</paper-item>
`
)}
</paper-listbox>
</ha-paper-dropdown-menu>`;
}
private _valueChanged(ev) {
if (this.disabled || !ev.detail.value) {
return;
}
fireEvent(this, "value-changed", {
value: ev.detail.value.itemValue,
});
}
static get styles(): CSSResult {
return css`
ha-paper-dropdown-menu {
width: 100%;
min-width: 200px;
display: block;
}
paper-listbox {
min-width: 200px;
}
paper-item {
cursor: pointer;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-select": HaSelectSelector;
}
}

View File

@ -3,7 +3,11 @@ import "@material/mwc-list/mwc-list-item";
import "@material/mwc-tab-bar/mwc-tab-bar"; import "@material/mwc-tab-bar/mwc-tab-bar";
import "@material/mwc-tab/mwc-tab"; import "@material/mwc-tab/mwc-tab";
import "@polymer/paper-input/paper-input"; import "@polymer/paper-input/paper-input";
import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; import {
HassEntity,
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import { import {
css, css,
CSSResult, CSSResult,
@ -20,7 +24,6 @@ import {
subscribeEntityRegistry, subscribeEntityRegistry,
} from "../../data/entity_registry"; } from "../../data/entity_registry";
import { TargetSelector } from "../../data/selector"; import { TargetSelector } from "../../data/selector";
import { Target } from "../../data/target";
import { SubscribeMixin } from "../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import "../ha-target-picker"; import "../ha-target-picker";
@ -31,7 +34,7 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) {
@property() public selector!: TargetSelector; @property() public selector!: TargetSelector;
@property() public value?: Target; @property() public value?: HassServiceTarget;
@property() public label?: string; @property() public label?: string;
@ -39,6 +42,8 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) {
@internalProperty() private _configEntries?: ConfigEntry[]; @internalProperty() private _configEntries?: ConfigEntry[];
@property({ type: Boolean }) public disabled = false;
public hassSubscribe(): UnsubscribeFunc[] { public hassSubscribe(): UnsubscribeFunc[] {
return [ return [
subscribeEntityRegistry(this.hass.connection!, (entities) => { subscribeEntityRegistry(this.hass.connection!, (entities) => {
@ -59,7 +64,8 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) {
const oldSelector = changedProperties.get("selector"); const oldSelector = changedProperties.get("selector");
if ( if (
oldSelector !== this.selector && oldSelector !== this.selector &&
this.selector.target.device?.integration (this.selector.target.device?.integration ||
this.selector.target.entity?.integration)
) { ) {
this._loadConfigEntries(); this._loadConfigEntries();
} }
@ -80,15 +86,20 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) {
.includeDomains=${this.selector.target.entity?.domain .includeDomains=${this.selector.target.entity?.domain
? [this.selector.target.entity.domain] ? [this.selector.target.entity.domain]
: undefined} : undefined}
.disabled=${this.disabled}
></ha-target-picker>`; ></ha-target-picker>`;
} }
private _filterEntities(entity: HassEntity): boolean { private _filterEntities(entity: HassEntity): boolean {
if (this.selector.target.entity?.integration) { if (
this.selector.target.entity?.integration ||
this.selector.target.device?.integration
) {
if ( if (
!this._entityPlaformLookup || !this._entityPlaformLookup ||
this._entityPlaformLookup[entity.entity_id] !== this._entityPlaformLookup[entity.entity_id] !==
this.selector.target.entity.integration (this.selector.target.entity?.integration ||
this.selector.target.device?.integration)
) { ) {
return false; return false;
} }
@ -118,7 +129,10 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) {
) { ) {
return false; return false;
} }
if (this.selector.target.device?.integration) { if (
this.selector.target.device?.integration ||
this.selector.target.entity?.integration
) {
if ( if (
!this._configEntries?.some((entry) => !this._configEntries?.some((entry) =>
device.config_entries.includes(entry.entry_id) device.config_entries.includes(entry.entry_id)
@ -132,14 +146,16 @@ export class HaTargetSelector extends SubscribeMixin(LitElement) {
private async _loadConfigEntries() { private async _loadConfigEntries() {
this._configEntries = (await getConfigEntries(this.hass)).filter( this._configEntries = (await getConfigEntries(this.hass)).filter(
(entry) => entry.domain === this.selector.target.device?.integration (entry) =>
entry.domain ===
(this.selector.target.device?.integration ||
this.selector.target.entity?.integration)
); );
} }
static get styles(): CSSResult { static get styles(): CSSResult {
return css` return css`
ha-target-picker { ha-target-picker {
margin: 0 -8px;
display: block; display: block;
} }
`; `;

View File

@ -13,14 +13,20 @@ export class HaTextSelector extends LitElement {
@property() public label?: string; @property() public label?: string;
@property() public placeholder?: string;
@property() public selector!: StringSelector; @property() public selector!: StringSelector;
@property({ type: Boolean }) public disabled = false;
protected render() { protected render() {
if (this.selector.text?.multiline) { if (this.selector.text?.multiline) {
return html`<paper-textarea return html`<paper-textarea
.label=${this.label} .label=${this.label}
.value="${this.value}" .placeholder=${this.placeholder}
@value-changed="${this._handleChange}" .value=${this.value}
.disabled=${this.disabled}
@value-changed=${this._handleChange}
autocapitalize="none" autocapitalize="none"
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
@ -29,6 +35,8 @@ export class HaTextSelector extends LitElement {
return html`<paper-input return html`<paper-input
required required
.value=${this.value} .value=${this.value}
.placeholder=${this.placeholder}
.disabled=${this.disabled}
@value-changed=${this._handleChange} @value-changed=${this._handleChange}
.label=${this.label} .label=${this.label}
></paper-input>`; ></paper-input>`;

View File

@ -17,6 +17,8 @@ export class HaTimeSelector extends LitElement {
@property() public label?: string; @property() public label?: string;
@property({ type: Boolean }) public disabled = false;
protected render() { protected render() {
const parts = this.value?.split(":") || []; const parts = this.value?.split(":") || [];
const hours = useAMPM ? parts[0] ?? "12" : parts[0] ?? "0"; const hours = useAMPM ? parts[0] ?? "12" : parts[0] ?? "0";
@ -29,6 +31,7 @@ export class HaTimeSelector extends LitElement {
.sec=${parts[2] ?? "00"} .sec=${parts[2] ?? "00"}
.format=${useAMPM ? 12 : 24} .format=${useAMPM ? 12 : 24}
.amPm=${useAMPM && (Number(hours) > 12 ? "PM" : "AM")} .amPm=${useAMPM && (Number(hours) > 12 ? "PM" : "AM")}
.disabled=${this.disabled}
@change=${this._timeChanged} @change=${this._timeChanged}
@am-pm-changed=${this._timeChanged} @am-pm-changed=${this._timeChanged}
hide-label hide-label

View File

@ -12,6 +12,7 @@ import "./ha-selector-target";
import "./ha-selector-time"; import "./ha-selector-time";
import "./ha-selector-object"; import "./ha-selector-object";
import "./ha-selector-text"; import "./ha-selector-text";
import "./ha-selector-select";
@customElement("ha-selector") @customElement("ha-selector")
export class HaSelector extends LitElement { export class HaSelector extends LitElement {
@ -23,6 +24,10 @@ export class HaSelector extends LitElement {
@property() public label?: string; @property() public label?: string;
@property() public placeholder?: any;
@property({ type: Boolean }) public disabled = false;
public focus() { public focus() {
const input = this.shadowRoot!.getElementById("selector"); const input = this.shadowRoot!.getElementById("selector");
if (!input) { if (!input) {
@ -42,6 +47,8 @@ export class HaSelector extends LitElement {
selector: this.selector, selector: this.selector,
value: this.value, value: this.value,
label: this.label, label: this.label,
placeholder: this.placeholder,
disabled: this.disabled,
id: "selector", id: "selector",
})} })}
`; `;

View File

@ -0,0 +1,407 @@
import { HassService, HassServiceTarget } from "home-assistant-js-websocket";
import {
css,
CSSResult,
customElement,
html,
internalProperty,
LitElement,
property,
PropertyValues,
query,
} from "lit-element";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import { computeObjectId } from "../common/entity/compute_object_id";
import { ENTITY_COMPONENT_DOMAINS } from "../data/entity";
import { Selector } from "../data/selector";
import { PolymerChangedEvent } from "../polymer-types";
import { HomeAssistant } from "../types";
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"> {
fields: {
key: string;
name?: string;
description: string;
required?: boolean;
advanced?: boolean;
default?: any;
example?: any;
selector?: Selector;
}[];
}
@customElement("ha-service-control")
export class HaServiceControl extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: {
service: string;
target?: HassServiceTarget;
data?: Record<string, any>;
};
@property({ reflect: true, type: Boolean }) public narrow!: boolean;
@property({ type: Boolean }) public showAdvanced?: boolean;
@internalProperty() private _serviceData?: ExtHassService;
@internalProperty() private _checkedKeys = new Set();
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;
protected updated(changedProperties: PropertyValues) {
if (!changedProperties.has("value")) {
return;
}
const oldValue = changedProperties.get("value") as
| undefined
| this["value"];
if (oldValue?.service !== this.value?.service) {
this._checkedKeys = new Set();
}
this._serviceData = this.value?.service
? this._getServiceInfo(this.value.service)
: undefined;
if (
this._serviceData &&
"target" in this._serviceData &&
(this.value?.data?.entity_id ||
this.value?.data?.area_id ||
this.value?.data?.device_id)
) {
const target = {
...this.value.target,
};
if (this.value.data.entity_id && !this.value.target?.entity_id) {
target.entity_id = this.value.data.entity_id;
}
if (this.value.data.area_id && !this.value.target?.area_id) {
target.area_id = this.value.data.area_id;
}
if (this.value.data.device_id && !this.value.target?.device_id) {
target.device_id = this.value.data.device_id;
}
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;
}
if (this.value?.data) {
const yamlEditor = this._yamlEditor;
if (yamlEditor && yamlEditor.value !== this.value.data) {
yamlEditor.setValue(this.value.data);
}
}
}
private _domainFilter = memoizeOne((service: string) => {
const domain = computeDomain(service);
return ENTITY_COMPONENT_DOMAINS.includes(domain) ? [domain] : null;
});
private _getServiceInfo = memoizeOne((service: string):
| ExtHassService
| undefined => {
if (!service) {
return undefined;
}
const domain = computeDomain(service);
const serviceName = computeObjectId(service);
const serviceDomains = this.hass.services;
if (!(domain in serviceDomains)) {
return undefined;
}
if (!(serviceName in serviceDomains[domain])) {
return undefined;
}
const fields = Object.entries(
serviceDomains[domain][serviceName].fields
).map(([key, value]) => {
return {
key,
...value,
selector: value.selector as Selector | undefined,
};
});
return {
...serviceDomains[domain][serviceName],
fields,
};
});
protected render() {
const legacy =
this._serviceData?.fields.length &&
!this._serviceData.fields.some((field) => field.selector);
const entityId =
legacy &&
this._serviceData?.fields.find((field) => field.key === "entity_id");
const hasOptional = Boolean(
!legacy &&
this._serviceData?.fields.some(
(field) => field.selector && !field.required
)
);
return html`<ha-service-picker
.hass=${this.hass}
.value=${this.value?.service}
@value-changed=${this._serviceChanged}
></ha-service-picker>
<p>${this._serviceData?.description}</p>
${this._serviceData && "target" in this._serviceData
? html`<ha-settings-row .narrow=${this.narrow}>
${hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: ""}
<span slot="heading"
>${this.hass.localize(
"ui.components.service-control.target"
)}</span
>
<span slot="description"
>${this.hass.localize(
"ui.components.service-control.target_description"
)}</span
><ha-selector
.hass=${this.hass}
.selector=${this._serviceData.target
? { target: this._serviceData.target }
: {
target: {
entity: { domain: computeDomain(this.value!.service) },
},
}}
@value-changed=${this._targetChanged}
.value=${this.value?.target}
></ha-selector
></ha-settings-row>`
: entityId
? html`<ha-entity-picker
.hass=${this.hass}
.value=${this.value?.data?.entity_id}
.label=${entityId.description}
.includeDomains=${this._domainFilter(this.value!.service)}
@value-changed=${this._entityPicked}
allow-custom-entity
></ha-entity-picker>`
: ""}
${legacy
? html`<ha-yaml-editor
.label=${this.hass.localize(
"ui.components.service-control.service_data"
)}
.name=${"data"}
.defaultValue=${this.value?.data}
@value-changed=${this._dataChanged}
></ha-yaml-editor>`
: this._serviceData?.fields.map((dataField) =>
dataField.selector && (!dataField.advanced || this.showAdvanced)
? html`<ha-settings-row .narrow=${this.narrow}>
${dataField.required
? hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: ""
: html`<ha-checkbox
.key=${dataField.key}
.checked=${this._checkedKeys.has(dataField.key) ||
(this.value?.data &&
this.value.data[dataField.key] !== undefined)}
@change=${this._checkboxChanged}
slot="prefix"
></ha-checkbox>`}
<span slot="heading">${dataField.name || dataField.key}</span>
<span slot="description">${dataField?.description}</span
><ha-selector
.disabled=${!dataField.required &&
!this._checkedKeys.has(dataField.key) &&
(!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]
: dataField.default}
></ha-selector
></ha-settings-row>`
: ""
)} `;
}
private _checkboxChanged(ev) {
const checked = ev.currentTarget.checked;
const key = ev.currentTarget.key;
if (checked) {
this._checkedKeys.add(key);
} else {
this._checkedKeys.delete(key);
const data = { ...this.value?.data };
delete data[key];
fireEvent(this, "value-changed", {
value: {
...this.value,
data,
},
});
}
this.requestUpdate("_checkedKeys");
}
private _serviceChanged(ev: PolymerChangedEvent<string>) {
ev.stopPropagation();
if (ev.detail.value === this.value?.service) {
return;
}
fireEvent(this, "value-changed", {
value: { service: ev.detail.value || "" },
});
}
private _entityPicked(ev: CustomEvent) {
ev.stopPropagation();
const newValue = ev.detail.value;
if (this.value?.data?.entity_id === newValue) {
return;
}
let 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 },
};
}
fireEvent(this, "value-changed", {
value,
});
}
private _targetChanged(ev: CustomEvent) {
ev.stopPropagation();
const newValue = ev.detail.value;
if (this.value?.target === newValue) {
return;
}
let value;
if (!newValue) {
value = { ...this.value };
delete value.target;
} else {
value = { ...this.value, target: ev.detail.value };
}
fireEvent(this, "value-changed", {
value,
});
}
private _serviceDataChanged(ev: CustomEvent) {
ev.stopPropagation();
const key = (ev.currentTarget as any).key;
const value = ev.detail.value;
if (this.value?.data && this.value.data[key] === value) {
return;
}
const data = { ...this.value?.data, [key]: value };
if (value === "" || value === undefined) {
delete data[key];
}
fireEvent(this, "value-changed", {
value: {
...this.value,
data,
},
});
}
private _dataChanged(ev: CustomEvent) {
ev.stopPropagation();
if (!ev.detail.isValid) {
return;
}
fireEvent(this, "value-changed", {
value: {
...this.value,
data: ev.detail.value,
},
});
}
static get styles(): CSSResult {
return css`
ha-settings-row {
padding: var(--service-control-padding, 0 16px);
}
ha-settings-row {
--paper-time-input-justify-content: flex-end;
border-top: var(
--service-control-items-border-top,
1px solid var(--divider-color)
);
}
ha-service-picker,
ha-entity-picker,
ha-yaml-editor {
display: block;
margin: var(--service-control-padding, 0 16px);
}
ha-yaml-editor {
padding: 16px 0;
}
p {
margin: var(--service-control-padding, 0 16px);
padding: 16px 0;
}
:host(:not([narrow])) ha-settings-row paper-input {
width: 60%;
}
:host(:not([narrow])) ha-settings-row ha-selector {
width: 60%;
}
.checkbox-spacer {
width: 32px;
}
ha-checkbox {
margin-left: -16px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-service-control": HaServiceControl;
}
}

View File

@ -1,60 +0,0 @@
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import LocalizeMixin from "../mixins/localize-mixin";
import "./ha-combo-box";
/*
* @appliesMixin LocalizeMixin
*/
class HaServicePicker extends LocalizeMixin(PolymerElement) {
static get template() {
return html`
<ha-combo-box
label="[[localize('ui.components.service-picker.service')]]"
items="[[_services]]"
value="{{value}}"
allow-custom-value=""
></ha-combo-box>
`;
}
static get properties() {
return {
hass: {
type: Object,
observer: "_hassChanged",
},
_services: Array,
value: {
type: String,
notify: true,
},
};
}
_hassChanged(hass, oldHass) {
if (!hass) {
this._services = [];
return;
}
if (oldHass && hass.services === oldHass.services) {
return;
}
const result = [];
Object.keys(hass.services)
.sort()
.forEach((domain) => {
const services = Object.keys(hass.services[domain]).sort();
for (let i = 0; i < services.length; i++) {
result.push(`${domain}.${services[i]}`);
}
});
this._services = result;
}
}
customElements.define("ha-service-picker", HaServicePicker);

View File

@ -0,0 +1,135 @@
import { html, internalProperty, LitElement, property } from "lit-element";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { LocalizeFunc } from "../common/translations/localize";
import { domainToName } from "../data/integration";
import { HomeAssistant } from "../types";
import "./ha-combo-box";
const rowRenderer = (
root: HTMLElement,
_owner,
model: { item: { service: string; name: string } }
) => {
if (!root.firstElementChild) {
root.innerHTML = `
<style>
paper-item {
margin: -10px 0;
padding: 0;
}
</style>
<paper-item>
<paper-item-body two-line="">
<div class='name'>[[item.name]]</div>
<div secondary>[[item.service]]</div>
</paper-item-body>
</paper-item>
`;
}
root.querySelector(".name")!.textContent = model.item.name;
root.querySelector("[secondary]")!.textContent =
model.item.name === model.item.service ? "" : model.item.service;
};
class HaServicePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public value?: string;
@internalProperty() private _filter?: string;
protected render() {
return html`
<ha-combo-box
.hass=${this.hass}
.label=${this.hass.localize("ui.components.service-picker.service")}
.filteredItems=${this._filteredServices(
this.hass.localize,
this.hass.services,
this._filter
)}
.value=${this.value}
.renderer=${rowRenderer}
item-value-path="service"
item-label-path="name"
allow-custom-value
@filter-changed=${this._filterChanged}
@value-changed=${this._valueChanged}
></ha-combo-box>
`;
}
private _services = memoizeOne(
(
localize: LocalizeFunc,
services: HomeAssistant["services"]
): {
service: string;
name: string;
}[] => {
if (!services) {
return [];
}
const result: { service: string; name: string }[] = [];
Object.keys(services)
.sort()
.forEach((domain) => {
const services_keys = Object.keys(services[domain]).sort();
for (const service of services_keys) {
result.push({
service: `${domain}.${service}`,
name: `${domainToName(localize, domain)}: ${
services[domain][service].name || service
}`,
});
}
});
return result;
}
);
private _filteredServices = memoizeOne(
(
localize: LocalizeFunc,
services: HomeAssistant["services"],
filter?: string
) => {
if (!services) {
return [];
}
const processedServices = this._services(localize, services);
if (!filter) {
return processedServices;
}
return processedServices.filter(
(service) =>
service.service.toLowerCase().includes(filter) ||
service.name?.toLowerCase().includes(filter)
);
}
);
private _filterChanged(ev: CustomEvent): void {
this._filter = ev.detail.value.toLowerCase();
}
private _valueChanged(ev) {
this.value = ev.detail.value;
fireEvent(this, "change");
fireEvent(this, "value-changed", { value: this.value });
}
}
customElements.define("ha-service-picker", HaServicePicker);
declare global {
interface HTMLElementTagNameMap {
"ha-service-picker": HaServicePicker;
}
}

View File

@ -6,7 +6,7 @@ import {
html, html,
LitElement, LitElement,
property, property,
SVGTemplateResult, TemplateResult,
} from "lit-element"; } from "lit-element";
@customElement("ha-settings-row") @customElement("ha-settings-row")
@ -16,15 +16,18 @@ export class HaSettingsRow extends LitElement {
@property({ type: Boolean, attribute: "three-line" }) @property({ type: Boolean, attribute: "three-line" })
public threeLine = false; public threeLine = false;
protected render(): SVGTemplateResult { protected render(): TemplateResult {
return html` return html`
<paper-item-body <div class="prefix-wrap">
?two-line=${!this.threeLine} <slot name="prefix"></slot>
?three-line=${this.threeLine} <paper-item-body
> ?two-line=${!this.threeLine}
<slot name="heading"></slot> ?three-line=${this.threeLine}
<div secondary><slot name="description"></slot></div> >
</paper-item-body> <slot name="heading"></slot>
<div secondary><slot name="description"></slot></div>
</paper-item-body>
</div>
<slot></slot> <slot></slot>
`; `;
} }
@ -45,6 +48,7 @@ export class HaSettingsRow extends LitElement {
min-height: calc( min-height: calc(
var(--paper-item-body-two-line-min-height, 72px) - 16px var(--paper-item-body-two-line-min-height, 72px) - 16px
); );
flex: 1;
} }
:host([narrow]) { :host([narrow]) {
align-items: normal; align-items: normal;
@ -58,6 +62,13 @@ export class HaSettingsRow extends LitElement {
div[secondary] { div[secondary] {
white-space: normal; white-space: normal;
} }
.prefix-wrap {
display: contents;
}
:host([narrow]) .prefix-wrap {
display: flex;
align-items: center;
}
`; `;
} }
} }

View File

@ -10,7 +10,10 @@ import {
mdiUnfoldMoreVertical, mdiUnfoldMoreVertical,
} from "@mdi/js"; } from "@mdi/js";
import "@polymer/paper-tooltip/paper-tooltip"; import "@polymer/paper-tooltip/paper-tooltip";
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import {
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import { import {
css, css,
CSSResult, CSSResult,
@ -41,7 +44,6 @@ import {
EntityRegistryEntry, EntityRegistryEntry,
subscribeEntityRegistry, subscribeEntityRegistry,
} from "../data/entity_registry"; } from "../data/entity_registry";
import { Target } from "../data/target";
import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
import "./device/ha-device-picker"; import "./device/ha-device-picker";
@ -56,7 +58,7 @@ import "./ha-svg-icon";
export class HaTargetPicker extends SubscribeMixin(LitElement) { export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property() public hass!: HomeAssistant; @property() public hass!: HomeAssistant;
@property() public value?: Target; @property() public value?: HassServiceTarget;
@property() public label?: string; @property() public label?: string;
@ -82,6 +84,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property() public entityFilter?: HaEntityPickerEntityFilterFunc; @property() public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ type: Boolean, reflect: true }) public disabled = false;
@internalProperty() private _areas?: { [areaId: string]: AreaRegistryEntry }; @internalProperty() private _areas?: { [areaId: string]: AreaRegistryEntry };
@internalProperty() private _devices?: { @internalProperty() private _devices?: {
@ -436,7 +440,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
type: string, type: string,
id: string id: string
): this["value"] { ): this["value"] {
const newVal = ensureArray(value![type])!.filter((val) => val !== id); const newVal = ensureArray(value![type])!.filter(
(val) => String(val) !== id
);
if (newVal.length) { if (newVal.length) {
return { return {
...value, ...value,
@ -530,6 +536,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.items { .items {
z-index: 2; z-index: 2;
} }
.mdc-chip-set {
padding: 4px 0;
}
.mdc-chip.add { .mdc-chip.add {
color: rgba(0, 0, 0, 0.87); color: rgba(0, 0, 0, 0.87);
} }
@ -594,6 +603,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
paper-tooltip.expand { paper-tooltip.expand {
min-width: 200px; min-width: 200px;
} }
:host([disabled]) .mdc-chip {
opacity: var(--light-disabled-opacity);
pointer-events: none;
}
`; `;
} }
} }

View File

@ -44,14 +44,14 @@ export class HaYamlEditor extends LitElement {
@internalProperty() private _yaml = ""; @internalProperty() private _yaml = "";
@query("ha-code-editor", true) private _editor?: HaCodeEditor; @query("ha-code-editor") private _editor?: HaCodeEditor;
public setValue(value): void { public setValue(value): void {
try { try {
this._yaml = value && !isEmpty(value) ? safeDump(value) : ""; this._yaml = value && !isEmpty(value) ? safeDump(value) : "";
} catch (err) { } catch (err) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(err); console.error(err, value);
alert(`There was an error converting to YAML: ${err}`); alert(`There was an error converting to YAML: ${err}`);
} }
afterNextRender(() => { afterNextRender(() => {
@ -73,7 +73,7 @@ export class HaYamlEditor extends LitElement {
return html``; return html``;
} }
return html` return html`
${this.label ? html` <p>${this.label}</p> ` : ""} ${this.label ? html`<p>${this.label}</p>` : ""}
<ha-code-editor <ha-code-editor
.value=${this._yaml} .value=${this._yaml}
mode="yaml" mode="yaml"
@ -85,13 +85,13 @@ export class HaYamlEditor extends LitElement {
private _onChange(ev: CustomEvent): void { private _onChange(ev: CustomEvent): void {
ev.stopPropagation(); ev.stopPropagation();
const value = ev.detail.value; this._yaml = ev.detail.value;
let parsed; let parsed;
let isValid = true; let isValid = true;
if (value) { if (this._yaml) {
try { try {
parsed = safeLoad(value); parsed = safeLoad(this._yaml);
} catch (err) { } catch (err) {
// Invalid YAML // Invalid YAML
isValid = false; isValid = false;
@ -107,7 +107,7 @@ export class HaYamlEditor extends LitElement {
} }
get yaml() { get yaml() {
return this._editor?.value; return this._yaml;
} }
} }

View File

@ -65,16 +65,18 @@ export const deleteConfigFlow = (hass: HomeAssistant, flowId: string) =>
export const getConfigFlowHandlers = (hass: HomeAssistant) => export const getConfigFlowHandlers = (hass: HomeAssistant) =>
hass.callApi<string[]>("GET", "config/config_entries/flow_handlers"); hass.callApi<string[]>("GET", "config/config_entries/flow_handlers");
const fetchConfigFlowInProgress = (conn) => export const fetchConfigFlowInProgress = (
conn: Connection
): Promise<DataEntryFlowProgress[]> =>
conn.sendMessagePromise({ conn.sendMessagePromise({
type: "config_entries/flow/progress", type: "config_entries/flow/progress",
}); });
const subscribeConfigFlowInProgressUpdates = (conn, store) => const subscribeConfigFlowInProgressUpdates = (conn: Connection, store) =>
conn.subscribeEvents( conn.subscribeEvents(
debounce( debounce(
() => () =>
fetchConfigFlowInProgress(conn).then((flows) => fetchConfigFlowInProgress(conn).then((flows: DataEntryFlowProgress[]) =>
store.setState(flows, true) store.setState(flows, true)
), ),
500, 500,

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HaFormSchema } from "../../components/ha-form/ha-form"; import { HaFormSchema } from "../../components/ha-form/ha-form";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import { SupervisorArch } from "../supervisor/supervisor"; import { SupervisorArch } from "../supervisor/supervisor";
@ -102,10 +103,28 @@ export interface HassioAddonSetOptionParams {
} }
export const reloadHassioAddons = async (hass: HomeAssistant) => { export const reloadHassioAddons = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/addons/reload",
method: "post",
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/addons/reload`); await hass.callApi<HassioResponse<void>>("POST", `hassio/addons/reload`);
}; };
export const fetchHassioAddonsInfo = async (hass: HomeAssistant) => { export const fetchHassioAddonsInfo = async (
hass: HomeAssistant
): Promise<HassioAddonsInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: "/addons",
method: "get",
});
}
return hassioApiResultExtractor( return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioAddonsInfo>>("GET", `hassio/addons`) await hass.callApi<HassioResponse<HassioAddonsInfo>>("GET", `hassio/addons`)
); );
@ -114,7 +133,15 @@ export const fetchHassioAddonsInfo = async (hass: HomeAssistant) => {
export const fetchHassioAddonInfo = async ( export const fetchHassioAddonInfo = async (
hass: HomeAssistant, hass: HomeAssistant,
slug: string slug: string
) => { ): Promise<HassioAddonDetails> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/info`,
method: "get",
});
}
return hassioApiResultExtractor( return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioAddonDetails>>( await hass.callApi<HassioResponse<HassioAddonDetails>>(
"GET", "GET",
@ -149,6 +176,16 @@ export const setHassioAddonOption = async (
slug: string, slug: string,
data: HassioAddonSetOptionParams data: HassioAddonSetOptionParams
) => { ) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/options`,
method: "post",
data,
});
return;
}
await hass.callApi<HassioResponse<void>>( await hass.callApi<HassioResponse<void>>(
"POST", "POST",
`hassio/addons/${slug}/options`, `hassio/addons/${slug}/options`,
@ -159,21 +196,64 @@ export const setHassioAddonOption = async (
export const validateHassioAddonOption = async ( export const validateHassioAddonOption = async (
hass: HomeAssistant, hass: HomeAssistant,
slug: string slug: string
) => { ): Promise<{ message: string; valid: boolean }> => {
return await hass.callApi< if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
HassioResponse<{ message: string; valid: boolean }> return await hass.callWS({
>("POST", `hassio/addons/${slug}/options/validate`); type: "supervisor/api",
endpoint: `/addons/${slug}/options/validate`,
method: "post",
});
}
return (
await hass.callApi<HassioResponse<{ message: string; valid: boolean }>>(
"POST",
`hassio/addons/${slug}/options/validate`
)
).data;
}; };
export const startHassioAddon = async (hass: HomeAssistant, slug: string) => { export const startHassioAddon = async (hass: HomeAssistant, slug: string) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/start`,
method: "post",
timeout: null,
});
}
return hass.callApi<string>("POST", `hassio/addons/${slug}/start`); return hass.callApi<string>("POST", `hassio/addons/${slug}/start`);
}; };
export const stopHassioAddon = async (hass: HomeAssistant, slug: string) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/stop`,
method: "post",
timeout: null,
});
}
return hass.callApi<string>("POST", `hassio/addons/${slug}/stop`);
};
export const setHassioAddonSecurity = async ( export const setHassioAddonSecurity = async (
hass: HomeAssistant, hass: HomeAssistant,
slug: string, slug: string,
data: HassioAddonSetSecurityParams data: HassioAddonSetSecurityParams
) => { ) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/security`,
method: "post",
data,
});
return;
}
await hass.callApi<HassioResponse<void>>( await hass.callApi<HassioResponse<void>>(
"POST", "POST",
`hassio/addons/${slug}/security`, `hassio/addons/${slug}/security`,
@ -181,15 +261,61 @@ export const setHassioAddonSecurity = async (
); );
}; };
export const installHassioAddon = async (hass: HomeAssistant, slug: string) => { export const installHassioAddon = async (
return hass.callApi<HassioResponse<void>>( hass: HomeAssistant,
slug: string
): Promise<void> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/install`,
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>(
"POST", "POST",
`hassio/addons/${slug}/install` `hassio/addons/${slug}/install`
); );
}; };
export const restartHassioAddon = async (hass: HomeAssistant, slug: string) => { export const updateHassioAddon = async (
return hass.callApi<HassioResponse<void>>( hass: HomeAssistant,
slug: string
): Promise<void> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/update`,
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>(
"POST",
`hassio/addons/${slug}/update`
);
};
export const restartHassioAddon = async (
hass: HomeAssistant,
slug: string
): Promise<void> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/restart`,
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>(
"POST", "POST",
`hassio/addons/${slug}/restart` `hassio/addons/${slug}/restart`
); );
@ -199,6 +325,16 @@ export const uninstallHassioAddon = async (
hass: HomeAssistant, hass: HomeAssistant,
slug: string slug: string
) => { ) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/addons/${slug}/uninstall`,
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>( await hass.callApi<HassioResponse<void>>(
"POST", "POST",
`hassio/addons/${slug}/uninstall` `hassio/addons/${slug}/uninstall`

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
export interface HassioResponse<T> { export interface HassioResponse<T> {
@ -33,6 +34,14 @@ export const fetchHassioStats = async (
hass: HomeAssistant, hass: HomeAssistant,
container: string container: string
): Promise<HassioStats> => { ): Promise<HassioStats> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: `/${container}/stats`,
method: "get",
});
}
return hassioApiResultExtractor( return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioStats>>( await hass.callApi<HassioResponse<HassioStats>>(
"GET", "GET",

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import { hassioApiResultExtractor, HassioResponse } from "./common"; import { hassioApiResultExtractor, HassioResponse } from "./common";
@ -5,7 +6,17 @@ interface HassioDockerRegistries {
[key: string]: { username: string; password?: string }; [key: string]: { username: string; password?: string };
} }
export const fetchHassioDockerRegistries = async (hass: HomeAssistant) => { export const fetchHassioDockerRegistries = async (
hass: HomeAssistant
): Promise<HassioDockerRegistries> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "get",
});
}
return hassioApiResultExtractor( return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioDockerRegistries>>( await hass.callApi<HassioResponse<HassioDockerRegistries>>(
"GET", "GET",
@ -18,6 +29,16 @@ export const addHassioDockerRegistry = async (
hass: HomeAssistant, hass: HomeAssistant,
data: HassioDockerRegistries data: HassioDockerRegistries
) => { ) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries`,
method: "post",
data,
});
return;
}
await hass.callApi<HassioResponse<HassioDockerRegistries>>( await hass.callApi<HassioResponse<HassioDockerRegistries>>(
"POST", "POST",
"hassio/docker/registries", "hassio/docker/registries",
@ -29,6 +50,15 @@ export const removeHassioDockerRegistry = async (
hass: HomeAssistant, hass: HomeAssistant,
registry: string registry: string
) => { ) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/docker/registries/${registry}`,
method: "delete",
});
return;
}
await hass.callApi<HassioResponse<void>>( await hass.callApi<HassioResponse<void>>(
"DELETE", "DELETE",
`hassio/docker/registries/${registry}` `hassio/docker/registries/${registry}`

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import { hassioApiResultExtractor, HassioResponse } from "./common"; import { hassioApiResultExtractor, HassioResponse } from "./common";
@ -21,7 +22,17 @@ export interface HassioHardwareInfo {
audio: Record<string, unknown>; audio: Record<string, unknown>;
} }
export const fetchHassioHardwareAudio = async (hass: HomeAssistant) => { export const fetchHassioHardwareAudio = async (
hass: HomeAssistant
): Promise<HassioHardwareAudioList> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/audio`,
method: "get",
});
}
return hassioApiResultExtractor( return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHardwareAudioList>>( await hass.callApi<HassioResponse<HassioHardwareAudioList>>(
"GET", "GET",
@ -30,7 +41,17 @@ export const fetchHassioHardwareAudio = async (hass: HomeAssistant) => {
); );
}; };
export const fetchHassioHardwareInfo = async (hass: HomeAssistant) => { export const fetchHassioHardwareInfo = async (
hass: HomeAssistant
): Promise<HassioHardwareInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: `/hardware/info`,
method: "get",
});
}
return hassioApiResultExtractor( return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHardwareInfo>>( await hass.callApi<HassioResponse<HassioHardwareInfo>>(
"GET", "GET",

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import { hassioApiResultExtractor, HassioResponse } from "./common"; import { hassioApiResultExtractor, HassioResponse } from "./common";
@ -23,7 +24,17 @@ export interface HassioHassOSInfo {
version: string | null; version: string | null;
} }
export const fetchHassioHostInfo = async (hass: HomeAssistant) => { export const fetchHassioHostInfo = async (
hass: HomeAssistant
): Promise<HassioHostInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: "/host/info",
method: "get",
});
}
const response = await hass.callApi<HassioResponse<HassioHostInfo>>( const response = await hass.callApi<HassioResponse<HassioHostInfo>>(
"GET", "GET",
"hassio/host/info" "hassio/host/info"
@ -31,7 +42,17 @@ export const fetchHassioHostInfo = async (hass: HomeAssistant) => {
return hassioApiResultExtractor(response); return hassioApiResultExtractor(response);
}; };
export const fetchHassioHassOsInfo = async (hass: HomeAssistant) => { export const fetchHassioHassOsInfo = async (
hass: HomeAssistant
): Promise<HassioHassOSInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: "/os/info",
method: "get",
});
}
return hassioApiResultExtractor( return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHassOSInfo>>( await hass.callApi<HassioResponse<HassioHassOSInfo>>(
"GET", "GET",
@ -41,22 +62,67 @@ export const fetchHassioHassOsInfo = async (hass: HomeAssistant) => {
}; };
export const rebootHost = async (hass: HomeAssistant) => { export const rebootHost = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: "/host/reboot",
method: "post",
timeout: null,
});
}
return hass.callApi<HassioResponse<void>>("POST", "hassio/host/reboot"); return hass.callApi<HassioResponse<void>>("POST", "hassio/host/reboot");
}; };
export const shutdownHost = async (hass: HomeAssistant) => { export const shutdownHost = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: "/host/shutdown",
method: "post",
timeout: null,
});
}
return hass.callApi<HassioResponse<void>>("POST", "hassio/host/shutdown"); return hass.callApi<HassioResponse<void>>("POST", "hassio/host/shutdown");
}; };
export const updateOS = async (hass: HomeAssistant) => { export const updateOS = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: "/os/update",
method: "post",
timeout: null,
});
}
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/update"); return hass.callApi<HassioResponse<void>>("POST", "hassio/os/update");
}; };
export const configSyncOS = async (hass: HomeAssistant) => { export const configSyncOS = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: "os/config/sync",
method: "post",
timeout: null,
});
}
return hass.callApi<HassioResponse<void>>("POST", "hassio/os/config/sync"); return hass.callApi<HassioResponse<void>>("POST", "hassio/os/config/sync");
}; };
export const changeHostOptions = async (hass: HomeAssistant, options: any) => { export const changeHostOptions = async (hass: HomeAssistant, options: any) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: "/host/options",
method: "post",
data: options,
});
}
return hass.callApi<HassioResponse<void>>( return hass.callApi<HassioResponse<void>>(
"POST", "POST",
"hassio/host/options", "hassio/host/options",

View File

@ -1,26 +1,50 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import { HassioResponse } from "./common"; import { HassioResponse } from "./common";
import { CreateSessionResponse } from "./supervisor"; import { CreateSessionResponse } from "./supervisor";
export const createHassioSession = async (hass: HomeAssistant) => { function setIngressCookie(session: string): string {
const response = await hass.callApi<HassioResponse<CreateSessionResponse>>( document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${
"POST",
"hassio/ingress/session"
);
document.cookie = `ingress_session=${
response.data.session
};path=/api/hassio_ingress/;SameSite=Strict${
location.protocol === "https:" ? ";Secure" : "" location.protocol === "https:" ? ";Secure" : ""
}`; }`;
return response.data.session; return session;
}
export const createHassioSession = async (
hass: HomeAssistant
): Promise<string> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
const wsResponse: { session: string } = await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/session",
method: "post",
});
return setIngressCookie(wsResponse.session);
}
const restResponse: { data: { session: string } } = await hass.callApi<
HassioResponse<CreateSessionResponse>
>("POST", "hassio/ingress/session");
return setIngressCookie(restResponse.data.session);
}; };
export const validateHassioSession = async ( export const validateHassioSession = async (
hass: HomeAssistant, hass: HomeAssistant,
session: string session: string
) => ): Promise<void> => {
await hass.callApi<HassioResponse<null>>( if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/ingress/validate_session",
method: "post",
data: { session },
});
return;
}
await hass.callApi<HassioResponse<void>>(
"POST", "POST",
"hassio/ingress/validate_session", "hassio/ingress/validate_session",
{ session } { session }
); );
};

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import { hassioApiResultExtractor, HassioResponse } from "./common"; import { hassioApiResultExtractor, HassioResponse } from "./common";
@ -51,7 +52,17 @@ export interface NetworkInfo {
docker: DockerNetwork; docker: DockerNetwork;
} }
export const fetchNetworkInfo = async (hass: HomeAssistant) => { export const fetchNetworkInfo = async (
hass: HomeAssistant
): Promise<NetworkInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: "/network/info",
method: "get",
});
}
return hassioApiResultExtractor( return hassioApiResultExtractor(
await hass.callApi<HassioResponse<NetworkInfo>>( await hass.callApi<HassioResponse<NetworkInfo>>(
"GET", "GET",
@ -65,6 +76,17 @@ export const updateNetworkInterface = async (
network_interface: string, network_interface: string,
options: Partial<NetworkInterface> options: Partial<NetworkInterface>
) => { ) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/update`,
method: "post",
data: options,
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<NetworkInfo>>( await hass.callApi<HassioResponse<NetworkInfo>>(
"POST", "POST",
`hassio/network/interface/${network_interface}/update`, `hassio/network/interface/${network_interface}/update`,
@ -75,7 +97,16 @@ export const updateNetworkInterface = async (
export const accesspointScan = async ( export const accesspointScan = async (
hass: HomeAssistant, hass: HomeAssistant,
network_interface: string network_interface: string
) => { ): Promise<AccessPoints> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: `/network/interface/${network_interface}/accesspoints`,
method: "get",
timeout: null,
});
}
return hassioApiResultExtractor( return hassioApiResultExtractor(
await hass.callApi<HassioResponse<AccessPoints>>( await hass.callApi<HassioResponse<AccessPoints>>(
"GET", "GET",

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import { hassioApiResultExtractor, HassioResponse } from "./common"; import { hassioApiResultExtractor, HassioResponse } from "./common";
@ -8,7 +9,17 @@ export interface HassioResolution {
suggestions: string[]; suggestions: string[];
} }
export const fetchHassioResolution = async (hass: HomeAssistant) => { export const fetchHassioResolution = async (
hass: HomeAssistant
): Promise<HassioResolution> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: "/resolution/info",
method: "get",
});
}
return hassioApiResultExtractor( return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioResolution>>( await hass.callApi<HassioResponse<HassioResolution>>(
"GET", "GET",

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import { hassioApiResultExtractor, HassioResponse } from "./common"; import { hassioApiResultExtractor, HassioResponse } from "./common";
@ -33,7 +34,18 @@ export interface HassioPartialSnapshotCreateParams {
password?: string; password?: string;
} }
export const fetchHassioSnapshots = async (hass: HomeAssistant) => { export const fetchHassioSnapshots = async (
hass: HomeAssistant
): Promise<HassioSnapshot[]> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
const data: { snapshots: HassioSnapshot[] } = await hass.callWS({
type: "supervisor/api",
endpoint: `/snapshots`,
method: "get",
});
return data.snapshots;
}
return hassioApiResultExtractor( return hassioApiResultExtractor(
await hass.callApi<HassioResponse<{ snapshots: HassioSnapshot[] }>>( await hass.callApi<HassioResponse<{ snapshots: HassioSnapshot[] }>>(
"GET", "GET",
@ -45,8 +57,15 @@ export const fetchHassioSnapshots = async (hass: HomeAssistant) => {
export const fetchHassioSnapshotInfo = async ( export const fetchHassioSnapshotInfo = async (
hass: HomeAssistant, hass: HomeAssistant,
snapshot: string snapshot: string
) => { ): Promise<HassioSnapshotDetail> => {
if (hass) { if (hass) {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: `/snapshots/${snapshot}/info`,
method: "get",
});
}
return hassioApiResultExtractor( return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioSnapshotDetail>>( await hass.callApi<HassioResponse<HassioSnapshotDetail>>(
"GET", "GET",
@ -63,6 +82,15 @@ export const fetchHassioSnapshotInfo = async (
}; };
export const reloadHassioSnapshots = async (hass: HomeAssistant) => { export const reloadHassioSnapshots = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/snapshots/reload",
method: "post",
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/snapshots/reload`); await hass.callApi<HassioResponse<void>>("POST", `hassio/snapshots/reload`);
}; };
@ -70,6 +98,15 @@ export const createHassioFullSnapshot = async (
hass: HomeAssistant, hass: HomeAssistant,
data: HassioFullSnapshotCreateParams data: HassioFullSnapshotCreateParams
) => { ) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/snapshots/new/full",
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>( await hass.callApi<HassioResponse<void>>(
"POST", "POST",
`hassio/snapshots/new/full`, `hassio/snapshots/new/full`,
@ -81,6 +118,17 @@ export const createHassioPartialSnapshot = async (
hass: HomeAssistant, hass: HomeAssistant,
data: HassioFullSnapshotCreateParams data: HassioFullSnapshotCreateParams
) => { ) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/snapshots/new/partial",
method: "post",
timeout: null,
data,
});
return;
}
await hass.callApi<HassioResponse<void>>( await hass.callApi<HassioResponse<void>>(
"POST", "POST",
`hassio/snapshots/new/partial`, `hassio/snapshots/new/partial`,

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant, PanelInfo } from "../../types"; import { HomeAssistant, PanelInfo } from "../../types";
import { SupervisorArch } from "../supervisor/supervisor"; import { SupervisorArch } from "../supervisor/supervisor";
import { HassioAddonInfo, HassioAddonRepository } from "./addon"; import { HassioAddonInfo, HassioAddonRepository } from "./addon";
@ -49,6 +50,15 @@ export type HassioInfo = {
hostname: string; hostname: string;
logging: string; logging: string;
machine: string; machine: string;
state:
| "initialize"
| "setup"
| "startup"
| "running"
| "freeze"
| "shutdown"
| "stopping"
| "close";
operating_system: string; operating_system: string;
supervisor: string; supervisor: string;
supported: boolean; supported: boolean;
@ -74,18 +84,57 @@ export interface SupervisorOptions {
} }
export const reloadSupervisor = async (hass: HomeAssistant) => { export const reloadSupervisor = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/reload",
method: "post",
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/reload`); await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/reload`);
}; };
export const restartSupervisor = async (hass: HomeAssistant) => { export const restartSupervisor = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/restart",
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/restart`); await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/restart`);
}; };
export const updateSupervisor = async (hass: HomeAssistant) => { export const updateSupervisor = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/update",
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/update`); await hass.callApi<HassioResponse<void>>("POST", `hassio/supervisor/update`);
}; };
export const fetchHassioHomeAssistantInfo = async (hass: HomeAssistant) => { export const fetchHassioHomeAssistantInfo = async (
hass: HomeAssistant
): Promise<HassioHomeAssistantInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: "/core/info",
method: "get",
});
}
return hassioApiResultExtractor( return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioHomeAssistantInfo>>( await hass.callApi<HassioResponse<HassioHomeAssistantInfo>>(
"GET", "GET",
@ -94,7 +143,17 @@ export const fetchHassioHomeAssistantInfo = async (hass: HomeAssistant) => {
); );
}; };
export const fetchHassioSupervisorInfo = async (hass: HomeAssistant) => { export const fetchHassioSupervisorInfo = async (
hass: HomeAssistant
): Promise<HassioSupervisorInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/info",
method: "get",
});
}
return hassioApiResultExtractor( return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioSupervisorInfo>>( await hass.callApi<HassioResponse<HassioSupervisorInfo>>(
"GET", "GET",
@ -103,7 +162,17 @@ export const fetchHassioSupervisorInfo = async (hass: HomeAssistant) => {
); );
}; };
export const fetchHassioInfo = async (hass: HomeAssistant) => { export const fetchHassioInfo = async (
hass: HomeAssistant
): Promise<HassioInfo> => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
return await hass.callWS({
type: "supervisor/api",
endpoint: "/info",
method: "get",
});
}
return hassioApiResultExtractor( return hassioApiResultExtractor(
await hass.callApi<HassioResponse<HassioInfo>>("GET", "hassio/info") await hass.callApi<HassioResponse<HassioInfo>>("GET", "hassio/info")
); );
@ -120,6 +189,16 @@ export const setSupervisorOption = async (
hass: HomeAssistant, hass: HomeAssistant,
data: SupervisorOptions data: SupervisorOptions
) => { ) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/supervisor/options",
method: "post",
data,
});
return;
}
await hass.callApi<HassioResponse<void>>( await hass.callApi<HassioResponse<void>>(
"POST", "POST",
"hassio/supervisor/options", "hassio/supervisor/options",

View File

@ -2,6 +2,7 @@ import {
Connection, Connection,
getCollection, getCollection,
HassEventBase, HassEventBase,
HassServiceTarget,
} from "home-assistant-js-websocket"; } from "home-assistant-js-websocket";
import { HASSDomEvent } from "../common/dom/fire_event"; import { HASSDomEvent } from "../common/dom/fire_event";
import { HuiErrorCard } from "../panels/lovelace/cards/hui-error-card"; import { HuiErrorCard } from "../panels/lovelace/cards/hui-error-card";
@ -120,8 +121,8 @@ export interface ToggleActionConfig extends BaseActionConfig {
export interface CallServiceActionConfig extends BaseActionConfig { export interface CallServiceActionConfig extends BaseActionConfig {
action: "call-service"; action: "call-service";
service: string; service: string;
target?: HassServiceTarget;
service_data?: { service_data?: {
entity_id?: string | [string];
[key: string]: any; [key: string]: any;
}; };
} }

View File

@ -1,6 +1,7 @@
import { import {
HassEntityAttributeBase, HassEntityAttributeBase,
HassEntityBase, HassEntityBase,
HassServiceTarget,
} from "home-assistant-js-websocket"; } from "home-assistant-js-websocket";
import { computeObjectId } from "../common/entity/compute_object_id"; import { computeObjectId } from "../common/entity/compute_object_id";
import { navigate } from "../common/navigate"; import { navigate } from "../common/navigate";
@ -36,6 +37,7 @@ export interface EventAction {
export interface ServiceAction { export interface ServiceAction {
service: string; service: string;
entity_id?: string; entity_id?: string;
target?: HassServiceTarget;
data?: Record<string, any>; data?: Record<string, any>;
} }

View File

@ -8,8 +8,8 @@ export type Selector =
| TimeSelector | TimeSelector
| ActionSelector | ActionSelector
| StringSelector | StringSelector
| ObjectSelector; | ObjectSelector
| SelectSelector;
export interface EntitySelector { export interface EntitySelector {
entity: { entity: {
integration?: string; integration?: string;
@ -95,3 +95,9 @@ export interface ObjectSelector {
// eslint-disable-next-line @typescript-eslint/ban-types // eslint-disable-next-line @typescript-eslint/ban-types
object: {}; object: {};
} }
export interface SelectSelector {
select: {
options: string[];
};
}

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import { HassioResponse } from "../hassio/common"; import { HassioResponse } from "../hassio/common";
@ -6,5 +7,15 @@ export const restartCore = async (hass: HomeAssistant) => {
}; };
export const updateCore = async (hass: HomeAssistant) => { export const updateCore = async (hass: HomeAssistant) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/core/update",
method: "post",
timeout: null,
});
return;
}
await hass.callApi<HassioResponse<void>>("POST", `hassio/core/update`); await hass.callApi<HassioResponse<void>>("POST", `hassio/core/update`);
}; };

View File

@ -1,3 +1,7 @@
import { Connection, getCollection } from "home-assistant-js-websocket";
import { Store } from "home-assistant-js-websocket/dist/store";
import { HomeAssistant } from "../../types";
import { HassioAddonsInfo } from "../hassio/addon";
import { HassioHassOSInfo, HassioHostInfo } from "../hassio/host"; import { HassioHassOSInfo, HassioHostInfo } from "../hassio/host";
import { NetworkInfo } from "../hassio/network"; import { NetworkInfo } from "../hassio/network";
import { HassioResolution } from "../hassio/resolution"; import { HassioResolution } from "../hassio/resolution";
@ -7,7 +11,46 @@ import {
HassioSupervisorInfo, HassioSupervisorInfo,
} from "../hassio/supervisor"; } from "../hassio/supervisor";
export const supervisorWSbaseCommand = {
type: "supervisor/api",
method: "GET",
};
export const supervisorStore = {
host: "/host/info",
supervisor: "/supervisor/info",
info: "/info",
core: "/core/info",
network: "/network/info",
resolution: "/resolution/info",
os: "/os/info",
addon: "/addons",
};
export type SupervisorArch = "armhf" | "armv7" | "aarch64" | "i386" | "amd64"; export type SupervisorArch = "armhf" | "armv7" | "aarch64" | "i386" | "amd64";
export type SupervisorObject =
| "host"
| "supervisor"
| "info"
| "core"
| "network"
| "resolution"
| "os"
| "addon";
interface supervisorApiRequest {
endpoint: string;
method?: "get" | "post" | "delete" | "put";
force_rest?: boolean;
data?: any;
}
export interface SupervisorEvent {
event: string;
update_key?: SupervisorObject;
data?: any;
[key: string]: any;
}
export interface Supervisor { export interface Supervisor {
host: HassioHostInfo; host: HassioHostInfo;
@ -17,4 +60,77 @@ export interface Supervisor {
network: NetworkInfo; network: NetworkInfo;
resolution: HassioResolution; resolution: HassioResolution;
os: HassioHassOSInfo; os: HassioHassOSInfo;
addon: HassioAddonsInfo;
} }
export const supervisorApiWsRequest = <T>(
conn: Connection,
request: supervisorApiRequest
): Promise<T> =>
conn.sendMessagePromise<T>({ ...supervisorWSbaseCommand, ...request });
async function processEvent(
conn: Connection,
store: Store<any>,
event: SupervisorEvent,
key: string
) {
if (
!event.data ||
event.data.event !== "supervisor-update" ||
event.data.update_key !== key
) {
return;
}
if (Object.keys(event.data.data).length === 0) {
const data = await supervisorApiWsRequest<any>(conn, {
endpoint: supervisorStore[key],
});
store.setState(data);
return;
}
const state = store.state;
if (state === undefined) {
return;
}
store.setState({
...state,
...event.data.data,
});
}
const subscribeSupervisorEventUpdates = (
conn: Connection,
store: Store<unknown>,
key: string
) =>
conn.subscribeEvents(
(event) => processEvent(conn, store, event as SupervisorEvent, key),
"supervisor_event"
);
export const getSupervisorEventCollection = (
conn: Connection,
key: string,
endpoint: string
) =>
getCollection(
conn,
`_supervisor${key}Event`,
() => supervisorApiWsRequest(conn, { endpoint }),
(connection, store) =>
subscribeSupervisorEventUpdates(connection, store, key)
);
export const subscribeSupervisorEvents = (
hass: HomeAssistant,
onChange: (event) => void,
key: string,
endpoint: string
) =>
getSupervisorEventCollection(hass.connection, key, endpoint).subscribe(
onChange
);

View File

@ -1,5 +0,0 @@
export interface Target {
entity_id?: string[];
device_id?: string[];
area_id?: string[];
}

View File

@ -22,7 +22,9 @@ import {
AreaRegistryEntry, AreaRegistryEntry,
subscribeAreaRegistry, subscribeAreaRegistry,
} from "../../data/area_registry"; } from "../../data/area_registry";
import { fetchConfigFlowInProgress } from "../../data/config_flow";
import type { import type {
DataEntryFlowProgress,
DataEntryFlowProgressedEvent, DataEntryFlowProgressedEvent,
DataEntryFlowStep, DataEntryFlowStep,
} from "../../data/data_entry_flow"; } from "../../data/data_entry_flow";
@ -41,6 +43,7 @@ import "./step-flow-form";
import "./step-flow-loading"; import "./step-flow-loading";
import "./step-flow-pick-handler"; import "./step-flow-pick-handler";
import "./step-flow-progress"; import "./step-flow-progress";
import "./step-flow-pick-flow";
let instance = 0; let instance = 0;
@ -76,6 +79,10 @@ class DataEntryFlowDialog extends LitElement {
@internalProperty() private _handlers?: string[]; @internalProperty() private _handlers?: string[];
@internalProperty() private _handler?: string;
@internalProperty() private _flowsInProgress?: DataEntryFlowProgress[];
private _unsubAreas?: UnsubscribeFunc; private _unsubAreas?: UnsubscribeFunc;
private _unsubDevices?: UnsubscribeFunc; private _unsubDevices?: UnsubscribeFunc;
@ -84,59 +91,93 @@ class DataEntryFlowDialog extends LitElement {
this._params = params; this._params = params;
this._instance = instance++; this._instance = instance++;
if (params.startFlowHandler) {
this._checkFlowsInProgress(params.startFlowHandler);
return;
}
if (params.continueFlowId) {
this._loading = true;
const curInstance = this._instance;
let step: DataEntryFlowStep;
try {
step = await params.flowConfig.fetchFlow(
this.hass,
params.continueFlowId
);
} catch (err) {
this._step = undefined;
this._params = undefined;
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_flow.error"
),
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.could_not_load"
),
});
return;
}
// Happens if second showDialog called
if (curInstance !== this._instance) {
return;
}
this._processStep(step);
this._loading = false;
return;
}
// Create a new config flow. Show picker // Create a new config flow. Show picker
if (!params.continueFlowId && !params.startFlowHandler) { if (!params.flowConfig.getFlowHandlers) {
if (!params.flowConfig.getFlowHandlers) { throw new Error("No getFlowHandlers defined in flow config");
throw new Error("No getFlowHandlers defined in flow config"); }
this._step = null;
// We only load the handlers once
if (this._handlers === undefined) {
this._loading = true;
try {
this._handlers = await params.flowConfig.getFlowHandlers(this.hass);
} finally {
this._loading = false;
} }
this._step = null;
// We only load the handlers once
if (this._handlers === undefined) {
this._loading = true;
try {
this._handlers = await params.flowConfig.getFlowHandlers(this.hass);
} finally {
this._loading = false;
}
}
await this.updateComplete;
return;
} }
await this.updateComplete;
this._loading = true;
const curInstance = this._instance;
let step: DataEntryFlowStep;
try {
step = await (params.continueFlowId
? params.flowConfig.fetchFlow(this.hass, params.continueFlowId)
: params.flowConfig.createFlow(this.hass, params.startFlowHandler!));
} catch (err) {
this._step = undefined;
this._params = undefined;
showAlertDialog(this, {
title: "Error",
text: "Config flow could not be loaded",
});
return;
}
// Happens if second showDialog called
if (curInstance !== this._instance) {
return;
}
this._processStep(step);
this._loading = false;
} }
public closeDialog() { public closeDialog() {
if (this._step) { if (!this._params) {
this._flowDone(); return;
} else if (this._step === null) { }
// Flow aborted during picking flow const flowFinished = Boolean(
this._step = undefined; this._step && ["create_entry", "abort"].includes(this._step.type)
this._params = undefined; );
// If we created this flow, delete it now.
if (this._step && !flowFinished && !this._params.continueFlowId) {
this._params.flowConfig.deleteFlow(this.hass, this._step.flow_id);
}
if (this._step !== null && this._params.dialogClosedCallback) {
this._params.dialogClosedCallback({
flowFinished,
});
}
this._step = undefined;
this._params = undefined;
this._devices = undefined;
this._flowsInProgress = undefined;
this._handler = undefined;
if (this._unsubAreas) {
this._unsubAreas();
this._unsubAreas = undefined;
}
if (this._unsubDevices) {
this._unsubDevices();
this._unsubDevices = undefined;
} }
fireEvent(this, "dialog-closed", { dialog: this.localName }); fireEvent(this, "dialog-closed", { dialog: this.localName });
} }
@ -156,7 +197,9 @@ class DataEntryFlowDialog extends LitElement {
> >
<div> <div>
${this._loading || ${this._loading ||
(this._step === null && this._handlers === undefined) (this._step === null &&
this._handlers === undefined &&
this._handler === undefined)
? html` ? html`
<step-flow-loading <step-flow-loading
.label=${this.hass.localize( .label=${this.hass.localize(
@ -178,15 +221,22 @@ class DataEntryFlowDialog extends LitElement {
?rtl=${computeRTL(this.hass)} ?rtl=${computeRTL(this.hass)}
></ha-icon-button> ></ha-icon-button>
${this._step === null ${this._step === null
? // Show handler picker ? this._handler
html` ? html`<step-flow-pick-flow
<step-flow-pick-handler
.flowConfig=${this._params.flowConfig} .flowConfig=${this._params.flowConfig}
.hass=${this.hass} .hass=${this.hass}
.handlers=${this._handlers} .handler=${this._handler}
.showAdvanced=${this._params.showAdvanced} .flowsInProgress=${this._flowsInProgress}
></step-flow-pick-handler> ></step-flow-pick-flow>`
` : // Show handler picker
html`
<step-flow-pick-handler
.hass=${this.hass}
.handlers=${this._handlers}
.showAdvanced=${this._params.showAdvanced}
@handler-picked=${this._handlerPicked}
></step-flow-pick-handler>
`
: this._step.type === "form" : this._step.type === "form"
? html` ? html`
<step-flow-form <step-flow-form
@ -291,6 +341,43 @@ class DataEntryFlowDialog extends LitElement {
}); });
} }
private async _checkFlowsInProgress(handler: string) {
this._loading = true;
const flowsInProgress = (
await fetchConfigFlowInProgress(this.hass.connection)
).filter((flow) => flow.handler === handler);
if (!flowsInProgress.length) {
let step: DataEntryFlowStep;
try {
step = await this._params!.flowConfig.createFlow(this.hass, handler);
} catch (err) {
this._step = undefined;
this._params = undefined;
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.integrations.config_flow.error"
),
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.could_not_load"
),
});
return;
}
this._processStep(step);
} else {
this._step = null;
this._handler = handler;
this._flowsInProgress = flowsInProgress;
}
this._loading = false;
}
private _handlerPicked(ev) {
this._checkFlowsInProgress(ev.detail.handler);
}
private async _processStep( private async _processStep(
step: DataEntryFlowStep | undefined | Promise<DataEntryFlowStep> step: DataEntryFlowStep | undefined | Promise<DataEntryFlowStep>
): Promise<void> { ): Promise<void> {
@ -305,7 +392,7 @@ class DataEntryFlowDialog extends LitElement {
} }
if (step === undefined) { if (step === undefined) {
this._flowDone(); this.closeDialog();
return; return;
} }
this._step = undefined; this._step = undefined;
@ -313,38 +400,6 @@ class DataEntryFlowDialog extends LitElement {
this._step = step; this._step = step;
} }
private _flowDone(): void {
if (!this._params) {
return;
}
const flowFinished = Boolean(
this._step && ["create_entry", "abort"].includes(this._step.type)
);
// If we created this flow, delete it now.
if (this._step && !flowFinished && !this._params.continueFlowId) {
this._params.flowConfig.deleteFlow(this.hass, this._step.flow_id);
}
if (this._params.dialogClosedCallback) {
this._params.dialogClosedCallback({
flowFinished,
});
}
this._step = undefined;
this._params = undefined;
this._devices = undefined;
if (this._unsubAreas) {
this._unsubAreas();
this._unsubAreas = undefined;
}
if (this._unsubDevices) {
this._unsubDevices();
this._unsubDevices = undefined;
}
}
static get styles(): CSSResultArray { static get styles(): CSSResultArray {
return [ return [
haStyleDialog, haStyleDialog,

View File

@ -0,0 +1,130 @@
import "@polymer/paper-item/paper-icon-item";
import "@polymer/paper-item";
import "@polymer/paper-item/paper-item-body";
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from "lit-element";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-icon-next";
import { localizeConfigFlowTitle } from "../../data/config_flow";
import { DataEntryFlowProgress } from "../../data/data_entry_flow";
import { domainToName } from "../../data/integration";
import { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
@customElement("step-flow-pick-flow")
class StepFlowPickFlow extends LitElement {
public flowConfig!: FlowConfig;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public flowsInProgress!: DataEntryFlowProgress[];
@property() public handler!: string;
protected render(): TemplateResult {
return html`
<h2>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.pick_flow_step.title"
)}
</h2>
<div>
${this.flowsInProgress.map(
(flow) => html` <paper-icon-item
@click=${this._flowInProgressPicked}
.flow=${flow}
>
<img
slot="item-icon"
loading="lazy"
src=${brandsUrl(flow.handler, "icon", true)}
referrerpolicy="no-referrer"
/>
<paper-item-body>
${localizeConfigFlowTitle(this.hass.localize, flow)}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-icon-item>`
)}
<paper-item @click=${this._startNewFlowPicked} .handler=${this.handler}>
<paper-item-body>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.pick_flow_step.new_flow",
"integration",
domainToName(this.hass.localize, this.handler)
)}
</paper-item-body>
<ha-icon-next></ha-icon-next>
</paper-item>
</div>
`;
}
private _startNewFlowPicked(ev) {
this._startFlow(ev.currentTarget.handler);
}
private _startFlow(handler: string) {
fireEvent(this, "flow-update", {
stepPromise: this.flowConfig.createFlow(this.hass, handler),
});
}
private _flowInProgressPicked(ev) {
const flow: DataEntryFlowProgress = ev.currentTarget.flow;
fireEvent(this, "flow-update", {
stepPromise: this.flowConfig.fetchFlow(this.hass, flow.flow_id),
});
}
static get styles(): CSSResult[] {
return [
configFlowContentStyles,
css`
img {
width: 40px;
height: 40px;
}
ha-icon-next {
margin-right: 8px;
}
div {
overflow: auto;
max-height: 600px;
margin: 16px 0;
}
h2 {
padding-right: 66px;
}
@media all and (max-height: 900px) {
div {
max-height: calc(100vh - 134px);
}
}
paper-icon-item,
paper-item {
cursor: pointer;
margin-bottom: 4px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"step-flow-pick-flow": StepFlowPickFlow;
}
}

View File

@ -22,7 +22,6 @@ import { domainToName } from "../../data/integration";
import { HomeAssistant } from "../../types"; import { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url"; import { brandsUrl } from "../../util/brands-url";
import { documentationUrl } from "../../util/documentation-url"; import { documentationUrl } from "../../util/documentation-url";
import { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles"; import { configFlowContentStyles } from "./styles";
interface HandlerObj { interface HandlerObj {
@ -30,17 +29,24 @@ interface HandlerObj {
slug: string; slug: string;
} }
declare global {
// for fire event
interface HASSDomEvents {
"handler-picked": {
handler: string;
};
}
}
@customElement("step-flow-pick-handler") @customElement("step-flow-pick-handler")
class StepFlowPickHandler extends LitElement { class StepFlowPickHandler extends LitElement {
public flowConfig!: FlowConfig;
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property() public handlers!: string[]; @property() public handlers!: string[];
@property() public showAdvanced?: boolean; @property() public showAdvanced?: boolean;
@internalProperty() private filter?: string; @internalProperty() private _filter?: string;
private _width?: number; private _width?: number;
@ -74,7 +80,7 @@ class StepFlowPickHandler extends LitElement {
protected render(): TemplateResult { protected render(): TemplateResult {
const handlers = this._getHandlers( const handlers = this._getHandlers(
this.handlers, this.handlers,
this.filter, this._filter,
this.hass.localize this.hass.localize
); );
@ -82,7 +88,7 @@ class StepFlowPickHandler extends LitElement {
<h2>${this.hass.localize("ui.panel.config.integrations.new")}</h2> <h2>${this.hass.localize("ui.panel.config.integrations.new")}</h2>
<search-input <search-input
autofocus autofocus
.filter=${this.filter} .filter=${this._filter}
@value-changed=${this._filterChanged} @value-changed=${this._filterChanged}
.label=${this.hass.localize("ui.panel.config.integrations.search")} .label=${this.hass.localize("ui.panel.config.integrations.search")}
></search-input> ></search-input>
@ -164,15 +170,12 @@ class StepFlowPickHandler extends LitElement {
} }
private async _filterChanged(e) { private async _filterChanged(e) {
this.filter = e.detail.value; this._filter = e.detail.value;
} }
private async _handlerPicked(ev) { private async _handlerPicked(ev) {
fireEvent(this, "flow-update", { fireEvent(this, "handler-picked", {
stepPromise: this.flowConfig.createFlow( handler: ev.currentTarget.handler.slug,
this.hass,
ev.currentTarget.handler.slug
),
}); });
} }
@ -195,6 +198,9 @@ class StepFlowPickHandler extends LitElement {
overflow: auto; overflow: auto;
max-height: 600px; max-height: 600px;
} }
h2 {
padding-right: 66px;
}
@media all and (max-height: 900px) { @media all and (max-height: 900px) {
div { div {
max-height: calc(100vh - 134px); max-height: calc(100vh - 134px);

View File

@ -380,22 +380,24 @@ export class QuickBar extends LitElement {
QuickBarNavigationItem, QuickBarNavigationItem,
"action" "action"
>[] { >[] {
return Object.keys(this.hass.panels).map((panelKey) => { return Object.keys(this.hass.panels)
const panel = this.hass.panels[panelKey]; .filter((panelKey) => panelKey !== "_my_redirect")
const translationKey = getPanelNameTranslationKey(panel); .map((panelKey) => {
const panel = this.hass.panels[panelKey];
const translationKey = getPanelNameTranslationKey(panel);
const text = this.hass.localize( const text = this.hass.localize(
"ui.dialogs.quick-bar.commands.navigation.navigate_to", "ui.dialogs.quick-bar.commands.navigation.navigate_to",
"panel", "panel",
this.hass.localize(translationKey) || panel.title || panel.url_path this.hass.localize(translationKey) || panel.title || panel.url_path
); );
return { return {
text, text,
icon: getPanelIcon(panel) || DEFAULT_NAVIGATION_ICON, icon: getPanelIcon(panel) || DEFAULT_NAVIGATION_ICON,
path: `/${panel.url_path}`, path: `/${panel.url_path}`,
}; };
}); });
} }
private _generateNavigationConfigSectionCommands(): Partial< private _generateNavigationConfigSectionCommands(): Partial<

View File

@ -15,7 +15,8 @@ export const demoConfig: HassConfig = {
time_zone: "America/Los_Angeles", time_zone: "America/Los_Angeles",
config_dir: "/config", config_dir: "/config",
version: "DEMO", version: "DEMO",
whitelist_external_dirs: [], allowlist_external_dirs: [],
allowlist_external_urls: [],
config_source: "storage", config_source: "storage",
safe_mode: false, safe_mode: false,
state: STATE_RUNNING, state: STATE_RUNNING,

View File

@ -1,4 +1,4 @@
<meta name='viewport' content='width=device-width, viewport-fit=cover'> <meta name='viewport' content='width=device-width, user-scalable=no, viewport-fit=cover'>
<style> <style>
body { body {
font-family: Roboto, sans-serif; font-family: Roboto, sans-serif;

View File

@ -42,7 +42,6 @@ import "./types/ha-automation-action-wait_template";
const OPTIONS = [ const OPTIONS = [
"condition", "condition",
"delay", "delay",
"device_id",
"event", "event",
"scene", "scene",
"service", "service",
@ -50,6 +49,7 @@ const OPTIONS = [
"wait_for_trigger", "wait_for_trigger",
"repeat", "repeat",
"choose", "choose",
"device_id",
]; ];
const getType = (action: Action) => { const getType = (action: Action) => {
@ -99,6 +99,8 @@ export default class HaAutomationActionRow extends LitElement {
@property() public totalActions!: number; @property() public totalActions!: number;
@property({ type: Boolean }) public narrow = false;
@internalProperty() private _warnings?: string[]; @internalProperty() private _warnings?: string[];
@internalProperty() private _uiModeAvailable = true; @internalProperty() private _uiModeAvailable = true;
@ -116,8 +118,9 @@ export default class HaAutomationActionRow extends LitElement {
this._yamlMode = true; this._yamlMode = true;
} }
if (this._yamlMode && this._yamlEditor) { const yamlEditor = this._yamlEditor;
this._yamlEditor.setValue(this.action); if (this._yamlMode && yamlEditor && yamlEditor.value !== this.action) {
yamlEditor.setValue(this.action);
} }
} }
@ -242,6 +245,7 @@ export default class HaAutomationActionRow extends LitElement {
${dynamicElement(`ha-automation-action-${type}`, { ${dynamicElement(`ha-automation-action-${type}`, {
hass: this.hass, hass: this.hass,
action: this.action, action: this.action,
narrow: this.narrow,
})} })}
</div> </div>
`} `}

View File

@ -18,6 +18,8 @@ import { HaDeviceAction } from "./types/ha-automation-action-device_id";
export default class HaAutomationAction extends LitElement { export default class HaAutomationAction extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property() public actions!: Action[]; @property() public actions!: Action[];
protected render() { protected render() {
@ -28,6 +30,7 @@ export default class HaAutomationAction extends LitElement {
.index=${idx} .index=${idx}
.totalActions=${this.actions.length} .totalActions=${this.actions.length}
.action=${action} .action=${action}
.narrow=${this.narrow}
@duplicate=${this._duplicateAction} @duplicate=${this._duplicateAction}
@move-action=${this._move} @move-action=${this._move}
@value-changed=${this._actionChanged} @value-changed=${this._actionChanged}

View File

@ -22,13 +22,17 @@ export class HaDelayAction extends LitElement implements ActionElement {
let data: HaFormTimeData = {}; let data: HaFormTimeData = {};
if (typeof this.action.delay !== "object") { if (typeof this.action.delay !== "object") {
const parts = this.action.delay?.toString().split(":") || []; if (isNaN(this.action.delay)) {
data = { const parts = this.action.delay?.toString().split(":") || [];
hours: Number(parts[0]), data = {
minutes: Number(parts[1]), hours: Number(parts[0]) || 0,
seconds: Number(parts[2]), minutes: Number(parts[1]) || 0,
milliseconds: Number(parts[3]), seconds: Number(parts[2]) || 0,
}; milliseconds: Number(parts[3]) || 0,
};
} else {
data = { seconds: this.action.delay };
}
} else { } else {
const { days, minutes, seconds, milliseconds } = this.action.delay; const { days, minutes, seconds, milliseconds } = this.action.delay;
let { hours } = this.action.delay || 0; let { hours } = this.action.delay || 0;
@ -46,7 +50,8 @@ export class HaDelayAction extends LitElement implements ActionElement {
.data=${data} .data=${data}
enableMillisecond enableMillisecond
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-time-input> >
</ha-time-input>
`; `;
} }

View File

@ -1,30 +1,26 @@
import "@polymer/paper-input/paper-input"; import "@polymer/paper-input/paper-input";
import { import {
css,
CSSResult,
customElement, customElement,
internalProperty,
LitElement, LitElement,
property, property,
PropertyValues, PropertyValues,
query,
} from "lit-element"; } from "lit-element";
import { html } from "lit-html"; import { html } from "lit-html";
import memoizeOne from "memoize-one";
import { any, assert, object, optional, string } from "superstruct"; import { any, assert, object, optional, string } from "superstruct";
import { fireEvent } from "../../../../../common/dom/fire_event"; import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import { computeObjectId } from "../../../../../common/entity/compute_object_id";
import "../../../../../components/entity/ha-entity-picker";
import "../../../../../components/ha-service-picker";
import "../../../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../../../components/ha-yaml-editor";
import { ServiceAction } from "../../../../../data/script"; import { ServiceAction } from "../../../../../data/script";
import type { PolymerChangedEvent } from "../../../../../polymer-types";
import type { HomeAssistant } from "../../../../../types"; import type { HomeAssistant } from "../../../../../types";
import { EntityIdOrAll } from "../../../../../common/structs/is-entity-id"; import { EntityIdOrAll } from "../../../../../common/structs/is-entity-id";
import { ActionElement, handleChangeEvent } from "../ha-automation-action-row"; import { ActionElement } from "../ha-automation-action-row";
import "../../../../../components/ha-service-control";
const actionStruct = object({ const actionStruct = object({
service: optional(string()), service: optional(string()),
entity_id: optional(EntityIdOrAll), entity_id: optional(EntityIdOrAll),
target: optional(any()),
data: optional(any()), data: optional(any()),
}); });
@ -34,36 +30,14 @@ export class HaServiceAction extends LitElement implements ActionElement {
@property({ attribute: false }) public action!: ServiceAction; @property({ attribute: false }) public action!: ServiceAction;
@query("ha-yaml-editor", true) private _yamlEditor?: HaYamlEditor; @property({ type: Boolean }) public narrow = false;
private _actionData?: ServiceAction["data"]; @internalProperty() private _action!: ServiceAction;
public static get defaultConfig() { public static get defaultConfig() {
return { service: "", data: {} }; return { service: "", data: {} };
} }
private _domain = memoizeOne((service: string) => [computeDomain(service)]);
private _getServiceData = memoizeOne((service: string) => {
if (!service) {
return [];
}
const domain = computeDomain(service);
const serviceName = computeObjectId(service);
const serviceDomains = this.hass.services;
if (!(domain in serviceDomains)) {
return [];
}
if (!(serviceName in serviceDomains[domain])) {
return [];
}
const fields = serviceDomains[domain][serviceName].fields;
return Object.keys(fields).map((field) => {
return { key: field, ...fields[field] };
});
});
protected updated(changedProperties: PropertyValues) { protected updated(changedProperties: PropertyValues) {
if (!changedProperties.has("action")) { if (!changedProperties.has("action")) {
return; return;
@ -73,73 +47,42 @@ export class HaServiceAction extends LitElement implements ActionElement {
} catch (error) { } catch (error) {
fireEvent(this, "ui-mode-not-available", error); fireEvent(this, "ui-mode-not-available", error);
} }
if (this._actionData && this._actionData !== this.action.data) { if (this.action.entity_id) {
if (this._yamlEditor) { this._action = {
this._yamlEditor.setValue(this.action.data); ...this.action,
} data: { ...this.action.data, entity_id: this.action.entity_id },
};
delete this._action.entity_id;
} else {
this._action = this.action;
} }
this._actionData = this.action.data;
} }
protected render() { protected render() {
const { service, data, entity_id } = this.action;
const serviceData = this._getServiceData(service);
const entity = serviceData.find((attr) => attr.key === "entity_id");
return html` return html`
<ha-service-picker <ha-service-control
.narrow=${this.narrow}
.hass=${this.hass} .hass=${this.hass}
.value=${service} .value=${this._action}
@value-changed=${this._serviceChanged} .showAdvanced=${this.hass.userData?.showAdvanced}
></ha-service-picker> @value-changed=${this._actionChanged}
${entity ></ha-service-control>
? html`
<ha-entity-picker
.hass=${this.hass}
.value=${entity_id}
.label=${entity.description}
@value-changed=${this._entityPicked}
.includeDomains=${this._domain(service)}
allow-custom-entity
></ha-entity-picker>
`
: ""}
<ha-yaml-editor
.label=${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.service.service_data"
)}
.name=${"data"}
.defaultValue=${data}
@value-changed=${this._dataChanged}
></ha-yaml-editor>
`; `;
} }
private _dataChanged(ev: CustomEvent): void { private _actionChanged(ev) {
ev.stopPropagation(); if (ev.detail.value === this._action) {
if (!ev.detail.isValid) { ev.stopPropagation();
return;
} }
this._actionData = ev.detail.value;
handleChangeEvent(this, ev);
} }
private _serviceChanged(ev: PolymerChangedEvent<string>) { static get styles(): CSSResult {
ev.stopPropagation(); return css`
if (ev.detail.value === this.action.service) { ha-service-control {
return; display: block;
} margin: 0 -16px;
fireEvent(this, "value-changed", { }
value: { ...this.action, service: ev.detail.value }, `;
});
}
private _entityPicked(ev: PolymerChangedEvent<string>) {
ev.stopPropagation();
fireEvent(this, "value-changed", {
value: { ...this.action, entity_id: ev.detail.value },
});
} }
} }

View File

@ -252,10 +252,7 @@ export class HaBlueprintAutomationEditor extends LitElement {
if (!name) { if (!name) {
return; return;
} }
let newVal = ev.detail.value; const newVal = ev.detail.value;
if (target.type === "number") {
newVal = Number(newVal);
}
if ((this.config![name] || "") === newVal) { if ((this.config![name] || "") === newVal) {
return; return;
} }

View File

@ -42,7 +42,7 @@ export class HaManualAutomationEditor extends LitElement {
@property() public stateObj?: HassEntity; @property() public stateObj?: HassEntity;
protected render() { protected render() {
return html`<ha-config-section .isWide=${this.isWide}> return html`<ha-config-section vertical .isWide=${this.isWide}>
${!this.narrow ${!this.narrow
? html` <span slot="header">${this.config.alias}</span> ` ? html` <span slot="header">${this.config.alias}</span> `
: ""} : ""}
@ -151,7 +151,7 @@ export class HaManualAutomationEditor extends LitElement {
</ha-card> </ha-card>
</ha-config-section> </ha-config-section>
<ha-config-section .isWide=${this.isWide}> <ha-config-section vertical .isWide=${this.isWide}>
<span slot="header"> <span slot="header">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.automation.editor.triggers.header" "ui.panel.config.automation.editor.triggers.header"
@ -180,7 +180,7 @@ export class HaManualAutomationEditor extends LitElement {
></ha-automation-trigger> ></ha-automation-trigger>
</ha-config-section> </ha-config-section>
<ha-config-section .isWide=${this.isWide}> <ha-config-section vertical .isWide=${this.isWide}>
<span slot="header"> <span slot="header">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.automation.editor.conditions.header" "ui.panel.config.automation.editor.conditions.header"
@ -209,7 +209,7 @@ export class HaManualAutomationEditor extends LitElement {
></ha-automation-condition> ></ha-automation-condition>
</ha-config-section> </ha-config-section>
<ha-config-section .isWide=${this.isWide}> <ha-config-section vertical .isWide=${this.isWide}>
<span slot="header"> <span slot="header">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.automation.editor.actions.header" "ui.panel.config.automation.editor.actions.header"
@ -235,6 +235,7 @@ export class HaManualAutomationEditor extends LitElement {
.actions=${this.config.action} .actions=${this.config.action}
@value-changed=${this._actionChanged} @value-changed=${this._actionChanged}
.hass=${this.hass} .hass=${this.hass}
.narrow=${this.narrow}
></ha-automation-action> ></ha-automation-action>
</ha-config-section>`; </ha-config-section>`;
} }

View File

@ -211,7 +211,7 @@ class HaBlueprintOverview extends LitElement {
"ui.panel.config.blueprint.overview.add_blueprint" "ui.panel.config.blueprint.overview.add_blueprint"
)} )}
extended extended
@click=${this._addBlueprint} @click=${this._addBlueprintClicked}
> >
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon> <ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
</ha-fab> </ha-fab>
@ -249,6 +249,10 @@ class HaBlueprintOverview extends LitElement {
}); });
} }
private _addBlueprintClicked(): void {
this._addBlueprint();
}
private _reload() { private _reload() {
fireEvent(this, "reload-blueprints"); fireEvent(this, "reload-blueprints");
} }

View File

@ -80,13 +80,16 @@ export class HaConfigSection extends LitElement {
font-weight: var(--paper-font-subhead_-_font-weight); font-weight: var(--paper-font-subhead_-_font-weight);
line-height: var(--paper-font-subhead_-_line-height); line-height: var(--paper-font-subhead_-_line-height);
width: 100%; width: 100%;
max-width: 400px;
margin-right: 40px;
opacity: var(--dark-primary-opacity); opacity: var(--dark-primary-opacity);
font-size: 14px; font-size: 14px;
padding-bottom: 20px; padding-bottom: 20px;
} }
.horizontal .intro {
max-width: 400px;
margin-right: 40px;
}
.panel { .panel {
margin-top: -24px; margin-top: -24px;
} }

View File

@ -221,7 +221,7 @@ export class HaSceneEditor extends SubscribeMixin(
> >
${this._config ${this._config
? html` ? html`
<ha-config-section .isWide=${this.isWide}> <ha-config-section vertical .isWide=${this.isWide}>
${!this.narrow ${!this.narrow
? html` <span slot="header">${name}</span> ` ? html` <span slot="header">${name}</span> `
: ""} : ""}
@ -253,7 +253,7 @@ export class HaSceneEditor extends SubscribeMixin(
</ha-card> </ha-card>
</ha-config-section> </ha-config-section>
<ha-config-section .isWide=${this.isWide}> <ha-config-section vertical .isWide=${this.isWide}>
<div slot="header"> <div slot="header">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.scene.editor.devices.header" "ui.panel.config.scene.editor.devices.header"
@ -324,7 +324,7 @@ export class HaSceneEditor extends SubscribeMixin(
${this.showAdvanced ${this.showAdvanced
? html` ? html`
<ha-config-section .isWide=${this.isWide}> <ha-config-section vertical .isWide=${this.isWide}>
<div slot="header"> <div slot="header">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.scene.editor.entities.header" "ui.panel.config.scene.editor.entities.header"

View File

@ -189,7 +189,7 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
> >
${this._config ${this._config
? html` ? html`
<ha-config-section .isWide=${this.isWide}> <ha-config-section vertical .isWide=${this.isWide}>
${!this.narrow ${!this.narrow
? html` ? html`
<span slot="header">${this._config.alias}</span> <span slot="header">${this._config.alias}</span>
@ -313,7 +313,7 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
</ha-card> </ha-card>
</ha-config-section> </ha-config-section>
<ha-config-section .isWide=${this.isWide}> <ha-config-section vertical .isWide=${this.isWide}>
<span slot="header"> <span slot="header">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.script.editor.sequence" "ui.panel.config.script.editor.sequence"
@ -350,7 +350,7 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
` `
: this._mode === "yaml" : this._mode === "yaml"
? html` ? html`
<ha-config-section .isWide=${false}> <ha-config-section vertical .isWide=${false}>
${!this.narrow ${!this.narrow
? html`<span slot="header">${this._config?.alias}</span>` ? html`<span slot="header">${this._config?.alias}</span>`
: ``} : ``}

View File

@ -1,371 +0,0 @@
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { safeDump, safeLoad } from "js-yaml";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/buttons/ha-progress-button";
import "../../../components/entity/ha-entity-picker";
import "../../../components/ha-card";
import "../../../components/ha-code-editor";
import "../../../components/ha-service-picker";
import { ENTITY_COMPONENT_DOMAINS } from "../../../data/entity";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import LocalizeMixin from "../../../mixins/localize-mixin";
import "../../../styles/polymer-ha-style";
import "../../../util/app-localstorage-document";
const ERROR_SENTINEL = {};
/*
* @appliesMixin LocalizeMixin
*/
class HaPanelDevService extends LocalizeMixin(PolymerElement) {
static get template() {
return html`
<style include="ha-style">
:host {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
display: block;
padding: 16px;
}
.ha-form {
margin-right: 16px;
max-width: 400px;
}
ha-progress-button {
margin-top: 8px;
}
ha-card {
margin-top: 12px;
}
.description {
margin-top: 12px;
white-space: pre-wrap;
}
.attributes th {
text-align: left;
background-color: var(--card-background-color);
border-bottom: 1px solid var(--primary-text-color);
}
:host([rtl]) .attributes th {
text-align: right;
}
.attributes tr {
vertical-align: top;
direction: ltr;
}
.attributes tr:nth-child(odd) {
background-color: var(--table-row-background-color, #eee);
}
.attributes tr:nth-child(even) {
background-color: var(--table-row-alternative-background-color, #eee);
}
.attributes td:nth-child(3) {
white-space: pre-wrap;
word-break: break-word;
}
pre {
margin: 0;
font-family: var(--code-font-family, monospace);
}
td {
padding: 4px;
}
.error {
color: var(--error-color);
}
:host([rtl]) .desc-container {
text-align: right;
}
:host([rtl]) .desc-container h3 {
direction: ltr;
}
</style>
<app-localstorage-document
key="panel-dev-service-state-domain-service"
data="{{domainService}}"
>
</app-localstorage-document>
<app-localstorage-document
key="[[_computeServiceDataKey(domainService)]]"
data="{{serviceData}}"
>
</app-localstorage-document>
<div class="content">
<p>
[[localize('ui.panel.developer-tools.tabs.services.description')]]
</p>
<div class="ha-form">
<ha-service-picker
hass="[[hass]]"
value="{{domainService}}"
></ha-service-picker>
<template is="dom-if" if="[[_computeHasEntity(_attributes)]]">
<ha-entity-picker
hass="[[hass]]"
value="[[_computeEntityValue(parsedJSON)]]"
on-change="_entityPicked"
disabled="[[!validJSON]]"
include-domains="[[_computeEntityDomainFilter(_domain)]]"
allow-custom-entity
></ha-entity-picker>
</template>
<p>[[localize('ui.panel.developer-tools.tabs.services.data')]]</p>
<ha-code-editor
mode="yaml"
value="[[serviceData]]"
error="[[!validJSON]]"
on-value-changed="_yamlChanged"
></ha-code-editor>
<ha-progress-button
on-click="_callService"
raised
disabled="[[!validJSON]]"
>
[[localize('ui.panel.developer-tools.tabs.services.call_service')]]
</ha-progress-button>
</div>
<ha-card>
<div class="card-header">
<template is="dom-if" if="[[!domainService]]">
[[localize('ui.panel.developer-tools.tabs.services.select_service')]]
</template>
<template is="dom-if" if="[[domainService]]">
<template is="dom-if" if="[[!_description]]">
[[localize('ui.panel.developer-tools.tabs.services.no_description')]]
</template>
<template is="dom-if" if="[[_description]]">
[[_description]]
</template>
</template>
</div>
<div class="card-content">
<template is="dom-if" if="[[_description]]">
<template is="dom-if" if="[[!_attributes.length]]">
[[localize('ui.panel.developer-tools.tabs.services.no_parameters')]]
</template>
<template is="dom-if" if="[[_attributes.length]]">
<table class="attributes">
<tr>
<th>
[[localize('ui.panel.developer-tools.tabs.services.column_parameter')]]
</th>
<th>
[[localize('ui.panel.developer-tools.tabs.services.column_description')]]
</th>
<th>
[[localize('ui.panel.developer-tools.tabs.services.column_example')]]
</th>
</tr>
<template is="dom-repeat" items="[[_attributes]]" as="attribute">
<tr>
<td><pre>[[attribute.key]]</pre></td>
<td>[[attribute.description]]</td>
<td>[[attribute.example]]</td>
</tr>
</template>
</table>
</template>
<template is="dom-if" if="[[_attributes.length]]">
<mwc-button on-click="_fillExampleData">
[[localize('ui.panel.developer-tools.tabs.services.fill_example_data')]]
</mwc-button>
</template>
</template>
</template>
</div>
</ha-card>
</div>
`;
}
static get properties() {
return {
hass: {
type: Object,
},
domainService: {
type: String,
observer: "_domainServiceChanged",
},
_domain: {
type: String,
computed: "_computeDomain(domainService)",
},
_service: {
type: String,
computed: "_computeService(domainService)",
},
serviceData: {
type: String,
value: "",
},
parsedJSON: {
type: Object,
computed: "_computeParsedServiceData(serviceData)",
},
validJSON: {
type: Boolean,
computed: "_computeValidJSON(parsedJSON)",
},
_attributes: {
type: Array,
computed: "_computeAttributesArray(hass, _domain, _service)",
},
_description: {
type: String,
computed: "_computeDescription(hass, _domain, _service)",
},
rtl: {
reflectToAttribute: true,
computed: "_computeRTL(hass)",
},
};
}
_domainServiceChanged() {
this.serviceData = "";
}
_computeAttributesArray(hass, domain, service) {
const serviceDomains = hass.services;
if (!(domain in serviceDomains)) return [];
if (!(service in serviceDomains[domain])) return [];
const fields = serviceDomains[domain][service].fields;
return Object.keys(fields).map(function (field) {
return { key: field, ...fields[field] };
});
}
_computeDescription(hass, domain, service) {
const serviceDomains = hass.services;
if (!(domain in serviceDomains)) return undefined;
if (!(service in serviceDomains[domain])) return undefined;
return serviceDomains[domain][service].description;
}
_computeServiceDataKey(domainService) {
return `panel-dev-service-state-servicedata.${domainService}`;
}
_computeDomain(domainService) {
return domainService.split(".", 1)[0];
}
_computeService(domainService) {
return domainService.split(".", 2)[1] || null;
}
_computeParsedServiceData(serviceData) {
try {
return serviceData.trim() ? safeLoad(serviceData) : {};
} catch (err) {
return ERROR_SENTINEL;
}
}
_computeValidJSON(parsedJSON) {
return parsedJSON !== ERROR_SENTINEL;
}
_computeHasEntity(attributes) {
return attributes.some((attr) => attr.key === "entity_id");
}
_computeEntityValue(parsedJSON) {
return parsedJSON === ERROR_SENTINEL ? "" : parsedJSON.entity_id;
}
_computeEntityDomainFilter(domain) {
return ENTITY_COMPONENT_DOMAINS.includes(domain) ? [domain] : null;
}
_callService(ev) {
const button = ev.target;
if (this.parsedJSON === ERROR_SENTINEL) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.developer-tools.tabs.services.alert_parsing_yaml",
"data",
this.serviceData
),
});
button.actionError();
return;
}
this.hass
.callService(this._domain, this._service, this.parsedJSON)
.then(() => {
button.actionSuccess();
})
.catch(() => {
button.actionError();
});
}
_fillExampleData() {
const example = {};
this._attributes.forEach((attribute) => {
if (attribute.example) {
let value = "";
try {
value = safeLoad(attribute.example);
} catch (err) {
value = attribute.example;
}
example[attribute.key] = value;
}
});
this.serviceData = safeDump(example);
}
_entityPicked(ev) {
this.serviceData = safeDump({
...this.parsedJSON,
entity_id: ev.target.value,
});
}
_yamlChanged(ev) {
this.serviceData = ev.detail.value;
}
_computeRTL(hass) {
return computeRTL(hass);
}
}
customElements.define("developer-tools-service", HaPanelDevService);

View File

@ -0,0 +1,350 @@
import { safeLoad } from "js-yaml";
import {
css,
CSSResultArray,
html,
LitElement,
property,
query,
} from "lit-element";
import memoizeOne from "memoize-one";
import { LocalStorage } from "../../../common/decorators/local-storage";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeObjectId } from "../../../common/entity/compute_object_id";
import "../../../components/buttons/ha-progress-button";
import "../../../components/entity/ha-entity-picker";
import "../../../components/ha-card";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-service-control";
import "../../../components/ha-service-picker";
import "../../../components/ha-yaml-editor";
import type { HaYamlEditor } from "../../../components/ha-yaml-editor";
import { ServiceAction } from "../../../data/script";
import { haStyle } from "../../../resources/styles";
import "../../../styles/polymer-ha-style";
import { HomeAssistant } from "../../../types";
import "../../../util/app-localstorage-document";
class HaPanelDevService extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public narrow!: boolean;
@LocalStorage("panel-dev-service-state-service-data", true)
private _serviceData?: ServiceAction = { service: "", target: {}, data: {} };
@LocalStorage("panel-dev-service-state-yaml-mode", true)
private _yamlMode = false;
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;
protected firstUpdated(params) {
super.firstUpdated(params);
if (!this._serviceData?.service) {
const domain = Object.keys(this.hass.services).sort()[0];
const service = Object.keys(this.hass.services[domain]).sort()[0];
this._serviceData = {
service: `${domain}.${service}`,
target: {},
data: {},
};
}
}
protected render() {
const { target, fields } = this._fields(
this.hass.services,
this._serviceData?.service
);
const isValid = this._isValid(this._serviceData, fields, target);
return html`
<div class="content">
<p>
${this.hass.localize(
"ui.panel.developer-tools.tabs.services.description"
)}
</p>
${this._yamlMode
? html`<ha-yaml-editor
.defaultValue=${this._serviceData}
@value-changed=${this._yamlChanged}
></ha-yaml-editor>`
: html`<ha-card
><div>
<ha-service-control
.hass=${this.hass}
.value=${this._serviceData}
.narrow=${this.narrow}
showAdvanced
@value-changed=${this._serviceChanged}
></ha-service-control></div
></ha-card>`}
</div>
<div class="button-row">
<div class="buttons">
<mwc-button @click=${this._toggleYaml}>
${this._yamlMode
? this.hass.localize(
"ui.panel.developer-tools.tabs.services.ui_mode"
)
: this.hass.localize(
"ui.panel.developer-tools.tabs.services.yaml_mode"
)}
</mwc-button>
<mwc-button .disabled=${!isValid} raised @click=${this._callService}>
${this.hass.localize(
"ui.panel.developer-tools.tabs.services.call_service"
)}
</mwc-button>
</div>
</div>
${(this._yamlMode ? fields : this._filterSelectorFields(fields)).length
? html`<div class="content">
<ha-expansion-panel
.header=${this._yamlMode
? this.hass.localize(
"ui.panel.developer-tools.tabs.services.all_parameters"
)
: this.hass.localize(
"ui.panel.developer-tools.tabs.services.yaml_parameters"
)}
outlined
.expanded=${this._yamlMode}
>
${this._yamlMode && target
? html`<h3>
${this.hass.localize(
"ui.panel.developer-tools.tabs.services.accepts_target"
)}
</h3>`
: ""}
<table class="attributes">
<tr>
<th>
${this.hass.localize(
"ui.panel.developer-tools.tabs.services.column_parameter"
)}
</th>
<th>
${this.hass.localize(
"ui.panel.developer-tools.tabs.services.column_description"
)}
</th>
<th>
${this.hass.localize(
"ui.panel.developer-tools.tabs.services.column_example"
)}
</th>
</tr>
${fields.map(
(field) => html` <tr>
<td><pre>${field.key}</pre></td>
<td>${field.description}</td>
<td>${field.example}</td>
</tr>`
)}
</table>
${this._yamlMode
? html`<mwc-button @click=${this._fillExampleData}
>${this.hass.localize(
"ui.panel.developer-tools.tabs.services.fill_example_data"
)}</mwc-button
>`
: ""}
</ha-expansion-panel>
</div>`
: ""}
`;
}
private _filterSelectorFields = memoizeOne((fields) =>
fields.filter((field) => !field.selector)
);
private _isValid = memoizeOne((serviceData, fields, target): boolean => {
if (!serviceData?.service) {
return false;
}
const domain = computeDomain(serviceData.service);
const service = computeObjectId(serviceData.service);
if (!domain || !service) {
return false;
}
if (
target &&
!serviceData.target &&
!serviceData.data?.entity_id &&
!serviceData.data?.device_id &&
!serviceData.data?.area_id
) {
return false;
}
for (const field of fields) {
if (
field.required &&
(!serviceData.data || serviceData.data[field.key] === undefined)
) {
return false;
}
}
return true;
});
private _fields = memoizeOne(
(
serviceDomains: HomeAssistant["services"],
domainService: string | undefined
): { target: boolean; fields: any[] } => {
if (!domainService) {
return { target: false, fields: [] };
}
const domain = computeDomain(domainService);
const service = computeObjectId(domainService);
if (!(domain in serviceDomains)) {
return { target: false, fields: [] };
}
if (!(service in serviceDomains[domain])) {
return { target: false, fields: [] };
}
const target = "target" in serviceDomains[domain][service];
const fields = serviceDomains[domain][service].fields;
const result = Object.keys(fields).map((field) => {
return { key: field, ...fields[field] };
});
return {
target,
fields: result,
};
}
);
private _callService() {
const domain = computeDomain(this._serviceData!.service);
const service = computeObjectId(this._serviceData!.service);
if (!domain || !service) {
return;
}
this.hass.callService(
domain,
service,
this._serviceData!.data,
this._serviceData!.target
);
}
private _toggleYaml() {
this._yamlMode = !this._yamlMode;
}
private _yamlChanged(ev) {
if (!ev.detail.isValid) {
return;
}
this._serviceChanged(ev);
}
private _serviceChanged(ev) {
this._serviceData = ev.detail.value;
}
private _fillExampleData() {
const { fields } = this._fields(
this.hass.services,
this._serviceData?.service
);
const example = {};
fields.forEach((field) => {
if (field.example) {
let value = "";
try {
value = safeLoad(field.example);
} catch (err) {
value = field.example;
}
example[field.key] = value;
}
});
this._serviceData = { ...this._serviceData!, data: example };
this._yamlEditor?.setValue(this._serviceData);
}
static get styles(): CSSResultArray {
return [
haStyle,
css`
.content {
padding: 16px;
max-width: 1200px;
margin: auto;
}
.button-row {
padding: 8px 16px;
border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
background: var(--card-background-color);
position: sticky;
bottom: 0;
box-sizing: border-box;
width: 100%;
}
.button-row .buttons {
display: flex;
justify-content: space-between;
max-width: 1200px;
margin: auto;
}
.attributes {
width: 100%;
}
.attributes th {
text-align: left;
background-color: var(--card-background-color);
border-bottom: 1px solid var(--primary-text-color);
}
:host([rtl]) .attributes th {
text-align: right;
}
.attributes tr {
vertical-align: top;
direction: ltr;
}
.attributes tr:nth-child(odd) {
background-color: var(--table-row-background-color, #eee);
}
.attributes tr:nth-child(even) {
background-color: var(--table-row-alternative-background-color, #eee);
}
.attributes td:nth-child(3) {
white-space: pre-wrap;
word-break: break-word;
}
.attributes td {
padding: 4px;
vertical-align: middle;
}
`,
];
}
}
customElements.define("developer-tools-service", HaPanelDevService);
declare global {
interface HTMLElementTagNameMap {
"developer-tools-service": HaPanelDevService;
}
}

View File

@ -272,6 +272,7 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
height: auto; height: auto;
color: var(--paper-item-icon-color, #44739e); color: var(--paper-item-icon-color, #44739e);
--mdc-icon-size: 100%; --mdc-icon-size: 100%;
margin-bottom: 8px;
} }
ha-icon, ha-icon,

View File

@ -130,7 +130,12 @@ export const handleAction = async (
return; return;
} }
const [domain, service] = actionConfig.service.split(".", 2); const [domain, service] = actionConfig.service.split(".", 2);
hass.callService(domain, service, actionConfig.service_data); hass.callService(
domain,
service,
actionConfig.service_data,
actionConfig.target
);
forwardHaptic("light"); forwardHaptic("light");
break; break;
} }

View File

@ -15,15 +15,17 @@ import {
} from "lit-element"; } from "lit-element";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-help-tooltip"; import "../../../components/ha-help-tooltip";
import "../../../components/ha-service-picker";
import { import {
ActionConfig, ActionConfig,
CallServiceActionConfig, CallServiceActionConfig,
NavigateActionConfig, NavigateActionConfig,
UrlActionConfig, UrlActionConfig,
} from "../../../data/lovelace"; } from "../../../data/lovelace";
import { ServiceAction } from "../../../data/script";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { EditorTarget } from "../editor/types"; import { EditorTarget } from "../editor/types";
import "../../../components/ha-service-control";
import memoizeOne from "memoize-one";
@customElement("hui-action-editor") @customElement("hui-action-editor")
export class HuiActionEditor extends LitElement { export class HuiActionEditor extends LitElement {
@ -47,10 +49,15 @@ export class HuiActionEditor extends LitElement {
return config.url_path || ""; return config.url_path || "";
} }
get _service(): string { private _serviceAction = memoizeOne(
const config = this.config as CallServiceActionConfig; (config: CallServiceActionConfig): ServiceAction => {
return config.service || ""; return {
} service: config.service || "",
data: config.service_data,
target: config.target,
};
}
);
protected render(): TemplateResult { protected render(): TemplateResult {
if (!this.hass || !this.actions) { if (!this.hass || !this.actions) {
@ -117,17 +124,13 @@ export class HuiActionEditor extends LitElement {
: ""} : ""}
${this.config?.action === "call-service" ${this.config?.action === "call-service"
? html` ? html`
<ha-service-picker <ha-service-control
.hass=${this.hass} .hass=${this.hass}
.value=${this._service} .value=${this._serviceAction(this.config)}
.configValue=${"service"} .showAdvanced=${this.hass.userData?.showAdvanced}
@value-changed=${this._valueChanged} narrow
></ha-service-picker> @value-changed=${this._serviceValueChanged}
<b> ></ha-service-control>
${this.hass!.localize(
"ui.panel.lovelace.editor.action-editor.editor_service_data"
)}
</b>
` `
: ""} : ""}
`; `;
@ -174,11 +177,26 @@ export class HuiActionEditor extends LitElement {
} }
} }
private _serviceValueChanged(ev: CustomEvent) {
ev.stopPropagation();
fireEvent(this, "value-changed", {
value: {
...this.config!,
service: ev.detail.value.service || "",
service_data: ev.detail.value.data || {},
target: ev.detail.value.target || {},
},
});
}
static get styles(): CSSResult { static get styles(): CSSResult {
return css` return css`
.dropdown { .dropdown {
display: flex; display: flex;
} }
ha-service-control {
--service-control-padding: 0;
}
`; `;
} }
} }

View File

@ -9,7 +9,11 @@ export const configElementStyle = css`
} }
.side-by-side > * { .side-by-side > * {
flex: 1; flex: 1;
padding-right: 4px; padding-right: 8px;
}
.side-by-side > *:last-child {
flex: 1;
padding-right: 0;
} }
.suffix { .suffix {
margin: 0 8px; margin: 0 8px;

View File

@ -120,20 +120,27 @@ const actionConfigStructConfirmation = union([
const actionConfigStructUrl = object({ const actionConfigStructUrl = object({
action: literal("url"), action: literal("url"),
url_path: string(), url_path: optional(string()),
confirmation: optional(actionConfigStructConfirmation), confirmation: optional(actionConfigStructConfirmation),
}); });
const actionConfigStructService = object({ const actionConfigStructService = object({
action: literal("call-service"), action: literal("call-service"),
service: string(), service: optional(string()),
service_data: optional(object()), service_data: optional(object()),
target: optional(
object({
entity_id: optional(union([string(), array(string())])),
device_id: optional(union([string(), array(string())])),
area_id: optional(union([string(), array(string())])),
})
),
confirmation: optional(actionConfigStructConfirmation), confirmation: optional(actionConfigStructConfirmation),
}); });
const actionConfigStructNavigate = object({ const actionConfigStructNavigate = object({
action: literal("navigate"), action: literal("navigate"),
navigation_path: string(), navigation_path: optional(string()),
confirmation: optional(actionConfigStructConfirmation), confirmation: optional(actionConfigStructConfirmation),
}); });

View File

@ -13,22 +13,28 @@ import {
extractSearchParamsObject, extractSearchParamsObject,
} from "../../common/url/search-params"; } from "../../common/url/search-params";
import "../../layouts/hass-error-screen"; import "../../layouts/hass-error-screen";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { domainToName } from "../../data/integration";
const REDIRECTS = { const REDIRECTS: Redirects = {
info: { developer_states: {
redirect: "/config/info", redirect: "/developer-tools/state",
}, },
logs: { developer_services: {
redirect: "/config/logs", redirect: "/developer-tools/service",
}, },
profile: { developer_template: {
redirect: "/profile/dashboard", redirect: "/developer-tools/template",
}, },
blueprint_import: { developer_events: {
redirect: "/config/blueprint/dashboard/import", redirect: "/developer-tools/event",
params: { },
blueprint_url: "url", cloud: {
}, component: "cloud",
redirect: "/config/cloud",
},
integrations: {
redirect: "/config/integrations",
}, },
config_flow_start: { config_flow_start: {
redirect: "/config/integrations/add", redirect: "/config/integrations/add",
@ -36,12 +42,80 @@ const REDIRECTS = {
domain: "string", domain: "string",
}, },
}, },
devices: {
redirect: "/config/devices/dashboard",
},
entities: {
redirect: "/config/entities",
},
areas: {
redirect: "/config/areas/dashboard",
},
blueprints: {
redirect: "/config/blueprint/dashboard",
},
blueprint_import: {
redirect: "/config/blueprint/dashboard/import",
params: {
blueprint_url: "url",
},
},
automations: {
redirect: "/config/automation/dashboard",
},
scenes: {
redirect: "/config/scene/dashboard",
},
scripts: {
redirect: "/config/script/dashboard",
},
helpers: {
redirect: "/config/helpers",
},
tags: {
redirect: "/config/tags",
},
lovelace_dashboards: {
redirect: "/config/lovelace/dashboards",
},
lovelace_resources: {
redirect: "/config/lovelace/resources",
},
people: {
redirect: "/config/person",
},
zones: {
redirect: "/config/zone",
},
users: {
redirect: "/config/users",
},
general: {
redirect: "/config/core",
},
server_controls: {
redirect: "/config/server_control",
},
logs: {
redirect: "/config/logs",
},
info: {
redirect: "/config/info",
},
customize: {
redirect: "/config/customize",
},
profile: {
redirect: "/profile/dashboard",
},
}; };
type ParamType = "url" | "string"; export type ParamType = "url" | "string";
interface Redirect { export type Redirects = { [key: string]: Redirect };
export interface Redirect {
redirect: string; redirect: string;
component?: string;
params?: { params?: {
[key: string]: ParamType; [key: string]: ParamType;
}; };
@ -53,24 +127,37 @@ class HaPanelMy extends LitElement {
@property() public route!: Route; @property() public route!: Route;
@internalProperty() public _error = ""; @internalProperty() public _error?: string;
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
const path = this.route.path.substr(1); const path = this.route.path.substr(1);
const redirect: Redirect | undefined = REDIRECTS[path];
if (path.startsWith("supervisor")) {
if (!isComponentLoaded(this.hass, "hassio")) {
this._error = "no_supervisor";
return;
}
navigate(
this,
`/hassio/_my_redirect/${path}${window.location.search}`,
true
);
return;
}
const redirect = REDIRECTS[path];
if (!redirect) { if (!redirect) {
this._error = this.hass.localize( this._error = "not_supported";
"ui.panel.my.not_supported", return;
"link", }
html`<a
target="_blank" if (
rel="noreferrer noopener" redirect.component &&
href="https://my.home-assistant.io/faq.html#supported-pages" !isComponentLoaded(this.hass, redirect.component)
>${this.hass.localize("ui.panel.my.faq_link")}</a ) {
>` this._error = "no_component";
);
return; return;
} }
@ -78,7 +165,7 @@ class HaPanelMy extends LitElement {
try { try {
url = this._createRedirectUrl(redirect); url = this._createRedirectUrl(redirect);
} catch (err) { } catch (err) {
this._error = this.hass.localize("ui.panel.my.error"); this._error = "url_error";
return; return;
} }
@ -87,9 +174,44 @@ class HaPanelMy extends LitElement {
protected render() { protected render() {
if (this._error) { if (this._error) {
return html`<hass-error-screen let error = "Unknown error";
.error=${this._error} switch (this._error) {
></hass-error-screen>`; case "not_supported":
error =
this.hass.localize(
"ui.panel.my.not_supported",
"link",
html`<a
target="_blank"
rel="noreferrer noopener"
href="https://my.home-assistant.io/faq.html#supported-pages"
>${this.hass.localize("ui.panel.my.faq_link")}</a
>`
) || "This redirect is not supported.";
break;
case "no_component":
error =
this.hass.localize(
"ui.panel.my.component_not_loaded",
"integration",
domainToName(
this.hass.localize,
REDIRECTS[this.route.path.substr(1)].component!
)
) || "This redirect is not supported.";
break;
case "no_supervisor":
error =
this.hass.localize(
"ui.panel.my.component_not_loaded",
"integration",
"Home Assistant Supervisor"
) || "This redirect requires Home Assistant Supervisor.";
break;
default:
error = this.hass.localize("ui.panel.my.error") || "Unknown error";
}
return html`<hass-error-screen .error=${error}></hass-error-screen>`;
} }
return html``; return html``;
} }

View File

@ -0,0 +1,281 @@
import "@material/mwc-button";
import {
css,
CSSResult,
customElement,
internalProperty,
LitElement,
property,
} from "lit-element";
import { html, TemplateResult } from "lit-html";
import { localizeKey } from "../../common/translations/localize";
import "../../components/ha-circular-progress";
import "../../components/ha-form/ha-form";
import "../../components/ha-markdown";
import {
DataEntryFlowStep,
DataEntryFlowStepForm,
} from "../../data/data_entry_flow";
import { haStyleDialog } from "../../resources/styles";
import { HomeAssistant } from "../../types";
import "../../components/ha-dialog";
let instance = 0;
@customElement("ha-mfa-module-setup-flow")
class HaMfaModuleSetupFlow extends LitElement {
@property() public hass!: HomeAssistant;
@internalProperty() private _dialogClosedCallback?: (params: {
flowFinished: boolean;
}) => void;
@internalProperty() private _instance?: number;
@internalProperty() private _loading = false;
@internalProperty() private _opened = false;
@internalProperty() private _stepData: any = {};
@internalProperty() private _step?: DataEntryFlowStep;
@internalProperty() private _errorMessage?: string;
public showDialog({ continueFlowId, mfaModuleId, dialogClosedCallback }) {
this._instance = instance++;
this._dialogClosedCallback = dialogClosedCallback;
this._opened = true;
const fetchStep = continueFlowId
? this.hass.callWS({
type: "auth/setup_mfa",
flow_id: continueFlowId,
})
: this.hass.callWS({
type: "auth/setup_mfa",
mfa_module_id: mfaModuleId,
});
const curInstance = this._instance;
fetchStep.then((step) => {
if (curInstance !== this._instance) return;
this._processStep(step);
});
}
public closeDialog() {
// Closed dialog by clicking on the overlay
if (this._step) {
this._flowDone();
}
this._opened = false;
}
protected render(): TemplateResult {
if (!this._opened) {
return html``;
}
return html`
<ha-dialog
open
.heading=${this._computeStepTitle()}
@closing=${this.closeDialog}
>
<div>
${this._errorMessage
? html`<div class="error">${this._errorMessage}</div>`
: ""}
${!this._step
? html`<div class="init-spinner">
<ha-circular-progress active></ha-circular-progress>
</div>`
: html`${this._step.type === "abort"
? html` <ha-markdown
allowsvg
breaks
.content=${this.hass.localize(
`component.auth.mfa_setup.${this._step.handler}.abort.${this._step.reason}`
)}
></ha-markdown>`
: this._step.type === "create_entry"
? html`<p>
${this.hass.localize(
"ui.panel.profile.mfa_setup.step_done",
"step",
this._step.title
)}
</p>`
: this._step.type === "form"
? html` <ha-markdown
allowsvg
breaks
.content=${localizeKey(
this.hass.localize,
`component.auth.mfa_setup.${this._step!.handler}.step.${
(this._step! as DataEntryFlowStepForm).step_id
}.description`,
this._step!.description_placeholders
)}
></ha-markdown>
<ha-form
.data=${this._stepData}
.schema=${this._step.data_schema}
.error=${this._step.errors}
.computeLabel=${this._computeLabel}
.computeError=${this._computeError}
@value-changed=${this._stepDataChanged}
></ha-form>`
: ""}`}
</div>
${["abort", "create_entry"].includes(this._step?.type || "")
? html`<mwc-button slot="primaryAction" @click=${this.closeDialog}
>${this.hass.localize(
"ui.panel.profile.mfa_setup.close"
)}</mwc-button
>`
: ""}
${this._step?.type === "form"
? html`<mwc-button
slot="primaryAction"
.disabled=${this._loading}
@click=${this._submitStep}
>${this.hass.localize(
"ui.panel.profile.mfa_setup.submit"
)}</mwc-button
>`
: ""}
</ha-dialog>
`;
}
static get styles(): CSSResult[] {
return [
haStyleDialog,
css`
.error {
color: red;
}
ha-dialog {
max-width: 500px;
}
ha-markdown {
--markdown-svg-background-color: white;
--markdown-svg-color: black;
display: block;
margin: 0 auto;
}
ha-markdown a {
color: var(--primary-color);
}
.init-spinner {
padding: 10px 100px 34px;
text-align: center;
}
.submit-spinner {
margin-right: 16px;
}
`,
];
}
protected firstUpdated(changedProperties) {
super.firstUpdated(changedProperties);
this.hass.loadBackendTranslation("mfa_setup", "auth");
this.addEventListener("keypress", (ev) => {
if (ev.key === "Enter") {
this._submitStep();
}
});
}
private _stepDataChanged(ev: CustomEvent) {
this._stepData = ev.detail.value;
}
private _submitStep() {
this._loading = true;
this._errorMessage = undefined;
const curInstance = this._instance;
this.hass
.callWS({
type: "auth/setup_mfa",
flow_id: this._step!.flow_id,
user_input: this._stepData,
})
.then(
(step) => {
if (curInstance !== this._instance) {
return;
}
this._processStep(step);
this._loading = false;
},
(err) => {
this._errorMessage =
(err && err.body && err.body.message) || "Unknown error occurred";
this._loading = false;
}
);
}
private _processStep(step) {
if (!step.errors) step.errors = {};
this._step = step;
// We got a new form if there are no errors.
if (Object.keys(step.errors).length === 0) {
this._stepData = {};
}
}
private _flowDone() {
const flowFinished = Boolean(
this._step && ["create_entry", "abort"].includes(this._step.type)
);
this._dialogClosedCallback!({
flowFinished,
});
this._errorMessage = undefined;
this._step = undefined;
this._stepData = {};
this._dialogClosedCallback = undefined;
this.closeDialog();
}
private _computeStepTitle() {
return this._step?.type === "abort"
? this.hass.localize("ui.panel.profile.mfa_setup.title_aborted")
: this._step?.type === "create_entry"
? this.hass.localize("ui.panel.profile.mfa_setup.title_success")
: this._step?.type === "form"
? this.hass.localize(
`component.auth.mfa_setup.${this._step.handler}.step.${this._step.step_id}.title`
)
: "";
}
private _computeLabel = (schema) =>
this.hass.localize(
`component.auth.mfa_setup.${this._step!.handler}.step.${
(this._step! as DataEntryFlowStepForm).step_id
}.data.${schema.name}`
) || schema.name;
private _computeError = (error) =>
this.hass.localize(
`component.auth.mfa_setup.${this._step!.handler}.error.${error}`
) || error;
}
declare global {
interface HTMLElementTagNameMap {
"ha-mfa-module-setup-flow": HaMfaModuleSetupFlow;
}
}

View File

@ -1,322 +0,0 @@
import "@material/mwc-button";
import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../../components/dialog/ha-paper-dialog";
import "../../components/ha-circular-progress";
import "../../components/ha-form/ha-form";
import "../../components/ha-markdown";
import { EventsMixin } from "../../mixins/events-mixin";
import LocalizeMixin from "../../mixins/localize-mixin";
import "../../styles/polymer-ha-style-dialog";
let instance = 0;
/*
* @appliesMixin LocalizeMixin
* @appliesMixin EventsMixin
*/
class HaMfaModuleSetupFlow extends LocalizeMixin(EventsMixin(PolymerElement)) {
static get template() {
return html`
<style include="ha-style-dialog">
.error {
color: red;
}
ha-paper-dialog {
max-width: 500px;
}
h2 {
white-space: normal;
}
ha-markdown {
--markdown-svg-background-color: white;
--markdown-svg-color: black;
display: block;
margin: 0 auto;
}
ha-markdown a {
color: var(--primary-color);
}
.init-spinner {
padding: 10px 100px 34px;
text-align: center;
}
.submit-spinner {
margin-right: 16px;
}
</style>
<ha-paper-dialog
id="dialog"
with-backdrop=""
opened="{{_opened}}"
on-opened-changed="_openedChanged"
>
<h2>
<template is="dom-if" if="[[_equals(_step.type, 'abort')]]">
[[localize('ui.panel.profile.mfa_setup.title_aborted')]]
</template>
<template is="dom-if" if="[[_equals(_step.type, 'create_entry')]]">
[[localize('ui.panel.profile.mfa_setup.title_success')]]
</template>
<template is="dom-if" if="[[_equals(_step.type, 'form')]]">
[[_computeStepTitle(localize, _step)]]
</template>
</h2>
<paper-dialog-scrollable>
<template is="dom-if" if="[[_errorMsg]]">
<div class="error">[[_errorMsg]]</div>
</template>
<template is="dom-if" if="[[!_step]]">
<div class="init-spinner">
<ha-circular-progress active></ha-circular-progress>
</div>
</template>
<template is="dom-if" if="[[_step]]">
<template is="dom-if" if="[[_equals(_step.type, 'abort')]]">
<ha-markdown
allowsvg
breaks
content="[[_computeStepAbortedReason(localize, _step)]]"
></ha-markdown>
</template>
<template is="dom-if" if="[[_equals(_step.type, 'create_entry')]]">
<p>
[[localize('ui.panel.profile.mfa_setup.step_done', 'step',
_step.title)]]
</p>
</template>
<template is="dom-if" if="[[_equals(_step.type, 'form')]]">
<template
is="dom-if"
if="[[_computeStepDescription(localize, _step)]]"
>
<ha-markdown
allowsvg
breaks
content="[[_computeStepDescription(localize, _step)]]"
></ha-markdown>
</template>
<ha-form
data="{{_stepData}}"
schema="[[_step.data_schema]]"
error="[[_step.errors]]"
compute-label="[[_computeLabelCallback(localize, _step)]]"
compute-error="[[_computeErrorCallback(localize, _step)]]"
></ha-form>
</template>
</template>
</paper-dialog-scrollable>
<div class="buttons">
<template is="dom-if" if="[[_equals(_step.type, 'abort')]]">
<mwc-button on-click="_flowDone"
>[[localize('ui.panel.profile.mfa_setup.close')]]</mwc-button
>
</template>
<template is="dom-if" if="[[_equals(_step.type, 'create_entry')]]">
<mwc-button on-click="_flowDone"
>[[localize('ui.panel.profile.mfa_setup.close')]]</mwc-button
>
</template>
<template is="dom-if" if="[[_equals(_step.type, 'form')]]">
<template is="dom-if" if="[[_loading]]">
<div class="submit-spinner">
<ha-circular-progress active></ha-circular-progress>
</div>
</template>
<template is="dom-if" if="[[!_loading]]">
<mwc-button on-click="_submitStep"
>[[localize('ui.panel.profile.mfa_setup.submit')]]</mwc-button
>
</template>
</template>
</div>
</ha-paper-dialog>
`;
}
static get properties() {
return {
_hass: Object,
_dialogClosedCallback: Function,
_instance: Number,
_loading: {
type: Boolean,
value: false,
},
// Error message when can't talk to server etc
_errorMsg: String,
_opened: {
type: Boolean,
value: false,
},
_step: {
type: Object,
value: null,
},
/*
* Store user entered data.
*/
_stepData: Object,
};
}
ready() {
super.ready();
this.hass.loadBackendTranslation("mfa_setup", "auth");
this.addEventListener("keypress", (ev) => {
if (ev.keyCode === 13) {
this._submitStep();
}
});
}
showDialog({ hass, continueFlowId, mfaModuleId, dialogClosedCallback }) {
this.hass = hass;
this._instance = instance++;
this._dialogClosedCallback = dialogClosedCallback;
this._createdFromHandler = !!mfaModuleId;
this._loading = true;
this._opened = true;
const fetchStep = continueFlowId
? this.hass.callWS({
type: "auth/setup_mfa",
flow_id: continueFlowId,
})
: this.hass.callWS({
type: "auth/setup_mfa",
mfa_module_id: mfaModuleId,
});
const curInstance = this._instance;
fetchStep.then((step) => {
if (curInstance !== this._instance) return;
this._processStep(step);
this._loading = false;
// When the flow changes, center the dialog.
// Don't do it on each step or else the dialog keeps bouncing.
setTimeout(() => this.$.dialog.center(), 0);
});
}
_submitStep() {
this._loading = true;
this._errorMsg = null;
const curInstance = this._instance;
this.hass
.callWS({
type: "auth/setup_mfa",
flow_id: this._step.flow_id,
user_input: this._stepData,
})
.then(
(step) => {
if (curInstance !== this._instance) return;
this._processStep(step);
this._loading = false;
},
(err) => {
this._errorMsg =
(err && err.body && err.body.message) || "Unknown error occurred";
this._loading = false;
}
);
}
_processStep(step) {
if (!step.errors) step.errors = {};
this._step = step;
// We got a new form if there are no errors.
if (Object.keys(step.errors).length === 0) {
this._stepData = {};
}
}
_flowDone() {
this._opened = false;
const flowFinished =
this._step && ["create_entry", "abort"].includes(this._step.type);
if (this._step && !flowFinished && this._createdFromHandler) {
// console.log('flow not finish');
}
this._dialogClosedCallback({
flowFinished,
});
this._errorMsg = null;
this._step = null;
this._stepData = {};
this._dialogClosedCallback = null;
}
_equals(a, b) {
return a === b;
}
_openedChanged(ev) {
// Closed dialog by clicking on the overlay
if (this._step && !ev.detail.value) {
this._flowDone();
}
}
_computeStepAbortedReason(localize, step) {
return localize(
`component.auth.mfa_setup.${step.handler}.abort.${step.reason}`
);
}
_computeStepTitle(localize, step) {
return (
localize(
`component.auth.mfa_setup.${step.handler}.step.${step.step_id}.title`
) || "Setup Multi-factor Authentication"
);
}
_computeStepDescription(localize, step) {
const args = [
`component.auth.mfa_setup.${step.handler}.step.${step.step_id}.description`,
];
const placeholders = step.description_placeholders || {};
Object.keys(placeholders).forEach((key) => {
args.push(key);
args.push(placeholders[key]);
});
return localize(...args);
}
_computeLabelCallback(localize, step) {
// Returns a callback for ha-form to calculate labels per schema object
return (schema) =>
localize(
`component.auth.mfa_setup.${step.handler}.step.${step.step_id}.data.${schema.name}`
) || schema.name;
}
_computeErrorCallback(localize, step) {
// Returns a callback for ha-form to calculate error messages
return (error) =>
localize(`component.auth.mfa_setup.${step.handler}.error.${error}`) ||
error;
}
}
customElements.define("ha-mfa-module-setup-flow", HaMfaModuleSetupFlow);

View File

@ -1,130 +0,0 @@
import "@material/mwc-button";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../../components/ha-card";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import { EventsMixin } from "../../mixins/events-mixin";
import LocalizeMixin from "../../mixins/localize-mixin";
import "../../styles/polymer-ha-style";
let registeredDialog = false;
/*
* @appliesMixin EventsMixin
* @appliesMixin LocalizeMixin
*/
class HaMfaModulesCard extends EventsMixin(LocalizeMixin(PolymerElement)) {
static get template() {
return html`
<style include="iron-flex ha-style">
.error {
color: red;
}
.status {
color: var(--primary-color);
}
.error,
.status {
position: absolute;
top: -4px;
}
mwc-button {
margin-right: -0.57em;
}
</style>
<ha-card header="[[localize('ui.panel.profile.mfa.header')]]">
<template is="dom-repeat" items="[[mfaModules]]" as="module">
<paper-item>
<paper-item-body two-line="">
<div>[[module.name]]</div>
<div secondary="">[[module.id]]</div>
</paper-item-body>
<template is="dom-if" if="[[module.enabled]]">
<mwc-button on-click="_disable"
>[[localize('ui.panel.profile.mfa.disable')]]</mwc-button
>
</template>
<template is="dom-if" if="[[!module.enabled]]">
<mwc-button on-click="_enable"
>[[localize('ui.panel.profile.mfa.enable')]]</mwc-button
>
</template>
</paper-item>
</template>
</ha-card>
`;
}
static get properties() {
return {
hass: Object,
_loading: {
type: Boolean,
value: false,
},
// Error message when can't talk to server etc
_statusMsg: String,
_errorMsg: String,
mfaModules: Array,
};
}
connectedCallback() {
super.connectedCallback();
if (!registeredDialog) {
registeredDialog = true;
this.fire("register-dialog", {
dialogShowEvent: "show-mfa-module-setup-flow",
dialogTag: "ha-mfa-module-setup-flow",
dialogImport: () => import("./ha-mfa-module-setup-flow"),
});
}
}
_enable(ev) {
this.fire("show-mfa-module-setup-flow", {
hass: this.hass,
mfaModuleId: ev.model.module.id,
dialogClosedCallback: () => this._refreshCurrentUser(),
});
}
async _disable(ev) {
const mfamodule = ev.model.module;
if (
!(await showConfirmationDialog(this, {
text: this.localize(
"ui.panel.profile.mfa.confirm_disable",
"name",
mfamodule.name
),
}))
) {
return;
}
const mfaModuleId = mfamodule.id;
this.hass
.callWS({
type: "auth/depose_mfa",
mfa_module_id: mfaModuleId,
})
.then(() => {
this._refreshCurrentUser();
});
}
_refreshCurrentUser() {
this.fire("hass-refresh-current-user");
}
}
customElements.define("ha-mfa-modules-card", HaMfaModulesCard);

View File

@ -0,0 +1,101 @@
import "@material/mwc-button";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-item/paper-item-body";
import {
css,
CSSResult,
customElement,
html,
LitElement,
property,
TemplateResult,
} from "lit-element";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-card";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import { HomeAssistant, MFAModule } from "../../types";
import { showMfaModuleSetupFlowDialog } from "./show-ha-mfa-module-setup-flow-dialog";
@customElement("ha-mfa-modules-card")
class HaMfaModulesCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public mfaModules!: MFAModule[];
protected render(): TemplateResult {
return html`
<ha-card .header=${this.hass.localize("ui.panel.profile.mfa.header")}>
${this.mfaModules.map(
(module) => html`<paper-item>
<paper-item-body two-line="">
<div>${module.name}</div>
<div secondary>${module.id}</div>
</paper-item-body>
${module.enabled
? html`<mwc-button .module=${module} @click=${this._disable}
>${this.hass.localize(
"ui.panel.profile.mfa.disable"
)}</mwc-button
>`
: html`<mwc-button .module=${module} @click=${this._enable}
>${this.hass.localize(
"ui.panel.profile.mfa.enable"
)}</mwc-button
>`}
</paper-item>`
)}
</ha-card>
`;
}
static get styles(): CSSResult {
return css`
mwc-button {
margin-right: -0.57em;
}
`;
}
private _enable(ev) {
showMfaModuleSetupFlowDialog(this, {
mfaModuleId: ev.currentTarget.module.id,
dialogClosedCallback: () => this._refreshCurrentUser(),
});
}
private async _disable(ev) {
const mfamodule = ev.currentTarget.module;
if (
!(await showConfirmationDialog(this, {
text: this.hass.localize(
"ui.panel.profile.mfa.confirm_disable",
"name",
mfamodule.name
),
}))
) {
return;
}
const mfaModuleId = mfamodule.id;
this.hass
.callWS({
type: "auth/depose_mfa",
mfa_module_id: mfaModuleId,
})
.then(() => {
this._refreshCurrentUser();
});
}
private _refreshCurrentUser() {
fireEvent(this, "hass-refresh-current-user");
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-mfa-modules-card": HaMfaModulesCard;
}
}

View File

@ -0,0 +1,21 @@
import { fireEvent } from "../../common/dom/fire_event";
export interface MfaModuleSetupFlowDialogParams {
continueFlowId?: string;
mfaModuleId?: string;
dialogClosedCallback: (params: { flowFinished: boolean }) => void;
}
export const loadMfaModuleSetupFlowDialog = () =>
import("./dialog-ha-mfa-module-setup-flow");
export const showMfaModuleSetupFlowDialog = (
element: HTMLElement,
dialogParams: MfaModuleSetupFlowDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "ha-mfa-module-setup-flow",
dialogImport: loadMfaModuleSetupFlowDialog,
dialogParams,
});
};

View File

@ -6,7 +6,6 @@ import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */ /* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element"; import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../components/entity/state-info"; import "../components/entity/state-info";
import "../components/ha-slider";
class StateCardNumber extends mixinBehaviors( class StateCardNumber extends mixinBehaviors(
[IronResizableBehavior], [IronResizableBehavior],
@ -16,9 +15,6 @@ class StateCardNumber extends mixinBehaviors(
return html` return html`
<style include="iron-flex iron-flex-alignment"></style> <style include="iron-flex iron-flex-alignment"></style>
<style> <style>
ha-slider {
margin-left: auto;
}
.state { .state {
@apply --paper-font-body1; @apply --paper-font-body1;
color: var(--primary-text-color); color: var(--primary-text-color);
@ -26,12 +22,6 @@ class StateCardNumber extends mixinBehaviors(
text-align: right; text-align: right;
line-height: 40px; line-height: 40px;
} }
.sliderstate {
min-width: 45px;
}
ha-slider[hidden] {
display: none !important;
}
paper-input { paper-input {
text-align: right; text-align: right;
margin-left: auto; margin-left: auto;
@ -40,19 +30,6 @@ class StateCardNumber extends mixinBehaviors(
<div class="horizontal justified layout" id="number_card"> <div class="horizontal justified layout" id="number_card">
${this.stateInfoTemplate} ${this.stateInfoTemplate}
<ha-slider
min="[[min]]"
max="[[max]]"
value="{{value}}"
step="[[step]]"
hidden="[[hiddenslider]]"
pin
on-change="selectedValueChanged"
on-click="stopPropagation"
id="slider"
ignore-bar-touch=""
>
</ha-slider>
<paper-input <paper-input
no-label-float="" no-label-float=""
auto-validate="" auto-validate=""
@ -64,19 +41,11 @@ class StateCardNumber extends mixinBehaviors(
type="number" type="number"
on-change="selectedValueChanged" on-change="selectedValueChanged"
on-click="stopPropagation" on-click="stopPropagation"
hidden="[[hiddenbox]]"
> >
</paper-input> </paper-input>
<div class="state" hidden="[[hiddenbox]]"> <div class="state">
[[stateObj.attributes.unit_of_measurement]] [[stateObj.attributes.unit_of_measurement]]
</div> </div>
<div
id="sliderstate"
class="state sliderstate"
hidden="[[hiddenslider]]"
>
[[value]] [[stateObj.attributes.unit_of_measurement]]
</div>
</div> </div>
`; `;
} }
@ -91,31 +60,9 @@ class StateCardNumber extends mixinBehaviors(
`; `;
} }
ready() {
super.ready();
if (typeof ResizeObserver === "function") {
const ro = new ResizeObserver((entries) => {
entries.forEach(() => {
this.hiddenState();
});
});
ro.observe(this.$.number_card);
} else {
this.addEventListener("iron-resize", this.hiddenState);
}
}
static get properties() { static get properties() {
return { return {
hass: Object, hass: Object,
hiddenbox: {
type: Boolean,
value: true,
},
hiddenslider: {
type: Boolean,
value: true,
},
inDialog: { inDialog: {
type: Boolean, type: Boolean,
value: false, value: false,
@ -138,35 +85,17 @@ class StateCardNumber extends mixinBehaviors(
}, },
step: Number, step: Number,
value: Number, value: Number,
mode: String,
}; };
} }
hiddenState() {
if (this.mode !== "slider") return;
const sliderwidth = this.$.slider.offsetWidth;
if (sliderwidth < 100) {
this.$.sliderstate.hidden = true;
} else if (sliderwidth >= 145) {
this.$.sliderstate.hidden = false;
}
}
stateObjectChanged(newVal) { stateObjectChanged(newVal) {
const prevMode = this.mode;
this.setProperties({ this.setProperties({
min: Number(newVal.attributes.min), min: Number(newVal.attributes.min),
max: Number(newVal.attributes.max), max: Number(newVal.attributes.max),
step: Number(newVal.attributes.step), step: Number(newVal.attributes.step),
value: Number(newVal.state), value: Number(newVal.state),
mode: String(newVal.attributes.mode),
maxlength: String(newVal.attributes.max).length, maxlength: String(newVal.attributes.max).length,
hiddenbox: newVal.attributes.mode !== "box",
hiddenslider: newVal.attributes.mode !== "slider",
}); });
if (this.mode === "slider" && prevMode !== "slider") {
this.hiddenState();
}
} }
selectedValueChanged() { selectedValueChanged() {

View File

@ -51,17 +51,24 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
enableShortcuts: true, enableShortcuts: true,
moreInfoEntityId: null, moreInfoEntityId: null,
hassUrl: (path = "") => new URL(path, auth.data.hassUrl).toString(), hassUrl: (path = "") => new URL(path, auth.data.hassUrl).toString(),
callService: async (domain, service, serviceData = {}) => { callService: async (domain, service, serviceData = {}, target) => {
if (__DEV__) { if (__DEV__) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log("Calling service", domain, service, serviceData); console.log(
"Calling service",
domain,
service,
serviceData,
target
);
} }
try { try {
return (await callService( return (await callService(
conn, conn,
domain, domain,
service, service,
serviceData serviceData,
target
)) as Promise<ServiceCallResponse>; )) as Promise<ServiceCallResponse>;
} catch (err) { } catch (err) {
if (__DEV__) { if (__DEV__) {
@ -71,6 +78,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
domain, domain,
service, service,
serviceData, serviceData,
target,
err err
); );
} }

View File

@ -51,6 +51,17 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
this._loadCoreTranslations(getLocalLanguage()); this._loadCoreTranslations(getLocalLanguage());
} }
protected updated(changedProps) {
super.updated(changedProps);
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass");
if (this.hass?.panels && oldHass.panels !== this.hass.panels) {
this._loadFragmentTranslations(this.hass.language, this.hass.panelUrl);
}
}
protected hassConnected() { protected hassConnected() {
super.hassConnected(); super.hassConnected();
getUserLanguage(this.hass!).then((language) => { getUserLanguage(this.hass!).then((language) => {
@ -204,13 +215,10 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
const panelComponent = this.hass?.panels?.[panelUrl]?.component_name; const panelComponent = this.hass?.panels?.[panelUrl]?.component_name;
// If it's the first call we don't have panel info yet to check the component. // If it's the first call we don't have panel info yet to check the component.
// If the url is not known it might be a custom lovelace dashboard, so we load lovelace translations
const fragment = translationMetadata.fragments.includes( const fragment = translationMetadata.fragments.includes(
panelComponent || panelUrl panelComponent || panelUrl
) )
? panelComponent || panelUrl ? panelComponent || panelUrl
: !panelComponent
? "lovelace"
: undefined; : undefined;
if (!fragment) { if (!fragment) {

View File

@ -424,6 +424,12 @@
"service-picker": { "service-picker": {
"service": "Service" "service": "Service"
}, },
"service-control": {
"required": "This field is required",
"target": "Target",
"target_description": "What should this service call target",
"service_data": "Service data"
},
"related-items": { "related-items": {
"no_related_found": "No related items found.", "no_related_found": "No related items found.",
"integration": "Integration", "integration": "Integration",
@ -806,6 +812,7 @@
"panel": { "panel": {
"my": { "my": {
"not_supported": "This redirect is not supported by your Home Assistant instance. Check the {link} for the supported redirects and the version they where introduced.", "not_supported": "This redirect is not supported by your Home Assistant instance. Check the {link} for the supported redirects and the version they where introduced.",
"component_not_loaded": "This redirect is not supported by your Home Assistant instance. You need the integration {integration} to use this redirect.",
"faq_link": "My Home Assistant FAQ", "faq_link": "My Home Assistant FAQ",
"error": "An unknown error occured" "error": "An unknown error occured"
}, },
@ -1400,8 +1407,7 @@
"type_select": "Action type", "type_select": "Action type",
"type": { "type": {
"service": { "service": {
"label": "Call service", "label": "Call service"
"service_data": "Service data"
}, },
"delay": { "delay": {
"label": "Delay", "label": "Delay",
@ -1424,7 +1430,7 @@
"event": { "event": {
"label": "Fire event", "label": "Fire event",
"event": "[%key:ui::panel::config::automation::editor::triggers::type::homeassistant::event%]", "event": "[%key:ui::panel::config::automation::editor::triggers::type::homeassistant::event%]",
"service_data": "[%key:ui::panel::config::automation::editor::actions::type::service::service_data%]" "service_data": "[%key:ui::components::service-control::service_data%]"
}, },
"device_id": { "device_id": {
"label": "Device", "label": "Device",
@ -2068,7 +2074,13 @@
"description": "This step requires you to visit an external website to be completed.", "description": "This step requires you to visit an external website to be completed.",
"open_site": "Open website" "open_site": "Open website"
}, },
"loading_first_time": "Please wait while the integration is being installed" "pick_flow_step": {
"title": "We discovered these, want to set them up?",
"new_flow": "No, set up an other instance of {integration}"
},
"loading_first_time": "Please wait while the integration is being installed",
"error": "Error",
"could_not_load": "Config flow could not be loaded"
} }
}, },
"users": { "users": {
@ -2693,7 +2705,6 @@
"action-editor": { "action-editor": {
"navigation_path": "Navigation Path", "navigation_path": "Navigation Path",
"url_path": "URL Path", "url_path": "URL Path",
"editor_service_data": "Service data can only be entered in the code editor",
"actions": { "actions": {
"default_action": "Default Action", "default_action": "Default Action",
"call-service": "Call Service", "call-service": "Call Service",
@ -3272,16 +3283,16 @@
"services": { "services": {
"title": "Services", "title": "Services",
"description": "The service dev tool allows you to call any available service in Home Assistant.", "description": "The service dev tool allows you to call any available service in Home Assistant.",
"data": "Service Data (YAML, optional)",
"call_service": "Call Service", "call_service": "Call Service",
"select_service": "Select a service to see the description",
"no_description": "No description is available",
"no_parameters": "This service takes no parameters.",
"column_parameter": "Parameter", "column_parameter": "Parameter",
"column_description": "Description", "column_description": "Description",
"column_example": "Example", "column_example": "Example",
"fill_example_data": "Fill Example Data", "fill_example_data": "Fill Example Data",
"alert_parsing_yaml": "Error parsing YAML: {data}" "yaml_mode": "Go to YAML mode",
"ui_mode": "Go to UI mode",
"yaml_parameters": "Parameters only available in YAML mode",
"all_parameters": "All available parameters",
"accepts_target": "This service accepts a target, for example: `entity_id: light.bed_light`"
}, },
"states": { "states": {
"title": "States", "title": "States",

View File

@ -3,6 +3,7 @@ import {
Connection, Connection,
HassConfig, HassConfig,
HassEntities, HassEntities,
HassServiceTarget,
HassServices, HassServices,
MessageBase, MessageBase,
} from "home-assistant-js-websocket"; } from "home-assistant-js-websocket";
@ -178,6 +179,7 @@ export interface ServiceCallRequest {
domain: string; domain: string;
service: string; service: string;
serviceData?: Record<string, any>; serviceData?: Record<string, any>;
target?: HassServiceTarget;
} }
export interface HomeAssistant { export interface HomeAssistant {
@ -216,7 +218,8 @@ export interface HomeAssistant {
callService( callService(
domain: ServiceCallRequest["domain"], domain: ServiceCallRequest["domain"],
service: ServiceCallRequest["service"], service: ServiceCallRequest["service"],
serviceData?: ServiceCallRequest["serviceData"] serviceData?: ServiceCallRequest["serviceData"],
target?: ServiceCallRequest["target"]
): Promise<ServiceCallResponse>; ): Promise<ServiceCallResponse>;
callApi<T>( callApi<T>(
method: "GET" | "POST" | "PUT" | "DELETE", method: "GET" | "POST" | "PUT" | "DELETE",

View File

@ -1,20 +1,21 @@
import * as assert from "assert"; import * as assert from "assert";
import { createHassioSession } from "../../src/data/hassio/ingress"; import { createHassioSession } from "../../src/data/hassio/ingress";
const sessionID = "fhdsu73rh3io4h8f3irhjel8ousafehf8f3yh";
describe("Create hassio session", function () { describe("Create hassio session", function () {
const hass = {
config: { version: "1.0.0" },
callApi: async function () {
return { data: { session: "fhdsu73rh3io4h8f3irhjel8ousafehf8f3yh" } };
},
};
it("Test create session without HTTPS", async function () { it("Test create session without HTTPS", async function () {
// @ts-ignore // @ts-ignore
global.document = {}; global.document = {};
// @ts-ignore // @ts-ignore
global.location = {}; global.location = {};
await createHassioSession({ // @ts-ignore
// @ts-ignore await createHassioSession(hass);
callApi: async function () {
return { data: { session: sessionID } };
},
});
assert.strictEqual( assert.strictEqual(
// @ts-ignore // @ts-ignore
global.document.cookie, global.document.cookie,
@ -26,12 +27,8 @@ describe("Create hassio session", function () {
global.document = {}; global.document = {};
// @ts-ignore // @ts-ignore
global.location = { protocol: "https:" }; global.location = { protocol: "https:" };
await createHassioSession({ // @ts-ignore
// @ts-ignore await createHassioSession(hass);
callApi: async function () {
return { data: { session: sessionID } };
},
});
assert.strictEqual( assert.strictEqual(
// @ts-ignore // @ts-ignore
global.document.cookie, global.document.cookie,

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