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",
"context": ".."
},
"appPort": 8123,
"appPort": "8124:8123",
"context": "..",
"postCreateCommand": "script/bootstrap",
"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 path = require("path");
const TerserPlugin = require("terser-webpack-plugin");
const ManifestPlugin = require("webpack-manifest-plugin");
const { WebpackManifestPlugin } = require("webpack-manifest-plugin");
const paths = require("./paths.js");
const bundle = require("./bundle");
const log = require("fancy-log");
@ -68,7 +68,7 @@ const createWebpackConfig = ({
],
},
plugins: [
new ManifestPlugin({
new WebpackManifestPlugin({
// Only include the JS of entrypoints
filter: (file) => file.isInitial && !file.name.endsWith(".map"),
}),

View File

@ -48,7 +48,7 @@ class HcCast extends LitElement {
protected render(): TemplateResult {
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 =

View File

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

View File

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

View File

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

View File

@ -9,17 +9,24 @@ import {
CSSResult,
customElement,
html,
internalProperty,
LitElement,
property,
TemplateResult,
} from "lit-element";
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 {
fetchHassioAddonInfo,
HassioAddonDetails,
} from "../../../src/data/hassio/addon";
import { extractApiErrorMessage } from "../../../src/data/hassio/common";
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 type { PageNavigation } from "../../../src/layouts/hass-tabs-subpage";
import { haStyle } from "../../../src/resources/styles";
@ -44,6 +51,8 @@ class HassioAddonDashboard extends LitElement {
@property({ type: Boolean }) public narrow!: boolean;
@internalProperty() _error?: string;
private _computeTail = memoizeOne((route: Route) => {
const dividerPos = route.path.indexOf("/", 1);
return dividerPos === -1
@ -58,8 +67,14 @@ class HassioAddonDashboard extends LitElement {
});
protected render(): TemplateResult {
if (this._error) {
return html`<hass-error-screen
.error=${this._error}
></hass-error-screen>`;
}
if (!this.addon) {
return html`<ha-circular-progress active></ha-circular-progress>`;
return html`<hass-loading-screen></hass-loading-screen>`;
}
const addonTabs: PageNavigation[] = [
@ -156,30 +171,51 @@ class HassioAddonDashboard extends LitElement {
}
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));
}
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;
}
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") {
history.back();
window.history.back();
} else {
await this._routeDataChanged(this.route);
await this._routeDataChanged();
}
}
private async _routeDataChanged(routeData: Route): Promise<void> {
const addon = routeData.path.split("/")[1];
protected updated(changedProperties) {
if (changedProperties.has("route") && !this.addon) {
this._routeDataChanged();
}
}
private async _routeDataChanged(): Promise<void> {
const addon = this.route.path.split("/")[1];
if (!addon) {
return;
}
try {
const addoninfo = await fetchHassioAddonInfo(this.hass, addon);
this.addon = addoninfo;
} catch {
} catch (err) {
this._error = `Error fetching addon info: ${extractApiErrorMessage(err)}`;
this.addon = undefined;
}
}

View File

@ -43,10 +43,13 @@ import {
HassioAddonSetOptionParams,
HassioAddonSetSecurityParams,
installHassioAddon,
restartHassioAddon,
setHassioAddonOption,
setHassioAddonSecurity,
startHassioAddon,
stopHassioAddon,
uninstallHassioAddon,
updateHassioAddon,
validateHassioAddonOption,
} from "../../../../src/data/hassio/addon";
import {
@ -196,13 +199,9 @@ class HassioAddonInfo extends LitElement {
: ""}
</div>
<div class="card-actions">
<ha-call-api-button
.hass=${this.hass}
.disabled=${!this.addon.available}
path="hassio/addons/${this.addon.slug}/update"
>
<ha-progress-button @click=${this._updateClicked}>
Update
</ha-call-api-button>
</ha-progress-button>
${this.addon.changelog
? html`
<mwc-button @click=${this._openChangelog}>
@ -579,20 +578,18 @@ class HassioAddonInfo extends LitElement {
${this.addon.version
? this._computeIsRunning
? html`
<ha-call-api-button
<ha-progress-button
class="warning"
.hass=${this.hass}
.path="hassio/addons/${this.addon.slug}/stop"
@click=${this._stopClicked}
>
Stop
</ha-call-api-button>
<ha-call-api-button
</ha-progress-button>
<ha-progress-button
class="warning"
.hass=${this.hass}
.path="hassio/addons/${this.addon.slug}/restart"
@click=${this._restartClicked}
>
Restart
</ha-call-api-button>
</ha-progress-button>
`
: html`
<ha-progress-button @click=${this._startClicked}>
@ -883,6 +880,82 @@ class HassioAddonInfo extends LitElement {
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> {
const button = ev.currentTarget as any;
button.progress = true;
@ -891,10 +964,10 @@ class HassioAddonInfo extends LitElement {
this.hass,
this.addon.slug
);
if (!validate.data.valid) {
if (!validate.valid) {
await showConfirmationDialog(this, {
title: "Failed to start addon - configuration validation failed!",
text: validate.data.message.split(" Got ")[0],
text: validate.message.split(" Got ")[0],
confirm: () => this._openConfiguration(),
confirmText: "Go to configuration",
dismissText: "Cancel",
@ -914,6 +987,12 @@ class HassioAddonInfo extends LitElement {
try {
await startHassioAddon(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) {
showAlertDialog(this, {
title: "Failed to start addon",

View File

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

View File

@ -22,7 +22,11 @@ import {
fetchHassioSnapshotInfo,
HassioSnapshotDetail,
} 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 { haStyle, haStyleDialog } from "../../../../src/resources/styles";
import { HomeAssistant } from "../../../../src/types";
@ -75,6 +79,8 @@ interface FolderItem {
class HassioSnapshotDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public supervisor?: Supervisor;
@internalProperty() private _error?: string;
@internalProperty() private _onboarding = false;
@ -102,6 +108,7 @@ class HassioSnapshotDialog extends LitElement {
this._dialogParams = params;
this._onboarding = params.onboarding ?? false;
this.supervisor = params.supervisor;
}
protected render(): TemplateResult {
@ -298,6 +305,16 @@ class HassioSnapshotDialog extends LitElement {
}
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 (
!(await showConfirmationDialog(this, {
title: "Are you sure you want partially to restore this snapshot?",
@ -359,6 +376,16 @@ class HassioSnapshotDialog extends LitElement {
}
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 (
!(await showConfirmationDialog(this, {
title:

View File

@ -1,9 +1,11 @@
import { fireEvent } from "../../../../src/common/dom/fire_event";
import { Supervisor } from "../../../../src/data/supervisor/supervisor";
export interface HassioSnapshotDialogParams {
slug: string;
onDelete?: () => void;
onboarding?: boolean;
supervisor?: Supervisor;
}
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 { fireEvent } from "../../src/common/dom/fire_event";
import { HassioPanelInfo } from "../../src/data/hassio/supervisor";
import { supervisorStore } from "../../src/data/supervisor/supervisor";
import { makeDialogManager } from "../../src/dialogs/make-dialog-manager";
import "../../src/layouts/hass-loading-screen";
import { HomeAssistant, Route } from "../../src/types";
import "./hassio-router";
import { SupervisorBaseElement } from "./supervisor-base-element";
@ -71,8 +73,15 @@ export class HassioMain extends SupervisorBaseElement {
protected render() {
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`
<hassio-router
.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",
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;
el.hass = this.hass;
el.supervisor = this.supervisor;
el.narrow = this.narrow;
el.route = route;
if (el.localName === "hassio-ingress-view") {
el.ingressPanel = this.panel.config && this.panel.config.ingress;
} else {
el.supervisor = this.supervisor;
}
}

View File

@ -41,6 +41,7 @@ import {
reloadHassioSnapshots,
} from "../../../src/data/hassio/snapshot";
import { Supervisor } from "../../../src/data/supervisor/supervisor";
import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box";
import "../../../src/layouts/hass-tabs-subpage";
import { PolymerChangedEvent } from "../../../src/polymer-types";
import { haStyle } from "../../../src/resources/styles";
@ -211,7 +212,13 @@ class HassioSnapshots extends LitElement {
: undefined}
</div>
<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
</ha-progress-button>
</div>
@ -325,6 +332,12 @@ class HassioSnapshots extends LitElement {
}
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;
button.progress = true;
@ -386,6 +399,7 @@ class HassioSnapshots extends LitElement {
private _snapshotClicked(ev) {
showHassioSnapshotDialog(this, {
slug: ev.currentTarget!.snapshot.slug,
supervisor: this.supervisor,
onDelete: () => this._updateSnapshots(),
});
}
@ -395,6 +409,7 @@ class HassioSnapshots extends LitElement {
showSnapshot: (slug: string) =>
showHassioSnapshotDialog(this, {
slug,
supervisor: this.supervisor,
onDelete: () => this._updateSnapshots(),
}),
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 {
fetchHassioHassOsInfo,
fetchHassioHostInfo,
@ -10,13 +19,20 @@ import {
fetchHassioInfo,
fetchHassioSupervisorInfo,
} 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 { urlSyncMixin } from "../../src/state/url-sync-mixin";
declare global {
interface HASSDomEvents {
"supervisor-update": Partial<Supervisor>;
"supervisor-store-refresh": { store: SupervisorObject };
}
}
@ -25,6 +41,20 @@ export class SupervisorBaseElement extends urlSyncMixin(
) {
@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 {
this.supervisor = { ...this.supervisor!, ...obj };
}
@ -32,13 +62,59 @@ export class SupervisorBaseElement extends urlSyncMixin(
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
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> {
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 [
addon,
supervisor,
host,
core,
@ -47,6 +123,7 @@ export class SupervisorBaseElement extends urlSyncMixin(
network,
resolution,
] = await Promise.all([
fetchHassioAddonsInfo(this.hass),
fetchHassioSupervisorInfo(this.hass),
fetchHassioHostInfo(this.hass),
fetchHassioHomeAssistantInfo(this.hass),
@ -57,6 +134,7 @@ export class SupervisorBaseElement extends urlSyncMixin(
]);
this.supervisor = {
addon,
supervisor,
host,
core,
@ -65,5 +143,9 @@ export class SupervisorBaseElement extends urlSyncMixin(
network,
resolution,
};
this.addEventListener("supervisor-update", (ev) =>
this._updateSupervisor(ev.detail)
);
}
}

View File

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

View File

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

View File

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

View File

@ -101,7 +101,7 @@
"fuse.js": "^6.0.0",
"google-timezones-json": "^1.0.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",
"intl-messageformat": "^8.3.9",
"js-yaml": "^3.13.1",
@ -110,7 +110,7 @@
"lit-element": "^2.4.0",
"lit-html": "^1.3.0",
"lit-virtualizer": "^0.4.2",
"marked": "^1.1.1",
"marked": "2.0.0",
"mdn-polyfills": "^5.16.0",
"memoize-one": "^5.0.2",
"node-vibrant": "3.2.1-alpha.1",
@ -161,7 +161,7 @@
"@types/js-yaml": "^3.12.1",
"@types/leaflet": "^1.4.3",
"@types/leaflet-draw": "^1.0.1",
"@types/marked": "^1.1.0",
"@types/marked": "^1.2.2",
"@types/memoize-one": "4.1.0",
"@types/mocha": "^7.0.2",
"@types/resize-observer-browser": "^0.1.3",
@ -222,7 +222,7 @@
"webpack": "5.1.3",
"webpack-cli": "4.1.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"
},
"_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(
name="home-assistant-frontend",
version="20210208.0",
version="20210222.0",
description="The Home Assistant frontend",
url="https://github.com/home-assistant/home-assistant-polymer",
author="The Home Assistant Authors",

View File

@ -1,11 +1,15 @@
export const atLeastVersion = (
version: string,
major: number,
minor: number
minor: number,
patch?: number
): boolean => {
const [haMajor, haMinor] = version.split(".", 2);
const [haMajor, haMinor, haPatch] = version.split(".", 3);
return (
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-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 {
css,
CSSResult,
customElement,
html,
internalProperty,
LitElement,
property,
PropertyValues,
@ -38,7 +34,7 @@ import {
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import { PolymerChangedEvent } from "../../polymer-types";
import { HomeAssistant } from "../../types";
import "../ha-svg-icon";
import { HaComboBox } from "../ha-combo-box";
interface Device {
name: string;
@ -112,10 +108,11 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
@property() public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ type: Boolean })
private _opened?: boolean;
@property({ type: Boolean }) public disabled?: 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;
@ -244,15 +241,11 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
);
public open() {
this.updateComplete.then(() => {
(this.shadowRoot?.querySelector("vaadin-combo-box-light") as any)?.open();
});
this._comboBox?.open();
}
public focus() {
this.updateComplete.then(() => {
this.shadowRoot?.querySelector("paper-input")?.focus();
});
this._comboBox?.focus();
}
public hassSubscribe(): UnsubscribeFunc[] {
@ -292,70 +285,29 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
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-id-path="id"
item-label-path="name"
.value=${this._value}
.renderer=${rowRenderer}
@opened-changed=${this._openedChanged}
@value-changed=${this._deviceChanged}
>
<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>
></ha-combo-box>
`;
}
private _clearValue(ev: Event) {
ev.stopPropagation();
this._setValue("");
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: PolymerChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _deviceChanged(ev: PolymerChangedEvent<string>) {
ev.stopPropagation();
const newValue = ev.detail.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) {
this.value = value;
setTimeout(() => {

View File

@ -117,6 +117,8 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) {
@property() public entityFilter?: (entity: EntityRegistryEntry) => boolean;
@property({ type: Boolean }) public disabled?: boolean;
@internalProperty() private _areas?: AreaRegistryEntry[];
@internalProperty() private _devices?: DeviceRegistryEntry[];
@ -339,6 +341,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) {
item-label-path="name"
.value=${this._value}
.renderer=${rowRenderer}
.disabled=${this.disabled}
@opened-changed=${this._openedChanged}
@value-changed=${this._areaChanged}
>
@ -349,6 +352,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) {
.placeholder=${this.placeholder
? this._area(this.placeholder)?.name
: undefined}
.disabled=${this.disabled}
class="input"
autocapitalize="none"
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 type { PaperInputElement } from "@polymer/paper-input/paper-input";
import {
css,
CSSResult,
customElement,
html,
internalProperty,
@ -10,12 +13,13 @@ import {
TemplateResult,
} from "lit-element";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-icon-button";
import "../ha-svg-icon";
import type {
HaFormElement,
HaFormStringData,
HaFormStringSchema,
} from "./ha-form";
import "@material/mwc-icon-button/mwc-icon-button";
@customElement("ha-form-string")
export class HaFormString extends LitElement implements HaFormElement {
@ -48,16 +52,17 @@ export class HaFormString extends LitElement implements HaFormElement {
.autoValidate=${this.schema.required}
@value-changed=${this._valueChanged}
>
<ha-icon-button
<mwc-icon-button
toggles
slot="suffix"
.icon=${this._unmaskedPassword ? "hass:eye-off" : "hass:eye"}
id="iconButton"
title="Click to toggle between masked and clear password"
@click=${this._toggleUnmaskedPassword}
tabindex="-1"
>
</ha-icon-button>
><ha-svg-icon
.path=${this._unmaskedPassword ? mdiEyeOff : mdiEye}
></ha-svg-icon>
</mwc-icon-button>
</paper-input>
`
: html`
@ -98,6 +103,15 @@ export class HaFormString extends LitElement implements HaFormElement {
}
return "text";
}
static get styles(): CSSResult {
return css`
mwc-icon-button {
--mdc-icon-button-size: 24px;
color: var(--secondary-text-color);
}
`;
}
}
declare global {

View File

@ -202,9 +202,8 @@ export class HaForm extends LitElement implements HaFormElement {
ev.stopPropagation();
const schema = (ev.target as HaFormElement).schema as HaFormSchema;
const data = this.data as HaFormDataContainer;
data[schema.name] = ev.detail.value;
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({ type: Boolean, reflect: true }) public disabled = false;
protected render() {
return html`<ha-automation-action
.disabled=${this.disabled}
.actions=${this.value || []}
.hass=${this.hass}
></ha-automation-action>`;
@ -34,6 +37,10 @@ export class HaActionSelector extends LitElement {
display: block;
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[];
@property({ type: Boolean }) public disabled = false;
protected updated(changedProperties) {
if (changedProperties.has("selector")) {
const oldSelector = changedProperties.get("selector");
@ -50,6 +52,7 @@ export class HaAreaSelector extends LitElement {
.includeDomains=${this.selector.area.entity?.domain
? [this.selector.area.entity.domain]
: undefined}
.disabled=${this.disabled}
></ha-area-picker>`;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,6 +12,7 @@ import "./ha-selector-target";
import "./ha-selector-time";
import "./ha-selector-object";
import "./ha-selector-text";
import "./ha-selector-select";
@customElement("ha-selector")
export class HaSelector extends LitElement {
@ -23,6 +24,10 @@ export class HaSelector extends LitElement {
@property() public label?: string;
@property() public placeholder?: any;
@property({ type: Boolean }) public disabled = false;
public focus() {
const input = this.shadowRoot!.getElementById("selector");
if (!input) {
@ -42,6 +47,8 @@ export class HaSelector extends LitElement {
selector: this.selector,
value: this.value,
label: this.label,
placeholder: this.placeholder,
disabled: this.disabled,
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,
LitElement,
property,
SVGTemplateResult,
TemplateResult,
} from "lit-element";
@customElement("ha-settings-row")
@ -16,15 +16,18 @@ export class HaSettingsRow extends LitElement {
@property({ type: Boolean, attribute: "three-line" })
public threeLine = false;
protected render(): SVGTemplateResult {
protected render(): TemplateResult {
return html`
<paper-item-body
?two-line=${!this.threeLine}
?three-line=${this.threeLine}
>
<slot name="heading"></slot>
<div secondary><slot name="description"></slot></div>
</paper-item-body>
<div class="prefix-wrap">
<slot name="prefix"></slot>
<paper-item-body
?two-line=${!this.threeLine}
?three-line=${this.threeLine}
>
<slot name="heading"></slot>
<div secondary><slot name="description"></slot></div>
</paper-item-body>
</div>
<slot></slot>
`;
}
@ -45,6 +48,7 @@ export class HaSettingsRow extends LitElement {
min-height: calc(
var(--paper-item-body-two-line-min-height, 72px) - 16px
);
flex: 1;
}
:host([narrow]) {
align-items: normal;
@ -58,6 +62,13 @@ export class HaSettingsRow extends LitElement {
div[secondary] {
white-space: normal;
}
.prefix-wrap {
display: contents;
}
:host([narrow]) .prefix-wrap {
display: flex;
align-items: center;
}
`;
}
}

View File

@ -10,7 +10,10 @@ import {
mdiUnfoldMoreVertical,
} from "@mdi/js";
import "@polymer/paper-tooltip/paper-tooltip";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import {
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import {
css,
CSSResult,
@ -41,7 +44,6 @@ import {
EntityRegistryEntry,
subscribeEntityRegistry,
} from "../data/entity_registry";
import { Target } from "../data/target";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { HomeAssistant } from "../types";
import "./device/ha-device-picker";
@ -56,7 +58,7 @@ import "./ha-svg-icon";
export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property() public hass!: HomeAssistant;
@property() public value?: Target;
@property() public value?: HassServiceTarget;
@property() public label?: string;
@ -82,6 +84,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property() public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ type: Boolean, reflect: true }) public disabled = false;
@internalProperty() private _areas?: { [areaId: string]: AreaRegistryEntry };
@internalProperty() private _devices?: {
@ -436,7 +440,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
type: string,
id: string
): this["value"] {
const newVal = ensureArray(value![type])!.filter((val) => val !== id);
const newVal = ensureArray(value![type])!.filter(
(val) => String(val) !== id
);
if (newVal.length) {
return {
...value,
@ -530,6 +536,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.items {
z-index: 2;
}
.mdc-chip-set {
padding: 4px 0;
}
.mdc-chip.add {
color: rgba(0, 0, 0, 0.87);
}
@ -594,6 +603,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
paper-tooltip.expand {
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 = "";
@query("ha-code-editor", true) private _editor?: HaCodeEditor;
@query("ha-code-editor") private _editor?: HaCodeEditor;
public setValue(value): void {
try {
this._yaml = value && !isEmpty(value) ? safeDump(value) : "";
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
console.error(err, value);
alert(`There was an error converting to YAML: ${err}`);
}
afterNextRender(() => {
@ -73,7 +73,7 @@ export class HaYamlEditor extends LitElement {
return html``;
}
return html`
${this.label ? html` <p>${this.label}</p> ` : ""}
${this.label ? html`<p>${this.label}</p>` : ""}
<ha-code-editor
.value=${this._yaml}
mode="yaml"
@ -85,13 +85,13 @@ export class HaYamlEditor extends LitElement {
private _onChange(ev: CustomEvent): void {
ev.stopPropagation();
const value = ev.detail.value;
this._yaml = ev.detail.value;
let parsed;
let isValid = true;
if (value) {
if (this._yaml) {
try {
parsed = safeLoad(value);
parsed = safeLoad(this._yaml);
} catch (err) {
// Invalid YAML
isValid = false;
@ -107,7 +107,7 @@ export class HaYamlEditor extends LitElement {
}
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) =>
hass.callApi<string[]>("GET", "config/config_entries/flow_handlers");
const fetchConfigFlowInProgress = (conn) =>
export const fetchConfigFlowInProgress = (
conn: Connection
): Promise<DataEntryFlowProgress[]> =>
conn.sendMessagePromise({
type: "config_entries/flow/progress",
});
const subscribeConfigFlowInProgressUpdates = (conn, store) =>
const subscribeConfigFlowInProgressUpdates = (conn: Connection, store) =>
conn.subscribeEvents(
debounce(
() =>
fetchConfigFlowInProgress(conn).then((flows) =>
fetchConfigFlowInProgress(conn).then((flows: DataEntryFlowProgress[]) =>
store.setState(flows, true)
),
500,

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant } from "../../types";
import { hassioApiResultExtractor, HassioResponse } from "./common";
@ -5,7 +6,17 @@ interface HassioDockerRegistries {
[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(
await hass.callApi<HassioResponse<HassioDockerRegistries>>(
"GET",
@ -18,6 +29,16 @@ export const addHassioDockerRegistry = async (
hass: HomeAssistant,
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>>(
"POST",
"hassio/docker/registries",
@ -29,6 +50,15 @@ export const removeHassioDockerRegistry = async (
hass: HomeAssistant,
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>>(
"DELETE",
`hassio/docker/registries/${registry}`

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant } from "../../types";
import { hassioApiResultExtractor, HassioResponse } from "./common";
@ -21,7 +22,17 @@ export interface HassioHardwareInfo {
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(
await hass.callApi<HassioResponse<HassioHardwareAudioList>>(
"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(
await hass.callApi<HassioResponse<HassioHardwareInfo>>(
"GET",

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant } from "../../types";
import { hassioApiResultExtractor, HassioResponse } from "./common";
@ -23,7 +24,17 @@ export interface HassioHassOSInfo {
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>>(
"GET",
"hassio/host/info"
@ -31,7 +42,17 @@ export const fetchHassioHostInfo = async (hass: HomeAssistant) => {
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(
await hass.callApi<HassioResponse<HassioHassOSInfo>>(
"GET",
@ -41,22 +62,67 @@ export const fetchHassioHassOsInfo = 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");
};
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");
};
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");
};
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");
};
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>>(
"POST",
"hassio/host/options",

View File

@ -1,26 +1,50 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant } from "../../types";
import { HassioResponse } from "./common";
import { CreateSessionResponse } from "./supervisor";
export const createHassioSession = async (hass: HomeAssistant) => {
const response = await hass.callApi<HassioResponse<CreateSessionResponse>>(
"POST",
"hassio/ingress/session"
);
document.cookie = `ingress_session=${
response.data.session
};path=/api/hassio_ingress/;SameSite=Strict${
function setIngressCookie(session: string): string {
document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${
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 (
hass: HomeAssistant,
session: string
) =>
await hass.callApi<HassioResponse<null>>(
): Promise<void> => {
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",
"hassio/ingress/validate_session",
{ session }
);
};

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant } from "../../types";
import { hassioApiResultExtractor, HassioResponse } from "./common";
@ -51,7 +52,17 @@ export interface NetworkInfo {
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(
await hass.callApi<HassioResponse<NetworkInfo>>(
"GET",
@ -65,6 +76,17 @@ export const updateNetworkInterface = async (
network_interface: string,
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>>(
"POST",
`hassio/network/interface/${network_interface}/update`,
@ -75,7 +97,16 @@ export const updateNetworkInterface = async (
export const accesspointScan = async (
hass: HomeAssistant,
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(
await hass.callApi<HassioResponse<AccessPoints>>(
"GET",

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant } from "../../types";
import { hassioApiResultExtractor, HassioResponse } from "./common";
@ -8,7 +9,17 @@ export interface HassioResolution {
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(
await hass.callApi<HassioResponse<HassioResolution>>(
"GET",

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant } from "../../types";
import { hassioApiResultExtractor, HassioResponse } from "./common";
@ -33,7 +34,18 @@ export interface HassioPartialSnapshotCreateParams {
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(
await hass.callApi<HassioResponse<{ snapshots: HassioSnapshot[] }>>(
"GET",
@ -45,8 +57,15 @@ export const fetchHassioSnapshots = async (hass: HomeAssistant) => {
export const fetchHassioSnapshotInfo = async (
hass: HomeAssistant,
snapshot: string
) => {
): Promise<HassioSnapshotDetail> => {
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(
await hass.callApi<HassioResponse<HassioSnapshotDetail>>(
"GET",
@ -63,6 +82,15 @@ export const fetchHassioSnapshotInfo = async (
};
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`);
};
@ -70,6 +98,15 @@ export const createHassioFullSnapshot = async (
hass: HomeAssistant,
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>>(
"POST",
`hassio/snapshots/new/full`,
@ -81,6 +118,17 @@ export const createHassioPartialSnapshot = async (
hass: HomeAssistant,
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>>(
"POST",
`hassio/snapshots/new/partial`,

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant, PanelInfo } from "../../types";
import { SupervisorArch } from "../supervisor/supervisor";
import { HassioAddonInfo, HassioAddonRepository } from "./addon";
@ -49,6 +50,15 @@ export type HassioInfo = {
hostname: string;
logging: string;
machine: string;
state:
| "initialize"
| "setup"
| "startup"
| "running"
| "freeze"
| "shutdown"
| "stopping"
| "close";
operating_system: string;
supervisor: string;
supported: boolean;
@ -74,18 +84,57 @@ export interface SupervisorOptions {
}
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`);
};
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`);
};
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`);
};
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(
await hass.callApi<HassioResponse<HassioHomeAssistantInfo>>(
"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(
await hass.callApi<HassioResponse<HassioSupervisorInfo>>(
"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(
await hass.callApi<HassioResponse<HassioInfo>>("GET", "hassio/info")
);
@ -120,6 +189,16 @@ export const setSupervisorOption = async (
hass: HomeAssistant,
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>>(
"POST",
"hassio/supervisor/options",

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant } from "../../types";
import { HassioResponse } from "../hassio/common";
@ -6,5 +7,15 @@ export const restartCore = 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`);
};

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 { NetworkInfo } from "../hassio/network";
import { HassioResolution } from "../hassio/resolution";
@ -7,7 +11,46 @@ import {
HassioSupervisorInfo,
} 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 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 {
host: HassioHostInfo;
@ -17,4 +60,77 @@ export interface Supervisor {
network: NetworkInfo;
resolution: HassioResolution;
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,
subscribeAreaRegistry,
} from "../../data/area_registry";
import { fetchConfigFlowInProgress } from "../../data/config_flow";
import type {
DataEntryFlowProgress,
DataEntryFlowProgressedEvent,
DataEntryFlowStep,
} from "../../data/data_entry_flow";
@ -41,6 +43,7 @@ import "./step-flow-form";
import "./step-flow-loading";
import "./step-flow-pick-handler";
import "./step-flow-progress";
import "./step-flow-pick-flow";
let instance = 0;
@ -76,6 +79,10 @@ class DataEntryFlowDialog extends LitElement {
@internalProperty() private _handlers?: string[];
@internalProperty() private _handler?: string;
@internalProperty() private _flowsInProgress?: DataEntryFlowProgress[];
private _unsubAreas?: UnsubscribeFunc;
private _unsubDevices?: UnsubscribeFunc;
@ -84,59 +91,93 @@ class DataEntryFlowDialog extends LitElement {
this._params = params;
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
if (!params.continueFlowId && !params.startFlowHandler) {
if (!params.flowConfig.getFlowHandlers) {
throw new Error("No getFlowHandlers defined in flow config");
if (!params.flowConfig.getFlowHandlers) {
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;
}
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;
await this.updateComplete;
}
public closeDialog() {
if (this._step) {
this._flowDone();
} else if (this._step === null) {
// Flow aborted during picking flow
this._step = undefined;
this._params = undefined;
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._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 });
}
@ -156,7 +197,9 @@ class DataEntryFlowDialog extends LitElement {
>
<div>
${this._loading ||
(this._step === null && this._handlers === undefined)
(this._step === null &&
this._handlers === undefined &&
this._handler === undefined)
? html`
<step-flow-loading
.label=${this.hass.localize(
@ -178,15 +221,22 @@ class DataEntryFlowDialog extends LitElement {
?rtl=${computeRTL(this.hass)}
></ha-icon-button>
${this._step === null
? // Show handler picker
html`
<step-flow-pick-handler
? this._handler
? html`<step-flow-pick-flow
.flowConfig=${this._params.flowConfig}
.hass=${this.hass}
.handlers=${this._handlers}
.showAdvanced=${this._params.showAdvanced}
></step-flow-pick-handler>
`
.handler=${this._handler}
.flowsInProgress=${this._flowsInProgress}
></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"
? html`
<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(
step: DataEntryFlowStep | undefined | Promise<DataEntryFlowStep>
): Promise<void> {
@ -305,7 +392,7 @@ class DataEntryFlowDialog extends LitElement {
}
if (step === undefined) {
this._flowDone();
this.closeDialog();
return;
}
this._step = undefined;
@ -313,38 +400,6 @@ class DataEntryFlowDialog extends LitElement {
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 {
return [
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 { brandsUrl } from "../../util/brands-url";
import { documentationUrl } from "../../util/documentation-url";
import { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
interface HandlerObj {
@ -30,17 +29,24 @@ interface HandlerObj {
slug: string;
}
declare global {
// for fire event
interface HASSDomEvents {
"handler-picked": {
handler: string;
};
}
}
@customElement("step-flow-pick-handler")
class StepFlowPickHandler extends LitElement {
public flowConfig!: FlowConfig;
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public handlers!: string[];
@property() public showAdvanced?: boolean;
@internalProperty() private filter?: string;
@internalProperty() private _filter?: string;
private _width?: number;
@ -74,7 +80,7 @@ class StepFlowPickHandler extends LitElement {
protected render(): TemplateResult {
const handlers = this._getHandlers(
this.handlers,
this.filter,
this._filter,
this.hass.localize
);
@ -82,7 +88,7 @@ class StepFlowPickHandler extends LitElement {
<h2>${this.hass.localize("ui.panel.config.integrations.new")}</h2>
<search-input
autofocus
.filter=${this.filter}
.filter=${this._filter}
@value-changed=${this._filterChanged}
.label=${this.hass.localize("ui.panel.config.integrations.search")}
></search-input>
@ -164,15 +170,12 @@ class StepFlowPickHandler extends LitElement {
}
private async _filterChanged(e) {
this.filter = e.detail.value;
this._filter = e.detail.value;
}
private async _handlerPicked(ev) {
fireEvent(this, "flow-update", {
stepPromise: this.flowConfig.createFlow(
this.hass,
ev.currentTarget.handler.slug
),
fireEvent(this, "handler-picked", {
handler: ev.currentTarget.handler.slug,
});
}
@ -195,6 +198,9 @@ class StepFlowPickHandler extends LitElement {
overflow: auto;
max-height: 600px;
}
h2 {
padding-right: 66px;
}
@media all and (max-height: 900px) {
div {
max-height: calc(100vh - 134px);

View File

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

View File

@ -15,7 +15,8 @@ export const demoConfig: HassConfig = {
time_zone: "America/Los_Angeles",
config_dir: "/config",
version: "DEMO",
whitelist_external_dirs: [],
allowlist_external_dirs: [],
allowlist_external_urls: [],
config_source: "storage",
safe_mode: false,
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>
body {
font-family: Roboto, sans-serif;

View File

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

View File

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

View File

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

View File

@ -1,30 +1,26 @@
import "@polymer/paper-input/paper-input";
import {
css,
CSSResult,
customElement,
internalProperty,
LitElement,
property,
PropertyValues,
query,
} from "lit-element";
import { html } from "lit-html";
import memoizeOne from "memoize-one";
import { any, assert, object, optional, string } from "superstruct";
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 type { PolymerChangedEvent } from "../../../../../polymer-types";
import type { HomeAssistant } from "../../../../../types";
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({
service: optional(string()),
entity_id: optional(EntityIdOrAll),
target: optional(any()),
data: optional(any()),
});
@ -34,36 +30,14 @@ export class HaServiceAction extends LitElement implements ActionElement {
@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() {
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) {
if (!changedProperties.has("action")) {
return;
@ -73,73 +47,42 @@ export class HaServiceAction extends LitElement implements ActionElement {
} catch (error) {
fireEvent(this, "ui-mode-not-available", error);
}
if (this._actionData && this._actionData !== this.action.data) {
if (this._yamlEditor) {
this._yamlEditor.setValue(this.action.data);
}
if (this.action.entity_id) {
this._action = {
...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() {
const { service, data, entity_id } = this.action;
const serviceData = this._getServiceData(service);
const entity = serviceData.find((attr) => attr.key === "entity_id");
return html`
<ha-service-picker
<ha-service-control
.narrow=${this.narrow}
.hass=${this.hass}
.value=${service}
@value-changed=${this._serviceChanged}
></ha-service-picker>
${entity
? 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>
.value=${this._action}
.showAdvanced=${this.hass.userData?.showAdvanced}
@value-changed=${this._actionChanged}
></ha-service-control>
`;
}
private _dataChanged(ev: CustomEvent): void {
ev.stopPropagation();
if (!ev.detail.isValid) {
return;
private _actionChanged(ev) {
if (ev.detail.value === this._action) {
ev.stopPropagation();
}
this._actionData = ev.detail.value;
handleChangeEvent(this, ev);
}
private _serviceChanged(ev: PolymerChangedEvent<string>) {
ev.stopPropagation();
if (ev.detail.value === this.action.service) {
return;
}
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 },
});
static get styles(): CSSResult {
return css`
ha-service-control {
display: block;
margin: 0 -16px;
}
`;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -189,7 +189,7 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
>
${this._config
? html`
<ha-config-section .isWide=${this.isWide}>
<ha-config-section vertical .isWide=${this.isWide}>
${!this.narrow
? html`
<span slot="header">${this._config.alias}</span>
@ -313,7 +313,7 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
</ha-card>
</ha-config-section>
<ha-config-section .isWide=${this.isWide}>
<ha-config-section vertical .isWide=${this.isWide}>
<span slot="header">
${this.hass.localize(
"ui.panel.config.script.editor.sequence"
@ -350,7 +350,7 @@ export class HaScriptEditor extends KeyboardShortcutMixin(LitElement) {
`
: this._mode === "yaml"
? html`
<ha-config-section .isWide=${false}>
<ha-config-section vertical .isWide=${false}>
${!this.narrow
? 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;
color: var(--paper-item-icon-color, #44739e);
--mdc-icon-size: 100%;
margin-bottom: 8px;
}
ha-icon,

View File

@ -130,7 +130,12 @@ export const handleAction = async (
return;
}
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");
break;
}

View File

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

View File

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

View File

@ -120,20 +120,27 @@ const actionConfigStructConfirmation = union([
const actionConfigStructUrl = object({
action: literal("url"),
url_path: string(),
url_path: optional(string()),
confirmation: optional(actionConfigStructConfirmation),
});
const actionConfigStructService = object({
action: literal("call-service"),
service: string(),
service: optional(string()),
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),
});
const actionConfigStructNavigate = object({
action: literal("navigate"),
navigation_path: string(),
navigation_path: optional(string()),
confirmation: optional(actionConfigStructConfirmation),
});

View File

@ -13,22 +13,28 @@ import {
extractSearchParamsObject,
} from "../../common/url/search-params";
import "../../layouts/hass-error-screen";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { domainToName } from "../../data/integration";
const REDIRECTS = {
info: {
redirect: "/config/info",
const REDIRECTS: Redirects = {
developer_states: {
redirect: "/developer-tools/state",
},
logs: {
redirect: "/config/logs",
developer_services: {
redirect: "/developer-tools/service",
},
profile: {
redirect: "/profile/dashboard",
developer_template: {
redirect: "/developer-tools/template",
},
blueprint_import: {
redirect: "/config/blueprint/dashboard/import",
params: {
blueprint_url: "url",
},
developer_events: {
redirect: "/developer-tools/event",
},
cloud: {
component: "cloud",
redirect: "/config/cloud",
},
integrations: {
redirect: "/config/integrations",
},
config_flow_start: {
redirect: "/config/integrations/add",
@ -36,12 +42,80 @@ const REDIRECTS = {
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;
component?: string;
params?: {
[key: string]: ParamType;
};
@ -53,24 +127,37 @@ class HaPanelMy extends LitElement {
@property() public route!: Route;
@internalProperty() public _error = "";
@internalProperty() public _error?: string;
connectedCallback() {
super.connectedCallback();
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) {
this._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._error = "not_supported";
return;
}
if (
redirect.component &&
!isComponentLoaded(this.hass, redirect.component)
) {
this._error = "no_component";
return;
}
@ -78,7 +165,7 @@ class HaPanelMy extends LitElement {
try {
url = this._createRedirectUrl(redirect);
} catch (err) {
this._error = this.hass.localize("ui.panel.my.error");
this._error = "url_error";
return;
}
@ -87,9 +174,44 @@ class HaPanelMy extends LitElement {
protected render() {
if (this._error) {
return html`<hass-error-screen
.error=${this._error}
></hass-error-screen>`;
let error = "Unknown error";
switch (this._error) {
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``;
}

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 */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../components/entity/state-info";
import "../components/ha-slider";
class StateCardNumber extends mixinBehaviors(
[IronResizableBehavior],
@ -16,9 +15,6 @@ class StateCardNumber extends mixinBehaviors(
return html`
<style include="iron-flex iron-flex-alignment"></style>
<style>
ha-slider {
margin-left: auto;
}
.state {
@apply --paper-font-body1;
color: var(--primary-text-color);
@ -26,12 +22,6 @@ class StateCardNumber extends mixinBehaviors(
text-align: right;
line-height: 40px;
}
.sliderstate {
min-width: 45px;
}
ha-slider[hidden] {
display: none !important;
}
paper-input {
text-align: right;
margin-left: auto;
@ -40,19 +30,6 @@ class StateCardNumber extends mixinBehaviors(
<div class="horizontal justified layout" id="number_card">
${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
no-label-float=""
auto-validate=""
@ -64,19 +41,11 @@ class StateCardNumber extends mixinBehaviors(
type="number"
on-change="selectedValueChanged"
on-click="stopPropagation"
hidden="[[hiddenbox]]"
>
</paper-input>
<div class="state" hidden="[[hiddenbox]]">
<div class="state">
[[stateObj.attributes.unit_of_measurement]]
</div>
<div
id="sliderstate"
class="state sliderstate"
hidden="[[hiddenslider]]"
>
[[value]] [[stateObj.attributes.unit_of_measurement]]
</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() {
return {
hass: Object,
hiddenbox: {
type: Boolean,
value: true,
},
hiddenslider: {
type: Boolean,
value: true,
},
inDialog: {
type: Boolean,
value: false,
@ -138,35 +85,17 @@ class StateCardNumber extends mixinBehaviors(
},
step: 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) {
const prevMode = this.mode;
this.setProperties({
min: Number(newVal.attributes.min),
max: Number(newVal.attributes.max),
step: Number(newVal.attributes.step),
value: Number(newVal.state),
mode: String(newVal.attributes.mode),
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() {

View File

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

View File

@ -51,6 +51,17 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
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() {
super.hassConnected();
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;
// 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(
panelComponent || panelUrl
)
? panelComponent || panelUrl
: !panelComponent
? "lovelace"
: undefined;
if (!fragment) {

View File

@ -424,6 +424,12 @@
"service-picker": {
"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": {
"no_related_found": "No related items found.",
"integration": "Integration",
@ -806,6 +812,7 @@
"panel": {
"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.",
"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",
"error": "An unknown error occured"
},
@ -1400,8 +1407,7 @@
"type_select": "Action type",
"type": {
"service": {
"label": "Call service",
"service_data": "Service data"
"label": "Call service"
},
"delay": {
"label": "Delay",
@ -1424,7 +1430,7 @@
"event": {
"label": "Fire 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": {
"label": "Device",
@ -2068,7 +2074,13 @@
"description": "This step requires you to visit an external website to be completed.",
"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": {
@ -2693,7 +2705,6 @@
"action-editor": {
"navigation_path": "Navigation Path",
"url_path": "URL Path",
"editor_service_data": "Service data can only be entered in the code editor",
"actions": {
"default_action": "Default Action",
"call-service": "Call Service",
@ -3272,16 +3283,16 @@
"services": {
"title": "Services",
"description": "The service dev tool allows you to call any available service in Home Assistant.",
"data": "Service Data (YAML, optional)",
"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_description": "Description",
"column_example": "Example",
"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": {
"title": "States",

View File

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

View File

@ -1,20 +1,21 @@
import * as assert from "assert";
import { createHassioSession } from "../../src/data/hassio/ingress";
const sessionID = "fhdsu73rh3io4h8f3irhjel8ousafehf8f3yh";
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 () {
// @ts-ignore
global.document = {};
// @ts-ignore
global.location = {};
await createHassioSession({
// @ts-ignore
callApi: async function () {
return { data: { session: sessionID } };
},
});
// @ts-ignore
await createHassioSession(hass);
assert.strictEqual(
// @ts-ignore
global.document.cookie,
@ -26,12 +27,8 @@ describe("Create hassio session", function () {
global.document = {};
// @ts-ignore
global.location = { protocol: "https:" };
await createHassioSession({
// @ts-ignore
callApi: async function () {
return { data: { session: sessionID } };
},
});
// @ts-ignore
await createHassioSession(hass);
assert.strictEqual(
// @ts-ignore
global.document.cookie,

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