diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 8948f51df5..4a67ddd5a0 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -30,7 +30,7 @@ jobs:
env:
CI: true
- name: Build resources
- run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-demos
+ run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
- name: Run eslint
run: yarn run lint:eslint
- name: Run tsc
diff --git a/.github/workflows/netflify.yml b/.github/workflows/netflify.yml
index 47f13fef80..51989057ba 100644
--- a/.github/workflows/netflify.yml
+++ b/.github/workflows/netflify.yml
@@ -15,5 +15,5 @@ jobs:
- name: Trigger Demo build
run: curl -X POST -d {} https://api.netlify.com/build_hooks/${{ secrets.NETLIFY_DEMO_DEV_BUILD_HOOK }}
- - name: Trigger Gallery build
- run: curl -X POST -d {} https://api.netlify.com/build_hooks/${{ secrets.NETLIFY_GALLERY_DEV_BUILD_HOOK }}
+ - name: Trigger Design build
+ run: curl -X POST -d "NIGHTLY" https://api.netlify.com/build_hooks/${{ secrets.NETLIFY_GALLERY_DEV_BUILD_HOOK }}
diff --git a/build-scripts/gulp/clean.js b/build-scripts/gulp/clean.js
index 2b184d5e63..7bc0ed9e83 100644
--- a/build-scripts/gulp/clean.js
+++ b/build-scripts/gulp/clean.js
@@ -31,6 +31,6 @@ gulp.task("clean-hassio", () =>
gulp.task(
"clean-gallery",
gulp.parallel("clean-translations", () =>
- del([paths.gallery_output_root, paths.build_dir])
+ del([paths.gallery_output_root, paths.gallery_build, paths.build_dir])
)
);
diff --git a/build-scripts/gulp/gallery.js b/build-scripts/gulp/gallery.js
index 2823ad70b1..fcca2fc26e 100644
--- a/build-scripts/gulp/gallery.js
+++ b/build-scripts/gulp/gallery.js
@@ -1,7 +1,11 @@
+/* eslint-disable */
// Run demo develop mode
const gulp = require("gulp");
const fs = require("fs");
const path = require("path");
+const marked = require("marked");
+const glob = require("glob");
+const yaml = require("js-yaml");
const env = require("../env");
const paths = require("../paths");
@@ -15,26 +19,129 @@ require("./service-worker.js");
require("./entry-html.js");
require("./rollup.js");
-gulp.task("gather-gallery-demos", async function gatherDemos() {
- const files = await fs.promises.readdir(
- path.resolve(paths.gallery_dir, "src/demos")
- );
-
- let content = "export const DEMOS = {\n";
-
- for (const file of files) {
- const demoId = path.basename(file, ".ts");
- const demoPath = "../src/demos/" + demoId;
- content += ` "${demoId}": () => import("${demoPath}"),\n`;
- }
-
- content += "};";
+gulp.task("gather-gallery-pages", async function gatherPages() {
+ const pageDir = path.resolve(paths.gallery_dir, "src/pages");
+ const files = glob.sync(path.resolve(pageDir, "**/*"));
const galleryBuild = path.resolve(paths.gallery_dir, "build");
-
fs.mkdirSync(galleryBuild, { recursive: true });
+
+ let content = "export const PAGES = {\n";
+
+ const processed = new Set();
+
+ for (const file of files) {
+ if (fs.lstatSync(file).isDirectory()) {
+ continue;
+ }
+ const pageId = file.substring(pageDir.length + 1, file.lastIndexOf("."));
+
+ if (processed.has(pageId)) {
+ continue;
+ }
+ processed.add(pageId);
+
+ const [category, name] = pageId.split("/", 2);
+
+ const demoFile = path.resolve(pageDir, `${pageId}.ts`);
+ const descriptionFile = path.resolve(pageDir, `${pageId}.markdown`);
+ const hasDemo = fs.existsSync(demoFile);
+ let hasDescription = fs.existsSync(descriptionFile);
+ let metadata = {};
+ if (hasDescription) {
+ let descriptionContent = fs.readFileSync(descriptionFile, "utf-8");
+
+ if (descriptionContent.startsWith("---")) {
+ const metadataEnd = descriptionContent.indexOf("---", 3);
+ metadata = yaml.load(descriptionContent.substring(3, metadataEnd));
+ descriptionContent = descriptionContent
+ .substring(metadataEnd + 3)
+ .trim();
+ }
+
+ // If description is just metadata
+ if (descriptionContent === "") {
+ hasDescription = false;
+ } else {
+ descriptionContent = marked(descriptionContent).replace(/`/g, "\\`");
+ fs.mkdirSync(path.resolve(galleryBuild, category), { recursive: true });
+ fs.writeFileSync(
+ path.resolve(galleryBuild, `${pageId}-description.ts`),
+ `
+ import {html} from "lit";
+ export default html\`${descriptionContent}\`
+ `
+ );
+ }
+ }
+ content += ` "${pageId}": {
+ metadata: ${JSON.stringify(metadata)},
+ ${
+ hasDescription
+ ? `description: () => import("./${pageId}-description").then(m => m.default),`
+ : ""
+ }
+ ${hasDemo ? `demo: () => import("../src/pages/${pageId}")` : ""}
+
+ },\n`;
+ }
+
+ content += "};\n";
+
+ // Generate sidebar
+ const sidebarPath = path.resolve(paths.gallery_dir, "sidebar.js");
+ // To make watch work during development
+ delete require.cache[sidebarPath];
+ const sidebar = require(sidebarPath);
+
+ const pagesToProcess = {};
+ for (const key of processed) {
+ const [category, page] = key.split("/", 2);
+ if (!(category in pagesToProcess)) {
+ pagesToProcess[category] = new Set();
+ }
+ pagesToProcess[category].add(page);
+ }
+
+ for (const group of Object.values(sidebar)) {
+ const toProcess = pagesToProcess[group.category];
+ delete pagesToProcess[group.category];
+
+ if (!toProcess) {
+ console.error("Unknown category", group.category);
+ if (!group.pages) {
+ group.pages = [];
+ }
+ continue;
+ }
+
+ // Any pre-defined groups will not be sorted.
+ if (group.pages) {
+ for (const page of group.pages) {
+ if (!toProcess.delete(page)) {
+ console.error("Found unreferenced demo", page);
+ }
+ }
+ } else {
+ group.pages = [];
+ }
+ for (const page of Array.from(toProcess).sort()) {
+ group.pages.push(page);
+ }
+ }
+
+ for (const [category, pages] of Object.entries(pagesToProcess)) {
+ sidebar.push({
+ category,
+ header: category,
+ pages: Array.from(pages).sort(),
+ });
+ }
+
+ content += `export const SIDEBAR = ${JSON.stringify(sidebar, null, 2)};\n`;
+
fs.writeFileSync(
- path.resolve(galleryBuild, "import-demos.ts"),
+ path.resolve(galleryBuild, "import-pages.ts"),
content,
"utf-8"
);
@@ -52,11 +159,24 @@ gulp.task(
"gen-icons-json",
"build-translations",
"build-locale-data",
- "gather-gallery-demos"
+ "gather-gallery-pages"
),
"copy-static-gallery",
"gen-index-gallery-dev",
- env.useRollup() ? "rollup-dev-server-gallery" : "webpack-dev-server-gallery"
+ gulp.parallel(
+ env.useRollup()
+ ? "rollup-dev-server-gallery"
+ : "webpack-dev-server-gallery",
+ async function watchMarkdownFiles() {
+ gulp.watch(
+ [
+ path.resolve(paths.gallery_dir, "src/pages/**/*.markdown"),
+ path.resolve(paths.gallery_dir, "sidebar.js"),
+ ],
+ gulp.series("gather-gallery-pages")
+ );
+ }
+ )
)
);
@@ -72,7 +192,7 @@ gulp.task(
"gen-icons-json",
"build-translations",
"build-locale-data",
- "gather-gallery-demos"
+ "gather-gallery-pages"
),
"copy-static-gallery",
env.useRollup() ? "rollup-prod-gallery" : "webpack-prod-gallery",
diff --git a/build-scripts/paths.js b/build-scripts/paths.js
index d89f5f582a..241c7669dd 100644
--- a/build-scripts/paths.js
+++ b/build-scripts/paths.js
@@ -26,6 +26,7 @@ module.exports = {
cast_output_es5: path.resolve(__dirname, "../cast/dist/frontend_es5"),
gallery_dir: path.resolve(__dirname, "../gallery"),
+ gallery_build: path.resolve(__dirname, "../gallery/build"),
gallery_output_root: path.resolve(__dirname, "../gallery/dist"),
gallery_output_latest: path.resolve(
__dirname,
diff --git a/demo/src/stubs/energy.ts b/demo/src/stubs/energy.ts
index a43eac5c78..06544bcc37 100644
--- a/demo/src/stubs/energy.ts
+++ b/demo/src/stubs/energy.ts
@@ -83,7 +83,7 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
}));
hass.mockWS("energy/info", () => ({ cost_sensors: [] }));
hass.mockWS("energy/fossil_energy_consumption", ({ period }) => ({
- start: period === "month" ? 500 : period === "day" ? 20 : 5,
+ start: period === "month" ? 250 : period === "day" ? 10 : 2,
}));
const todayString = format(startOfToday(), "yyyy-MM-dd");
const tomorrowString = format(startOfTomorrow(), "yyyy-MM-dd");
diff --git a/gallery/script/netlify_build_gallery b/gallery/script/netlify_build_gallery
index 173b77d73f..a5732d4c83 100755
--- a/gallery/script/netlify_build_gallery
+++ b/gallery/script/netlify_build_gallery
@@ -1,6 +1,6 @@
#!/bin/bash
-TARGET_LABEL="Needs gallery preview"
+TARGET_LABEL="needs design preview"
if [[ "$NETLIFY" != "true" ]]; then
echo "This script can only be run on Netlify"
@@ -13,16 +13,14 @@ function createStatus() {
target_url="$3"
curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/home-assistant/frontend/statuses/$COMMIT_REF" \
- -d '{"state": "'"${state}"'", "context": "Netlify/Gallery Preview Build", "description": "'"$description"'", "target_url": "'"$target_url"'"}'
+ -d '{"state": "'"${state}"'", "context": "Netlify/Design Preview Build", "description": "'"$description"'", "target_url": "'"$target_url"'"}'
}
-if [[ "${PULL_REQUEST}" == "false" ]]; then
- gulp build-gallery
-else
+if [[ "${PULL_REQUEST}" == "true" ]]; then
if [[ "$(curl -sSLf -H "Accept: application/vnd.github.v3+json" -H "Authorization: token $GITHUB_TOKEN" \
"https://api.github.com/repos/home-assistant/frontend/pulls/${REVIEW_ID}" | jq '.labels[].name' -r)" =~ "$TARGET_LABEL" ]]; then
- createStatus "pending" "Building gallery preview" "https://app.netlify.com/sites/home-assistant-gallery/deploys/$BUILD_ID"
+ createStatus "pending" "Building design preview" "https://app.netlify.com/sites/home-assistant-gallery/deploys/$BUILD_ID"
gulp build-gallery
if [ $? -eq 0 ]; then
createStatus "success" "Build complete" "$DEPLOY_URL"
@@ -32,4 +30,6 @@ else
else
createStatus "success" "Build was not requested by PR label"
fi
+elif [[ "$INCOMING_HOOK_BODY" == "NIGHTLY" ]]; then
+ gulp build-gallery
fi
diff --git a/gallery/sidebar.js b/gallery/sidebar.js
new file mode 100644
index 0000000000..84bd8f8eff
--- /dev/null
+++ b/gallery/sidebar.js
@@ -0,0 +1,48 @@
+module.exports = [
+ {
+ // This section has no header and so all page links are shown directly in the sidebar
+ category: "concepts",
+ pages: ["home"],
+ },
+
+ {
+ category: "lovelace",
+ // Label for in the sidebar
+ header: "Lovelace",
+ // Specify order of pages. Any pages in the category folder but not listed here will
+ // automatically be added after the pages listed here.
+ pages: ["introduction"],
+ },
+ {
+ category: "automation",
+ header: "Automation",
+ pages: [
+ "editor-trigger",
+ "editor-condition",
+ "editor-action",
+ "selectors",
+ "trace",
+ "trace-timeline",
+ ],
+ },
+ {
+ category: "components",
+ header: "Components",
+ },
+ {
+ category: "more-info",
+ header: "More Info dialogs",
+ },
+ {
+ category: "misc",
+ header: "Miscelaneous",
+ },
+ {
+ category: "user-test",
+ header: "User Tests",
+ },
+ {
+ category: "design.home-assistant.io",
+ header: "Design Documentation",
+ },
+];
diff --git a/gallery/src/components/demo-card.js b/gallery/src/components/demo-card.js
deleted file mode 100644
index 4d9ce53899..0000000000
--- a/gallery/src/components/demo-card.js
+++ /dev/null
@@ -1,129 +0,0 @@
-import { html } from "@polymer/polymer/lib/utils/html-tag";
-/* eslint-plugin-disable lit */
-import { PolymerElement } from "@polymer/polymer/polymer-element";
-import { load } from "js-yaml";
-import { createCardElement } from "../../../src/panels/lovelace/create-element/create-card-element";
-
-class DemoCard extends PolymerElement {
- static get template() {
- return html`
-
-
- [[config.heading]]
-
- (size [[_size]])
-
-
-
-
-
- [[_trim(config.config)]]
-
-
- `;
- }
-
- static get properties() {
- return {
- hass: {
- type: Object,
- observer: "_hassChanged",
- },
- config: {
- type: Object,
- observer: "_configChanged",
- },
- showConfig: Boolean,
- _size: {
- type: Number,
- },
- };
- }
-
- ready() {
- super.ready();
- }
-
- _configChanged(config) {
- const card = this.$.card;
- while (card.lastChild) {
- card.removeChild(card.lastChild);
- }
-
- const el = this._createCardElement(load(config.config)[0]);
- card.appendChild(el);
- this._getSize(el);
- }
-
- async _getSize(el) {
- await customElements.whenDefined(el.localName);
-
- if (!("getCardSize" in el)) {
- this._size = undefined;
- return;
- }
- this._size = await el.getCardSize();
- }
-
- _createCardElement(cardConfig) {
- const element = createCardElement(cardConfig);
- if (this.hass) {
- element.hass = this.hass;
- }
- element.addEventListener(
- "ll-rebuild",
- (ev) => {
- ev.stopPropagation();
- this._rebuildCard(element, cardConfig);
- },
- { once: true }
- );
- return element;
- }
-
- _rebuildCard(cardElToReplace, config) {
- const newCardEl = this._createCardElement(config);
- cardElToReplace.parentElement.replaceChild(newCardEl, cardElToReplace);
- }
-
- _hassChanged(hass) {
- const card = this.$.card.lastChild;
- if (card) card.hass = hass;
- }
-
- _trim(config) {
- return config.trim();
- }
-}
-
-customElements.define("demo-card", DemoCard);
diff --git a/gallery/src/components/demo-card.ts b/gallery/src/components/demo-card.ts
new file mode 100644
index 0000000000..1c08792952
--- /dev/null
+++ b/gallery/src/components/demo-card.ts
@@ -0,0 +1,129 @@
+import { load } from "js-yaml";
+import { html, css, LitElement, PropertyValues } from "lit";
+import { customElement, property, query, state } from "lit/decorators";
+import { createCardElement } from "../../../src/panels/lovelace/create-element/create-card-element";
+import { HomeAssistant } from "../../../src/types";
+
+export interface DemoCardConfig {
+ heading: string;
+ config: string;
+}
+
+@customElement("demo-card")
+class DemoCard extends LitElement {
+ @property() public hass!: HomeAssistant;
+
+ @property() public config!: DemoCardConfig;
+
+ @property() public showConfig = false;
+
+ @state() private _size?: number;
+
+ @query("#card") private _card!: HTMLElement;
+
+ render() {
+ return html`
+
+ ${this.config.heading}
+ ${this._size !== undefined
+ ? html`(size ${this._size}) `
+ : ""}
+
+
+
+ ${this.showConfig ? html`
${this.config.config.trim()} ` : ""}
+
+ `;
+ }
+
+ updated(changedProps: PropertyValues) {
+ super.updated(changedProps);
+
+ if (changedProps.has("config")) {
+ const card = this._card;
+ while (card.lastChild) {
+ card.removeChild(card.lastChild);
+ }
+
+ const el = this._createCardElement((load(this.config.config) as any)[0]);
+ card.appendChild(el);
+ this._getSize(el);
+ }
+
+ if (changedProps.has("hass")) {
+ const card = this._card.lastChild;
+ if (card) {
+ (card as any).hass = this.hass;
+ }
+ }
+ }
+
+ async _getSize(el) {
+ await customElements.whenDefined(el.localName);
+
+ if (!("getCardSize" in el)) {
+ this._size = undefined;
+ return;
+ }
+ this._size = await el.getCardSize();
+ }
+
+ _createCardElement(cardConfig) {
+ const element = createCardElement(cardConfig);
+ if (this.hass) {
+ element.hass = this.hass;
+ }
+ element.addEventListener(
+ "ll-rebuild",
+ (ev) => {
+ ev.stopPropagation();
+ this._rebuildCard(element, cardConfig);
+ },
+ { once: true }
+ );
+ return element;
+ }
+
+ _rebuildCard(cardElToReplace, config) {
+ const newCardEl = this._createCardElement(config);
+ cardElToReplace.parentElement.replaceChild(newCardEl, cardElToReplace);
+ }
+
+ static styles = css`
+ .root {
+ display: flex;
+ }
+ h2 {
+ margin: 0 0 20px;
+ color: var(--primary-color);
+ }
+ h2 small {
+ font-size: 0.5em;
+ color: var(--primary-text-color);
+ }
+ #card {
+ max-width: 400px;
+ width: 100vw;
+ }
+ pre {
+ width: 400px;
+ margin: 0 16px;
+ overflow: auto;
+ color: var(--primary-text-color);
+ }
+ @media only screen and (max-width: 800px) {
+ .root {
+ flex-direction: column;
+ }
+ pre {
+ margin: 16px 0;
+ }
+ }
+ `;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "demo-card": DemoCard;
+ }
+}
diff --git a/gallery/src/components/demo-cards.js b/gallery/src/components/demo-cards.js
deleted file mode 100644
index 59faa671a6..0000000000
--- a/gallery/src/components/demo-cards.js
+++ /dev/null
@@ -1,83 +0,0 @@
-import "@polymer/app-layout/app-toolbar/app-toolbar";
-import { html } from "@polymer/polymer/lib/utils/html-tag";
-/* eslint-plugin-disable lit */
-import { PolymerElement } from "@polymer/polymer/polymer-element";
-import { applyThemesOnElement } from "../../../src/common/dom/apply_themes_on_element";
-import "../../../src/components/ha-formfield";
-import "../../../src/components/ha-switch";
-import "./demo-card";
-
-class DemoCards extends PolymerElement {
- static get template() {
- return html`
-
-
-
-
-
-
-
-
-
-
-
-
-
- `;
- }
-
- static get properties() {
- return {
- configs: Object,
- hass: Object,
- _showConfig: {
- type: Boolean,
- value: false,
- },
- };
- }
-
- _showConfigToggled(ev) {
- this._showConfig = ev.target.checked;
- }
-
- _darkThemeToggled(ev) {
- applyThemesOnElement(this.$.container, { themes: {} }, "default", {
- dark: ev.target.checked,
- });
- }
-}
-
-customElements.define("demo-cards", DemoCards);
diff --git a/gallery/src/components/demo-cards.ts b/gallery/src/components/demo-cards.ts
new file mode 100644
index 0000000000..fa91ca2097
--- /dev/null
+++ b/gallery/src/components/demo-cards.ts
@@ -0,0 +1,88 @@
+import "@polymer/app-layout/app-toolbar/app-toolbar";
+import { html, css, LitElement } from "lit";
+import { customElement, property, query, state } from "lit/decorators";
+import { applyThemesOnElement } from "../../../src/common/dom/apply_themes_on_element";
+import "../../../src/components/ha-formfield";
+import "../../../src/components/ha-switch";
+import { HomeAssistant } from "../../../src/types";
+import "./demo-card";
+import type { DemoCardConfig } from "./demo-card";
+
+@customElement("demo-cards")
+class DemoCards extends LitElement {
+ @property() public configs!: DemoCardConfig[];
+
+ @property() public hass!: HomeAssistant;
+
+ @state() private _showConfig = false;
+
+ @query("#container") private _container!: HTMLElement;
+
+ render() {
+ return html`
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${this.configs.map(
+ (config) => html`
+
+ `
+ )}
+
+
+ `;
+ }
+
+ _showConfigToggled(ev) {
+ this._showConfig = ev.target.checked;
+ }
+
+ _darkThemeToggled(ev) {
+ applyThemesOnElement(this._container, { themes: {} } as any, "default", {
+ dark: ev.target.checked,
+ });
+ }
+
+ static styles = css`
+ .cards {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ }
+ demo-card {
+ margin: 16px 16px 32px;
+ }
+ app-toolbar {
+ background-color: var(--light-primary-color);
+ }
+ .filters {
+ margin-left: 60px;
+ }
+ ha-formfield {
+ margin-right: 16px;
+ }
+ `;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "demo-cards": DemoCards;
+ }
+}
diff --git a/gallery/src/components/page-description.ts b/gallery/src/components/page-description.ts
new file mode 100644
index 0000000000..8e449195a6
--- /dev/null
+++ b/gallery/src/components/page-description.ts
@@ -0,0 +1,46 @@
+import { html, css } from "lit";
+import { customElement, property } from "lit/decorators";
+import { until } from "lit/directives/until";
+import { HaMarkdown } from "../../../src/components/ha-markdown";
+import { PAGES } from "../../build/import-pages";
+
+@customElement("page-description")
+class PageDescription extends HaMarkdown {
+ @property() public page!: string;
+
+ render() {
+ if (!PAGES[this.page].description) {
+ return html``;
+ }
+ return html`
+ ${until(
+ PAGES[this.page]
+ .description()
+ .then((content) => html`${content}
`),
+ ""
+ )}
+ `;
+ }
+
+ static styles = [
+ HaMarkdown.styles,
+ css`
+ .root {
+ max-width: 800px;
+ margin: 0 auto;
+ }
+ .root > *:first-child {
+ margin-top: 0;
+ }
+ .root > *:last-child {
+ margin-bottom: 0;
+ }
+ `,
+ ];
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "page-description": PageDescription;
+ }
+}
diff --git a/gallery/src/ha-gallery.js b/gallery/src/ha-gallery.js
deleted file mode 100644
index 671cbe9077..0000000000
--- a/gallery/src/ha-gallery.js
+++ /dev/null
@@ -1,225 +0,0 @@
-import "@polymer/app-layout/app-header-layout/app-header-layout";
-import "@polymer/app-layout/app-header/app-header";
-import "@polymer/app-layout/app-toolbar/app-toolbar";
-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 "../../src/components/ha-card";
-import "../../src/components/ha-icon";
-import "../../src/components/ha-icon-button";
-import "../../src/managers/notification-manager";
-import "../../src/styles/polymer-ha-style";
-// eslint-disable-next-line import/extensions
-import { DEMOS } from "../build/import-demos";
-
-class HaGallery extends PolymerElement {
- static get template() {
- return html`
-
-
-
-
-
-
-
-
-
- [[_withDefault(_demo, "Home Assistant Gallery")]]
-
-
-
-
-
-
-
-
-
-
-
- Lovelace has many different cards. Each card allows the user
- to tell a different story about what is going on in their
- house. These cards are very customizable, as no household is
- the same.
-
-
-
- This gallery helps our developers and designers to see all
- the different states that each card can be in.
-
-
-
- Check
- the official website
- for instructions on how to get started with Lovelace.
-
-
-
-
-
- {{ item }}
-
-
-
-
-
-
-
-
-
-
-
- {{ item }}
-
-
-
-
-
-
-
-
-
-
- `;
- }
-
- static get properties() {
- return {
- _fakeHass: {
- type: Object,
- // Just enough for computeRTL
- value: {
- language: "en",
- translationMetadata: {
- translations: {},
- },
- },
- },
- _demo: {
- type: String,
- value: document.location.hash.substr(1),
- observer: "_demoChanged",
- },
- _demos: {
- type: Array,
- value: Object.keys(DEMOS),
- },
- _lovelaceDemos: {
- type: Array,
- computed: "_computeLovelace(_demos)",
- },
- _restDemos: {
- type: Array,
- computed: "_computeRest(_demos)",
- },
- };
- }
-
- ready() {
- super.ready();
-
- this.addEventListener("show-notification", (ev) =>
- this.$.notifications.showDialog({ message: ev.detail.message })
- );
-
- this.addEventListener("alert-dismissed-clicked", () =>
- this.$.notifications.showDialog({ message: "Alert dismissed clicked" })
- );
- this.addEventListener("hass-more-info", (ev) => {
- if (ev.detail.entityId) {
- this.$.notifications.showDialog({
- message: `Showing more info for ${ev.detail.entityId}`,
- });
- }
- });
-
- window.addEventListener("hashchange", () => {
- this._demo = document.location.hash.substr(1);
- });
- }
-
- _withDefault(value, def) {
- return value || def;
- }
-
- _demoChanged(demo) {
- const root = this.$.demo;
-
- while (root.lastChild) root.removeChild(root.lastChild);
-
- if (demo) {
- DEMOS[demo]();
- const el = document.createElement(demo);
- root.appendChild(el);
- }
- }
-
- _computeHeaderButtonClass(demo) {
- return demo ? "" : "invisible";
- }
-
- _backTapped() {
- document.location.hash = "";
- }
-
- _computeLovelace(demos) {
- return demos.filter((demo) => demo.includes("hui"));
- }
-
- _computeRest(demos) {
- return demos.filter((demo) => !demo.includes("hui"));
- }
-}
-
-customElements.define("ha-gallery", HaGallery);
diff --git a/gallery/src/ha-gallery.ts b/gallery/src/ha-gallery.ts
new file mode 100644
index 0000000000..86e64e8d35
--- /dev/null
+++ b/gallery/src/ha-gallery.ts
@@ -0,0 +1,257 @@
+import { mdiMenu } from "@mdi/js";
+import "@material/mwc-drawer";
+import "@material/mwc-top-app-bar-fixed";
+import { html, css, LitElement, PropertyValues } from "lit";
+import { customElement, property, query } from "lit/decorators";
+import "../../src/components/ha-icon-button";
+import "../../src/managers/notification-manager";
+import { haStyle } from "../../src/resources/styles";
+import { PAGES, SIDEBAR } from "../build/import-pages";
+import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
+import "./components/page-description";
+
+const GITHUB_DEMO_URL =
+ "https://github.com/home-assistant/frontend/blob/dev/gallery/src/pages/";
+
+const FAKE_HASS = {
+ // Just enough for computeRTL for notification-manager
+ language: "en",
+ translationMetadata: {
+ translations: {},
+ },
+};
+
+@customElement("ha-gallery")
+class HaGallery extends LitElement {
+ @property() private _page =
+ document.location.hash.substring(1) ||
+ `${SIDEBAR[0].category}/${SIDEBAR[0].pages![0]}`;
+
+ @query("notification-manager")
+ private _notifications!: HTMLElementTagNameMap["notification-manager"];
+
+ @query("mwc-drawer")
+ private _drawer!: HTMLElementTagNameMap["mwc-drawer"];
+
+ private _narrow = window.matchMedia("(max-width: 600px)").matches;
+
+ render() {
+ const sidebar: unknown[] = [];
+
+ for (const group of SIDEBAR) {
+ const links: unknown[] = [];
+
+ for (const page of group.pages!) {
+ const key = `${group.category}/${page}`;
+ const active = this._page === key;
+ const title = PAGES[key].metadata.title || page;
+ links.push(html`
+ ${title}
+ `);
+ }
+
+ sidebar.push(
+ group.header
+ ? html`
+
+ ${group.header}
+ ${links}
+
+ `
+ : links
+ );
+ }
+
+ return html`
+
+ Home Assistant Design
+
+
+
+
+
+
+
+ ${PAGES[this._page].metadata.title || this._page.split("/")[1]}
+
+
+
+ ${PAGES[this._page].description
+ ? html`
+
+ `
+ : ""}
+ ${dynamicElement(`demo-${this._page.replace("/", "-")}`)}
+
+
+
+
+
+ `;
+ }
+
+ firstUpdated(changedProps: PropertyValues) {
+ super.firstUpdated(changedProps);
+
+ this.addEventListener("show-notification", (ev) =>
+ this._notifications.showDialog({ message: ev.detail.message })
+ );
+ this.addEventListener("alert-dismissed-clicked", () =>
+ this._notifications.showDialog({ message: "Alert dismissed clicked" })
+ );
+ this.addEventListener("hass-more-info", (ev) => {
+ if (ev.detail.entityId) {
+ this._notifications.showDialog({
+ message: `Showing more info for ${ev.detail.entityId}`,
+ });
+ }
+ });
+
+ document.location.hash = this._page;
+
+ window.addEventListener("hashchange", () => {
+ this._page = document.location.hash.substring(1);
+ if (this._narrow) {
+ this._drawer.open = false;
+ }
+ });
+ }
+
+ updated(changedProps: PropertyValues) {
+ super.updated(changedProps);
+ if (!changedProps.has("_page")) {
+ return;
+ }
+
+ if (PAGES[this._page].demo) {
+ PAGES[this._page].demo();
+ }
+
+ const menuItem = this.shadowRoot!.querySelector(
+ `a[href="#${this._page}"]`
+ )!;
+ // Make sure section is expanded
+ if (menuItem.parentElement instanceof HTMLDetailsElement) {
+ menuItem.parentElement.open = true;
+ }
+ }
+
+ _menuTapped() {
+ this._drawer.open = !this._drawer.open;
+ }
+
+ static styles = [
+ haStyle,
+ css`
+ :host {
+ -ms-user-select: initial;
+ -webkit-user-select: initial;
+ -moz-user-select: initial;
+ }
+
+ .sidebar {
+ padding: 4px;
+ }
+
+ .sidebar details {
+ margin-top: 1em;
+ margin-left: 1em;
+ }
+
+ .sidebar summary {
+ cursor: pointer;
+ font-weight: bold;
+ margin-bottom: 8px;
+ }
+
+ .sidebar a {
+ color: var(--primary-text-color);
+ display: block;
+ padding: 4px 12px;
+ text-decoration: none;
+ position: relative;
+ }
+
+ .sidebar a[active]::before {
+ border-radius: 4px;
+ position: absolute;
+ top: 0;
+ right: 2px;
+ bottom: 0;
+ left: 2px;
+ pointer-events: none;
+ content: "";
+ transition: opacity 15ms linear;
+ will-change: opacity;
+ background-color: var(--sidebar-selected-icon-color);
+ opacity: 0.12;
+ }
+
+ div[slot="appContent"] {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+ background: var(--primary-background-color);
+ }
+
+ .content {
+ flex: 1;
+ }
+
+ page-description {
+ margin: 16px;
+ }
+
+ .page-footer {
+ text-align: center;
+ margin: 16px 0;
+ padding-top: 16px;
+ border-top: 1px solid rgba(0, 0, 0, 0.12);
+ }
+
+ .page-footer a {
+ display: inline-block;
+ margin: 0 8px;
+ }
+ `,
+ ];
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-gallery": HaGallery;
+ }
+}
diff --git a/gallery/src/html/index.html.template b/gallery/src/html/index.html.template
index 3240e59bb0..05d6a4bb36 100644
--- a/gallery/src/html/index.html.template
+++ b/gallery/src/html/index.html.template
@@ -7,7 +7,7 @@
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
- HAGallery
+ Home Assistant Design
-
-
-
- [[value]] [[units]]
-
- `;
- }
-
- static get properties() {
- return {
- value: {
- type: Number,
- observer: "valueChanged",
- },
- units: {
- type: String,
- },
- min: {
- type: Number,
- },
- max: {
- type: Number,
- },
- step: {
- type: Number,
- value: 1,
- },
- };
- }
-
- temperatureStateInFlux(inFlux) {
- this.$.target_temperature.classList.toggle("in-flux", inFlux);
- }
-
- _round(val) {
- // round value to precision derived from step
- // insired by https://github.com/soundar24/roundSlider/blob/master/src/roundslider.js
- const s = this.step.toString().split(".");
- return s[1] ? parseFloat(val.toFixed(s[1].length)) : Math.round(val);
- }
-
- incrementValue() {
- const newval = this._round(this.value + this.step);
- if (this.value < this.max) {
- this.last_changed = Date.now();
- this.temperatureStateInFlux(true);
- }
- if (newval <= this.max) {
- // If no initial target_temp
- // this forces control to start
- // from the min configured instead of 0
- if (newval <= this.min) {
- this.value = this.min;
- } else {
- this.value = newval;
- }
- } else {
- this.value = this.max;
- }
- }
-
- decrementValue() {
- const newval = this._round(this.value - this.step);
- if (this.value > this.min) {
- this.last_changed = Date.now();
- this.temperatureStateInFlux(true);
- }
- if (newval >= this.min) {
- this.value = newval;
- } else {
- this.value = this.min;
- }
- }
-
- valueChanged() {
- // when the last_changed timestamp is changed,
- // trigger a potential event fire in
- // the future, as long as last changed is far enough in the
- // past.
- if (this.last_changed) {
- window.setTimeout(() => {
- const now = Date.now();
- if (now - this.last_changed >= 2000) {
- this.fire("change");
- this.temperatureStateInFlux(false);
- this.last_changed = null;
- }
- }, 2010);
- }
- }
-}
-
-customElements.define("ha-climate-control", HaClimateControl);
diff --git a/src/components/ha-climate-control.ts b/src/components/ha-climate-control.ts
new file mode 100644
index 0000000000..b57cbb5053
--- /dev/null
+++ b/src/components/ha-climate-control.ts
@@ -0,0 +1,138 @@
+import { mdiChevronDown, mdiChevronUp } from "@mdi/js";
+import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
+import { customElement, property, query } from "lit/decorators";
+import { fireEvent } from "../common/dom/fire_event";
+import { conditionalClamp } from "../common/number/clamp";
+import { HomeAssistant } from "../types";
+import "./ha-icon";
+import "./ha-icon-button";
+
+@customElement("ha-climate-control")
+class HaClimateControl extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @property() public value!: number;
+
+ @property() public unit = "";
+
+ @property() public min?: number;
+
+ @property() public max?: number;
+
+ @property() public step = 1;
+
+ private _lastChanged?: number;
+
+ @query("#target_temperature") private _targetTemperature!: HTMLElement;
+
+ protected render(): TemplateResult {
+ return html`
+ ${this.value} ${this.unit}
+
+ `;
+ }
+
+ protected updated(changedProperties) {
+ if (changedProperties.has("value")) {
+ this._valueChanged();
+ }
+ }
+
+ private _temperatureStateInFlux(inFlux) {
+ this._targetTemperature.classList.toggle("in-flux", inFlux);
+ }
+
+ private _round(value) {
+ // Round value to precision derived from step.
+ // Inspired by https://github.com/soundar24/roundSlider/blob/master/src/roundslider.js
+ const s = this.step.toString().split(".");
+ return s[1] ? parseFloat(value.toFixed(s[1].length)) : Math.round(value);
+ }
+
+ private _incrementValue() {
+ const newValue = this._round(this.value + this.step);
+ this._processNewValue(newValue);
+ }
+
+ private _decrementValue() {
+ const newValue = this._round(this.value - this.step);
+ this._processNewValue(newValue);
+ }
+
+ private _processNewValue(value) {
+ const newValue = conditionalClamp(value, this.min, this.max);
+
+ if (this.value !== newValue) {
+ this.value = newValue;
+ this._lastChanged = Date.now();
+ this._temperatureStateInFlux(true);
+ }
+ }
+
+ private _valueChanged() {
+ // When the last_changed timestamp is changed,
+ // trigger a potential event fire in the future,
+ // as long as last_changed is far enough in the past.
+ if (this._lastChanged) {
+ window.setTimeout(() => {
+ const now = Date.now();
+ if (now - this._lastChanged! >= 2000) {
+ fireEvent(this, "change");
+ this._temperatureStateInFlux(false);
+ this._lastChanged = undefined;
+ }
+ }, 2010);
+ }
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ :host {
+ display: flex;
+ justify-content: space-between;
+ }
+ .in-flux {
+ color: var(--error-color);
+ }
+ #target_temperature {
+ align-self: center;
+ font-size: 28px;
+ direction: ltr;
+ }
+ .control-buttons {
+ font-size: 24px;
+ text-align: right;
+ }
+ ha-icon-button {
+ --mdc-icon-size: 32px;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-climate-control": HaClimateControl;
+ }
+}
diff --git a/src/components/ha-form/ha-form-string.ts b/src/components/ha-form/ha-form-string.ts
index 5b40a1cfb7..fc787e4e63 100644
--- a/src/components/ha-form/ha-form-string.ts
+++ b/src/components/ha-form/ha-form-string.ts
@@ -68,7 +68,6 @@ export class HaFormString extends LitElement implements HaFormElement {
toggles
.label=${`${this._unmaskedPassword ? "Hide" : "Show"} password`}
@click=${this._toggleUnmaskedPassword}
- tabindex="-1"
.path=${this._unmaskedPassword ? mdiEyeOff : mdiEye}
>`
: ""}
diff --git a/src/components/ha-gauge.ts b/src/components/ha-gauge.ts
index 9ce1259669..c3374d6b97 100644
--- a/src/components/ha-gauge.ts
+++ b/src/components/ha-gauge.ts
@@ -1,16 +1,10 @@
import { css, LitElement, PropertyValues, svg, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
-import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { formatNumber } from "../common/number/format_number";
import { afterNextRender } from "../common/util/render-status";
import { FrontendLocaleData } from "../data/translation";
import { getValueInPercentage, normalize } from "../util/calculate";
-import { isSafari } from "../util/is_safari";
-
-// Safari version 15.2 and up behaves differently than other Safari versions.
-// https://github.com/home-assistant/frontend/issues/10766
-const isSafari152 = isSafari && /Version\/15\.[^0-1]/.test(navigator.userAgent);
const getAngle = (value: number, min: number, max: number) => {
const percentage = getValueInPercentage(normalize(value, min, max), min, max);
@@ -65,12 +59,12 @@ export class Gauge extends LitElement {
protected render() {
return svg`
-
+
${
!this.needle || !this.levels
? svg` `
: ""
}
@@ -87,9 +81,9 @@ export class Gauge extends LitElement {
stroke="var(--info-color)"
class="level"
d="M
- ${50 - 40 * Math.cos((angle * Math.PI) / 180)}
- ${50 - 40 * Math.sin((angle * Math.PI) / 180)}
- A 40 40 0 0 1 90 50
+ ${0 - 40 * Math.cos((angle * Math.PI) / 180)}
+ ${0 - 40 * Math.sin((angle * Math.PI) / 180)}
+ A 40 40 0 0 1 40 0
"
>`;
}
@@ -98,9 +92,9 @@ export class Gauge extends LitElement {
stroke="${level.stroke}"
class="level"
d="M
- ${50 - 40 * Math.cos((angle * Math.PI) / 180)}
- ${50 - 40 * Math.sin((angle * Math.PI) / 180)}
- A 40 40 0 0 1 90 50
+ ${0 - 40 * Math.cos((angle * Math.PI) / 180)}
+ ${0 - 40 * Math.sin((angle * Math.PI) / 180)}
+ A 40 40 0 0 1 40 0
"
>`;
})
@@ -110,46 +104,16 @@ export class Gauge extends LitElement {
this.needle
? svg`
`
: svg``
}
- ${
- // Workaround for https://github.com/home-assistant/frontend/issues/6467
- isSafari
- ? svg` `
- : ""
- }
@@ -187,12 +151,10 @@ export class Gauge extends LitElement {
fill: none;
stroke-width: 15;
stroke: var(--gauge-color);
- transform-origin: 50% 100%;
transition: all 1s ease 0s;
}
.needle {
fill: var(--primary-text-color);
- transform-origin: 50% 100%;
transition: all 1s ease 0s;
}
.level {
diff --git a/src/components/ha-hls-player.ts b/src/components/ha-hls-player.ts
index dbabf84b20..fd16491efa 100644
--- a/src/components/ha-hls-player.ts
+++ b/src/components/ha-hls-player.ts
@@ -9,7 +9,6 @@ import {
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { nextRender } from "../common/util/render-status";
-import { getExternalConfig } from "../external_app/external_config";
import type { HomeAssistant } from "../types";
import "./ha-alert";
@@ -48,8 +47,11 @@ class HaHLSPlayer extends LitElement {
private _exoPlayer = false;
+ private static streamCount = 0;
+
public connectedCallback() {
super.connectedCallback();
+ HaHLSPlayer.streamCount += 1;
if (this.hasUpdated) {
this._startHls();
}
@@ -57,6 +59,7 @@ class HaHLSPlayer extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
+ HaHLSPlayer.streamCount -= 1;
this._cleanUp();
}
@@ -87,19 +90,9 @@ class HaHLSPlayer extends LitElement {
this._startHls();
}
- private async _getUseExoPlayer(): Promise {
- if (!this.hass!.auth.external || !this.allowExoPlayer) {
- return false;
- }
- const externalConfig = await getExternalConfig(this.hass!.auth.external);
- return externalConfig && externalConfig.hasExoPlayer;
- }
-
private async _startHls(): Promise {
this._error = undefined;
- const videoEl = this._videoEl;
- const useExoPlayerPromise = this._getUseExoPlayer();
const masterPlaylistPromise = fetch(this.url);
const Hls: typeof HlsType = (await import("hls.js/dist/hls.light.min"))
@@ -113,7 +106,7 @@ class HaHLSPlayer extends LitElement {
if (!hlsSupported) {
hlsSupported =
- videoEl.canPlayType("application/vnd.apple.mpegurl") !== "";
+ this._videoEl.canPlayType("application/vnd.apple.mpegurl") !== "";
}
if (!hlsSupported) {
@@ -123,7 +116,8 @@ class HaHLSPlayer extends LitElement {
return;
}
- const useExoPlayer = await useExoPlayerPromise;
+ const useExoPlayer =
+ this.allowExoPlayer && this.hass.auth.external?.config.hasExoPlayer;
const masterPlaylist = await (await masterPlaylistPromise).text();
if (!this.isConnected) {
@@ -151,9 +145,9 @@ class HaHLSPlayer extends LitElement {
if (useExoPlayer && match !== null && match[1] !== undefined) {
this._renderHLSExoPlayer(playlist_url);
} else if (Hls.isSupported()) {
- this._renderHLSPolyfill(videoEl, Hls, playlist_url);
+ this._renderHLSPolyfill(this._videoEl, Hls, playlist_url);
} else {
- this._renderHLSNative(videoEl, playlist_url);
+ this._renderHLSNative(this._videoEl, playlist_url);
}
}
@@ -187,6 +181,28 @@ class HaHLSPlayer extends LitElement {
});
};
+ private _isLLHLSSupported(): boolean {
+ // LL-HLS keeps multiple requests in flight, which can run into browser limitations without
+ // an http/2 proxy to pipeline requests. However, a small number of streams active at
+ // once should be OK.
+ // The stream count may be incremented multiple times before this function is called to check
+ // the count e.g. when loading a page with many streams on it. The race can work in our favor
+ // so we now have a better idea on if we'll use too many browser connections later.
+ if (HaHLSPlayer.streamCount <= 2) {
+ return true;
+ }
+ if (
+ !("performance" in window) ||
+ performance.getEntriesByType("resource").length === 0
+ ) {
+ return false;
+ }
+ const perfEntry = performance.getEntriesByType(
+ "resource"
+ )[0] as PerformanceResourceTiming;
+ return "nextHopProtocol" in perfEntry && perfEntry.nextHopProtocol === "h2";
+ }
+
private async _renderHLSPolyfill(
videoEl: HTMLVideoElement,
Hls: typeof HlsType,
@@ -198,6 +214,7 @@ class HaHLSPlayer extends LitElement {
manifestLoadingTimeOut: 30000,
levelLoadingTimeOut: 30000,
maxLiveSyncPlaybackRate: 2,
+ lowLatencyMode: this._isLLHLSSupported(),
});
this._hlsPolyfillInstance = hls;
hls.attachMedia(videoEl);
@@ -261,9 +278,10 @@ class HaHLSPlayer extends LitElement {
this.hass!.auth.external!.fireMessage({ type: "exoplayer/stop" });
this._exoPlayer = false;
}
- const videoEl = this._videoEl;
- videoEl.removeAttribute("src");
- videoEl.load();
+ if (this._videoEl) {
+ this._videoEl.removeAttribute("src");
+ this._videoEl.load();
+ }
}
static get styles(): CSSResultGroup {
diff --git a/src/components/ha-markdown.ts b/src/components/ha-markdown.ts
index 0f192bac32..2b628c1e04 100644
--- a/src/components/ha-markdown.ts
+++ b/src/components/ha-markdown.ts
@@ -3,7 +3,7 @@ import { customElement, property } from "lit/decorators";
import "./ha-markdown-element";
@customElement("ha-markdown")
-class HaMarkdown extends LitElement {
+export class HaMarkdown extends LitElement {
@property() public content?;
@property({ type: Boolean }) public allowSvg = false;
@@ -38,35 +38,43 @@ class HaMarkdown extends LitElement {
ha-markdown-element > *:last-child {
margin-bottom: 0;
}
- ha-markdown-element a {
+ a {
color: var(--primary-color);
}
- ha-markdown-element img {
+ img {
max-width: 100%;
}
- ha-markdown-element code,
+ code,
pre {
background-color: var(--markdown-code-background-color, none);
border-radius: 3px;
}
- ha-markdown-element svg {
+ svg {
background-color: var(--markdown-svg-background-color, none);
color: var(--markdown-svg-color, none);
}
- ha-markdown-element code {
+ code {
font-size: 85%;
padding: 0.2em 0.4em;
}
- ha-markdown-element pre code {
+ pre code {
padding: 0;
}
- ha-markdown-element pre {
+ pre {
padding: 16px;
overflow: auto;
line-height: 1.45;
font-family: var(--code-font-family, monospace);
}
- ha-markdown-element h2 {
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ line-height: initial;
+ }
+ h2 {
font-size: 1.5em;
font-weight: bold;
}
diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts
index b9cfa06920..8b2b189af9 100644
--- a/src/components/ha-service-control.ts
+++ b/src/components/ha-service-control.ts
@@ -130,6 +130,33 @@ export class HaServiceControl extends LitElement {
this._value = this.value;
}
+ if (oldValue?.service !== this.value?.service) {
+ let updatedDefaultValue = false;
+ if (this._value && serviceData) {
+ // Set mandatory bools without a default value to false
+ this._value.data ??= {};
+ serviceData.fields.forEach((field) => {
+ if (
+ field.selector &&
+ field.required &&
+ field.default === undefined &&
+ "boolean" in field.selector &&
+ this._value!.data![field.key] === undefined
+ ) {
+ updatedDefaultValue = true;
+ this._value!.data![field.key] = false;
+ }
+ });
+ }
+ if (updatedDefaultValue) {
+ fireEvent(this, "value-changed", {
+ value: {
+ ...this._value,
+ },
+ });
+ }
+ }
+
if (this._value?.data) {
const yamlEditor = this._yamlEditor;
if (yamlEditor && yamlEditor.value !== this._value.data) {
diff --git a/src/components/ha-sidebar.ts b/src/components/ha-sidebar.ts
index 6d19184f88..70ee6ed471 100644
--- a/src/components/ha-sidebar.ts
+++ b/src/components/ha-sidebar.ts
@@ -44,10 +44,6 @@ import {
PersistentNotification,
subscribeNotifications,
} from "../data/persistent_notification";
-import {
- ExternalConfig,
- getExternalConfig,
-} from "../external_app/external_config";
import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant, PanelInfo, Route } from "../types";
@@ -192,8 +188,6 @@ class HaSidebar extends LitElement {
@property({ type: Boolean }) public editMode = false;
- @state() private _externalConfig?: ExternalConfig;
-
@state() private _notifications?: PersistentNotification[];
@state() private _renderEmptySortable = false;
@@ -270,13 +264,6 @@ class HaSidebar extends LitElement {
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
-
- if (this.hass && this.hass.auth.external) {
- getExternalConfig(this.hass.auth.external).then((conf) => {
- this._externalConfig = conf;
- });
- }
-
subscribeNotifications(this.hass.connection, (notifications) => {
this._notifications = notifications;
});
@@ -559,8 +546,7 @@ class HaSidebar extends LitElement {
private _renderExternalConfiguration() {
return html`${!this.hass.user?.is_admin &&
- this._externalConfig &&
- this._externalConfig.hasSettingsScreen
+ this.hass.auth.external?.config.hasSettingsScreen
? html`
+ focusable="false"
+ role="img"
+ aria-hidden="true"
+ >
${this.path ? svg` ` : ""}
diff --git a/src/components/ha-textfield.ts b/src/components/ha-textfield.ts
new file mode 100644
index 0000000000..55daec0349
--- /dev/null
+++ b/src/components/ha-textfield.ts
@@ -0,0 +1,25 @@
+import { TextField } from "@material/mwc-textfield";
+import { TemplateResult, html } from "lit";
+import { customElement } from "lit/decorators";
+
+@customElement("ha-textfield")
+export class HaTextField extends TextField {
+ override renderIcon(_icon: string, isTrailingIcon = false): TemplateResult {
+ const type = isTrailingIcon ? "trailing" : "leading";
+
+ return html`
+
+
+
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-textfield": HaTextField;
+ }
+}
diff --git a/src/components/ha-water_heater-state.js b/src/components/ha-water_heater-state.js
index 8e6759ea6d..c9bd1147ac 100644
--- a/src/components/ha-water_heater-state.js
+++ b/src/components/ha-water_heater-state.js
@@ -64,7 +64,7 @@ class HaWaterHeaterState extends LocalizeMixin(PolymerElement) {
return `${formatNumber(
stateObj.attributes.target_temp_low,
this.hass.locale
- )} - ${formatNumber(
+ )} – ${formatNumber(
stateObj.attributes.target_temp_high,
this.hass.locale
)} ${hass.config.unit_system.temperature}`;
diff --git a/src/components/ha-web-rtc-player.ts b/src/components/ha-web-rtc-player.ts
index b2f64a0645..e65017265e 100644
--- a/src/components/ha-web-rtc-player.ts
+++ b/src/components/ha-web-rtc-player.ts
@@ -136,9 +136,8 @@ class HaWebRtcPlayer extends LitElement {
this._remoteStream = undefined;
}
if (this._videoEl) {
- const videoEl = this._videoEl;
- videoEl.removeAttribute("src");
- videoEl.load();
+ this._videoEl.removeAttribute("src");
+ this._videoEl.load();
}
if (this._peerConnection) {
this._peerConnection.close();
diff --git a/src/components/ha-yaml-editor.ts b/src/components/ha-yaml-editor.ts
index 4e1a4e5853..03fa830724 100644
--- a/src/components/ha-yaml-editor.ts
+++ b/src/components/ha-yaml-editor.ts
@@ -60,6 +60,7 @@ export class HaYamlEditor extends LitElement {
mode="yaml"
.error=${this.isValid === false}
@value-changed=${this._onChange}
+ dir="ltr"
>
`;
}
diff --git a/src/components/media-player/dialog-media-player-browse.ts b/src/components/media-player/dialog-media-player-browse.ts
index 6e5a54d973..8e737dc0ed 100644
--- a/src/components/media-player/dialog-media-player-browse.ts
+++ b/src/components/media-player/dialog-media-player-browse.ts
@@ -1,45 +1,50 @@
+import "../ha-header-bar";
+import { mdiArrowLeft, mdiClose } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent, HASSDomEvent } from "../../common/dom/fire_event";
+import { computeRTLDirection } from "../../common/util/compute_rtl";
import type {
MediaPickedEvent,
MediaPlayerBrowseAction,
+ MediaPlayerItem,
} from "../../data/media-player";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../ha-dialog";
import "./ha-media-player-browse";
+import type { MediaPlayerItemId } from "./ha-media-player-browse";
import { MediaPlayerBrowseDialogParams } from "./show-media-browser-dialog";
@customElement("dialog-media-player-browse")
class DialogMediaPlayerBrowse extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
- @state() private _entityId!: string;
+ @state() private _currentItem?: MediaPlayerItem;
- @state() private _mediaContentId?: string;
-
- @state() private _mediaContentType?: string;
-
- @state() private _action?: MediaPlayerBrowseAction;
+ @state() private _navigateIds?: MediaPlayerItemId[];
@state() private _params?: MediaPlayerBrowseDialogParams;
public showDialog(params: MediaPlayerBrowseDialogParams): void {
this._params = params;
- this._entityId = this._params.entityId;
- this._mediaContentId = this._params.mediaContentId;
- this._mediaContentType = this._params.mediaContentType;
- this._action = this._params.action || "play";
+ this._navigateIds = [
+ {
+ media_content_id: this._params.mediaContentId,
+ media_content_type: this._params.mediaContentType,
+ },
+ ];
}
public closeDialog() {
this._params = undefined;
+ this._navigateIds = undefined;
+ this._currentItem = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render(): TemplateResult {
- if (!this._params) {
+ if (!this._params || !this._navigateIds) {
return html``;
}
@@ -50,22 +55,60 @@ class DialogMediaPlayerBrowse extends LitElement {
escapeKeyAction
hideActions
flexContent
+ .heading=${true}
@closed=${this.closeDialog}
>
+
+ ${this._navigateIds.length > 1
+ ? html`
+
+ `
+ : ""}
+
+ ${!this._currentItem
+ ? this.hass.localize(
+ "ui.components.media-browser.media-player-browser"
+ )
+ : this._currentItem.title}
+
+
+
+
`;
}
+ private _goBack() {
+ this._navigateIds = this._navigateIds?.slice(0, -1);
+ this._currentItem = undefined;
+ }
+
+ private _mediaBrowsed(ev: { detail: HASSDomEvents["media-browsed"] }) {
+ this._navigateIds = ev.detail.ids;
+ this._currentItem = ev.detail.current;
+ }
+
private _mediaPicked(ev: HASSDomEvent): void {
this._params!.mediaPickedCallback(ev.detail);
if (this._action !== "play") {
@@ -73,6 +116,10 @@ class DialogMediaPlayerBrowse extends LitElement {
}
}
+ private get _action(): MediaPlayerBrowseAction {
+ return this._params!.action || "play";
+ }
+
static get styles(): CSSResultGroup {
return [
haStyleDialog,
@@ -83,7 +130,7 @@ class DialogMediaPlayerBrowse extends LitElement {
}
ha-media-player-browse {
- --media-browser-max-height: 100vh;
+ --media-browser-max-height: calc(100vh - 65px);
}
@media (min-width: 800px) {
@@ -95,10 +142,17 @@ class DialogMediaPlayerBrowse extends LitElement {
}
ha-media-player-browse {
position: initial;
- --media-browser-max-height: 100vh - 72px;
+ --media-browser-max-height: 100vh - 137px;
width: 700px;
}
}
+
+ ha-header-bar {
+ --mdc-theme-on-primary: var(--primary-text-color);
+ --mdc-theme-primary: var(--mdc-theme-surface);
+ flex-shrink: 0;
+ border-bottom: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12));
+ }
`,
];
}
diff --git a/src/components/media-player/ha-media-player-browse.ts b/src/components/media-player/ha-media-player-browse.ts
index 19672ca8de..2f8e21686a 100644
--- a/src/components/media-player/ha-media-player-browse.ts
+++ b/src/components/media-player/ha-media-player-browse.ts
@@ -1,7 +1,7 @@
import "@material/mwc-button/mwc-button";
import "@material/mwc-list/mwc-list";
import "@material/mwc-list/mwc-list-item";
-import { mdiArrowLeft, mdiClose, mdiPlay, mdiPlus } from "@mdi/js";
+import { mdiPlay, mdiPlus } from "@mdi/js";
import "@polymer/paper-item/paper-item";
import "@polymer/paper-listbox/paper-listbox";
import "@polymer/paper-tooltip/paper-tooltip";
@@ -18,6 +18,7 @@ import {
eventOptions,
property,
query,
+ queryAll,
state,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
@@ -26,6 +27,7 @@ import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../../common/dom/fire_event";
import { computeRTLDirection } from "../../common/util/compute_rtl";
import { debounce } from "../../common/util/debounce";
+import { getSignedPath } from "../../data/auth";
import type { MediaPlayerItem } from "../../data/media-player";
import {
browseLocalMediaPlayer,
@@ -43,247 +45,299 @@ import { documentationUrl } from "../../util/documentation-url";
import "../entity/ha-entity-picker";
import "../ha-button-menu";
import "../ha-card";
+import type { HaCard } from "../ha-card";
import "../ha-circular-progress";
-import "../ha-fab";
import "../ha-icon-button";
import "../ha-svg-icon";
+import "../ha-fab";
declare global {
interface HASSDomEvents {
"media-picked": MediaPickedEvent;
+ "media-browsed": { ids: MediaPlayerItemId[]; current?: MediaPlayerItem };
}
}
+export interface MediaPlayerItemId {
+ media_content_id: string | undefined;
+ media_content_type: string | undefined;
+}
+
@customElement("ha-media-player-browse")
export class HaMediaPlayerBrowse extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public entityId!: string;
- @property() public mediaContentId?: string;
-
- @property() public mediaContentType?: string;
-
@property() public action: MediaPlayerBrowseAction = "play";
@property({ type: Boolean }) public dialog = false;
+ @property() public navigateIds!: MediaPlayerItemId[];
+
@property({ type: Boolean, attribute: "narrow", reflect: true })
+ // @ts-ignore
private _narrow = false;
@property({ type: Boolean, attribute: "scroll", reflect: true })
private _scrolled = false;
- @state() private _loading = false;
-
@state() private _error?: { message: string; code: string };
- @state() private _mediaPlayerItems: MediaPlayerItem[] = [];
+ @state() private _parentItem?: MediaPlayerItem;
+
+ @state() private _currentItem?: MediaPlayerItem;
@query(".header") private _header?: HTMLDivElement;
@query(".content") private _content?: HTMLDivElement;
+ @queryAll(".lazythumbnail") private _thumbnails?: HaCard[];
+
private _headerOffsetHeight = 0;
private _resizeObserver?: ResizeObserver;
+ // @ts-ignore
+ private _intersectionObserver?: IntersectionObserver;
+
public connectedCallback(): void {
super.connectedCallback();
- this.updateComplete.then(() => this._attachObserver());
+ this.updateComplete.then(() => this._attachResizeObserver());
}
public disconnectedCallback(): void {
if (this._resizeObserver) {
this._resizeObserver.disconnect();
}
+ if (this._intersectionObserver) {
+ this._intersectionObserver.disconnect();
+ }
}
- public navigateBack() {
- this._mediaPlayerItems!.pop();
- const item = this._mediaPlayerItems!.pop();
- if (!item) {
- return;
+ public play(): void {
+ if (this._currentItem?.can_play) {
+ this._runAction(this._currentItem);
}
- this._navigate(item);
}
protected render(): TemplateResult {
- if (this._loading) {
+ if (this._error) {
+ return html`
+ ${this._renderError(this._error)}
+ `;
+ }
+
+ if (!this._currentItem) {
return html` `;
}
- if (this._error && !this._mediaPlayerItems.length) {
- if (this.dialog) {
- this._closeDialogAction();
- showAlertDialog(this, {
- title: this.hass.localize(
- "ui.components.media-browser.media_browsing_error"
- ),
- text: this._renderError(this._error),
- });
- } else {
- return html`
- ${this._renderError(this._error)}
- `;
- }
- }
-
- if (!this._mediaPlayerItems.length) {
- return html``;
- }
-
- const currentItem =
- this._mediaPlayerItems[this._mediaPlayerItems.length - 1];
-
- const previousItem: MediaPlayerItem | undefined =
- this._mediaPlayerItems.length > 1
- ? this._mediaPlayerItems[this._mediaPlayerItems.length - 2]
- : undefined;
+ const currentItem = this._currentItem;
const subtitle = this.hass.localize(
`ui.components.media-browser.class.${currentItem.media_class}`
);
+
const mediaClass = MediaClassBrowserSettings[currentItem.media_class];
const childrenMediaClass =
MediaClassBrowserSettings[currentItem.children_media_class];
return html`
-
`;
}
protected firstUpdated(): void {
this._measureCard();
- this._attachObserver();
+ this._attachResizeObserver();
+ }
+
+ protected shouldUpdate(changedProps: PropertyValues): boolean {
+ if (changedProps.size > 1 || !changedProps.has("hass")) {
+ return true;
+ }
+ const oldHass = changedProps.get("hass") as this["hass"];
+ return oldHass === undefined || oldHass.localize !== this.hass.localize;
+ }
+
+ public willUpdate(changedProps: PropertyValues): void {
+ super.willUpdate(changedProps);
+
+ if (changedProps.has("entityId")) {
+ this._setError(undefined);
+ }
+ if (!changedProps.has("navigateIds")) {
+ return;
+ }
+ const oldNavigateIds = changedProps.get("navigateIds") as
+ | this["navigateIds"]
+ | undefined;
+
+ // We're navigating. Reset the shizzle.
+ this._content?.scrollTo(0, 0);
+ this._scrolled = false;
+ const oldCurrentItem = this._currentItem;
+ const oldParentItem = this._parentItem;
+ this._currentItem = undefined;
+ this._parentItem = undefined;
+ const currentId = this.navigateIds[this.navigateIds.length - 1];
+ const parentId =
+ this.navigateIds.length > 1
+ ? this.navigateIds[this.navigateIds.length - 2]
+ : undefined;
+ let currentProm: Promise | undefined;
+ let parentProm: Promise | undefined;
+
+ // See if we can take loading shortcuts if navigating to parent or child
+ if (!changedProps.has("entityId")) {
+ if (
+ // Check if we navigated to a child
+ oldNavigateIds &&
+ this.navigateIds.length > oldNavigateIds.length &&
+ oldNavigateIds.every((oldVal, idx) => {
+ const curVal = this.navigateIds[idx];
+ return (
+ curVal.media_content_id === oldVal.media_content_id &&
+ curVal.media_content_type === oldVal.media_content_type
+ );
+ })
+ ) {
+ parentProm = Promise.resolve(oldCurrentItem!);
+ } else if (
+ // Check if we navigated to a parent
+ oldNavigateIds &&
+ this.navigateIds.length < oldNavigateIds.length &&
+ this.navigateIds.every((curVal, idx) => {
+ const oldVal = oldNavigateIds[idx];
+ return (
+ curVal.media_content_id === oldVal.media_content_id &&
+ curVal.media_content_type === oldVal.media_content_type
+ );
+ })
+ ) {
+ currentProm = Promise.resolve(oldParentItem!);
+ }
+ }
+ // Fetch current
+ if (!currentProm) {
+ currentProm = this._fetchData(
+ this.entityId,
+ currentId.media_content_id,
+ currentId.media_content_type
+ );
+ }
+ currentProm.then(
+ (item) => {
+ this._currentItem = item;
+ fireEvent(this, "media-browsed", {
+ ids: this.navigateIds,
+ current: item,
+ });
+ },
+ (err) => this._setError(err)
+ );
+ // Fetch parent
+ if (!parentProm && parentId !== undefined) {
+ parentProm = this._fetchData(
+ this.entityId,
+ parentId.media_content_id,
+ parentId.media_content_type
+ );
+ }
+ if (parentProm) {
+ parentProm.then((parent) => {
+ this._parentItem = parent;
+ });
+ }
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
- if (
- changedProps.has("_mediaPlayerItems") &&
- this._mediaPlayerItems.length
- ) {
- this._setHeaderHeight();
- }
-
- if (
- changedProps.get("_scrolled") !== undefined &&
- this._mediaPlayerItems.length
- ) {
+ if (changedProps.has("_scrolled")) {
this._animateHeaderHeight();
- }
-
- if (
- !changedProps.has("entityId") &&
- !changedProps.has("mediaContentId") &&
- !changedProps.has("mediaContentType") &&
- !changedProps.has("action")
- ) {
- return;
- }
-
- if (changedProps.has("entityId")) {
- this._error = undefined;
- this._mediaPlayerItems = [];
- }
-
- this._fetchData(this.mediaContentId, this.mediaContentType)
- .then((itemData) => {
- if (!itemData) {
- return;
- }
-
- this._mediaPlayerItems = [itemData];
- })
- .catch((err) => {
- this._error = err;
- });
- }
-
- private async _setHeaderHeight() {
- await this.updateComplete;
- const header = this._header;
- const content = this._content;
- if (!header || !content) {
- return;
- }
- this._headerOffsetHeight = header.offsetHeight;
- content.style.marginTop = `${this._headerOffsetHeight}px`;
- content.style.maxHeight = `calc(var(--media-browser-max-height, 100%) - ${this._headerOffsetHeight}px)`;
- }
-
- private _animateHeaderHeight() {
- let start;
- const animate = (time) => {
- if (start === undefined) {
- start = time;
- }
- const elapsed = time - start;
+ } else if (changedProps.has("_currentItem")) {
this._setHeaderHeight();
- if (elapsed < 400) {
- requestAnimationFrame(animate);
- }
- };
- requestAnimationFrame(animate);
+ this._attachIntersectionObserver();
+ }
}
private _actionClicked(ev: MouseEvent): void {
@@ -489,71 +532,26 @@ export class HaMediaPlayerBrowse extends LitElement {
return;
}
- this._navigate(item);
- }
-
- private async _navigate(item: MediaPlayerItem) {
- this._error = undefined;
-
- let itemData: MediaPlayerItem;
-
- try {
- itemData = await this._fetchData(
- item.media_content_id,
- item.media_content_type
- );
- } catch (err: any) {
- showAlertDialog(this, {
- title: this.hass.localize(
- "ui.components.media-browser.media_browsing_error"
- ),
- text: this._renderError(err),
- });
- return;
- }
-
- this._content?.scrollTo(0, 0);
- this._scrolled = false;
- this._mediaPlayerItems = [...this._mediaPlayerItems, itemData];
+ fireEvent(this, "media-browsed", {
+ ids: [...this.navigateIds, item],
+ });
}
private async _fetchData(
+ entityId: string,
mediaContentId?: string,
mediaContentType?: string
): Promise {
- this._loading = true;
- let itemData: any;
- try {
- itemData =
- this.entityId !== BROWSER_PLAYER
- ? await browseMediaPlayer(
- this.hass,
- this.entityId,
- mediaContentId,
- mediaContentType
- )
- : await browseLocalMediaPlayer(this.hass, mediaContentId);
- } finally {
- this._loading = false;
- }
- return itemData;
+ return entityId !== BROWSER_PLAYER
+ ? browseMediaPlayer(this.hass, entityId, mediaContentId, mediaContentType)
+ : browseLocalMediaPlayer(this.hass, mediaContentId);
}
private _measureCard(): void {
this._narrow = (this.dialog ? window.innerWidth : this.offsetWidth) < 450;
}
- @eventOptions({ passive: true })
- private _scroll(ev: Event): void {
- const content = ev.currentTarget as HTMLDivElement;
- if (!this._scrolled && content.scrollTop > this._headerOffsetHeight) {
- this._scrolled = true;
- } else if (this._scrolled && content.scrollTop < this._headerOffsetHeight) {
- this._scrolled = false;
- }
- }
-
- private async _attachObserver(): Promise {
+ private async _attachResizeObserver(): Promise {
if (!this._resizeObserver) {
await installResizeObserver();
this._resizeObserver = new ResizeObserver(
@@ -564,10 +562,67 @@ export class HaMediaPlayerBrowse extends LitElement {
this._resizeObserver.observe(this);
}
+ /**
+ * Load thumbnails for images on demand as they become visible.
+ */
+ private async _attachIntersectionObserver(): Promise {
+ if (!("IntersectionObserver" in window) || !this._thumbnails) {
+ return;
+ }
+ if (!this._intersectionObserver) {
+ this._intersectionObserver = new IntersectionObserver(
+ async (entries, observer) => {
+ await Promise.all(
+ entries.map(async (entry) => {
+ if (!entry.isIntersecting) {
+ return;
+ }
+ const thumbnailCard = entry.target as HTMLElement;
+ let thumbnailUrl = thumbnailCard.dataset.src;
+ if (!thumbnailUrl) {
+ return;
+ }
+ if (thumbnailUrl.startsWith("/")) {
+ // Thumbnails served by local API require authentication
+ const signedPath = await getSignedPath(this.hass, thumbnailUrl);
+ thumbnailUrl = signedPath.path;
+ }
+ thumbnailCard.style.backgroundImage = `url(${thumbnailUrl})`;
+ observer.unobserve(thumbnailCard); // loaded, so no need to observe anymore
+ })
+ );
+ }
+ );
+ }
+ const observer = this._intersectionObserver!;
+ for (const thumbnailCard of this._thumbnails) {
+ observer.observe(thumbnailCard);
+ }
+ }
+
private _closeDialogAction(): void {
fireEvent(this, "close-dialog");
}
+ private _setError(error: any) {
+ if (!this.dialog) {
+ this._error = error;
+ return;
+ }
+
+ if (!error) {
+ return;
+ }
+
+ this._closeDialogAction();
+ showAlertDialog(this, {
+ title: this.hass.localize(
+ "ui.components.media-browser.media_browsing_error"
+ ),
+ text: this._renderError(error),
+ });
+ }
+
private _renderError(err: { message: string; code: string }) {
if (err.message === "Media directory does not exist.") {
return html`
@@ -602,6 +657,43 @@ export class HaMediaPlayerBrowse extends LitElement {
return html`${err.message} `;
}
+ private async _setHeaderHeight() {
+ await this.updateComplete;
+ const header = this._header;
+ const content = this._content;
+ if (!header || !content) {
+ return;
+ }
+ this._headerOffsetHeight = header.offsetHeight;
+ content.style.marginTop = `${this._headerOffsetHeight}px`;
+ content.style.maxHeight = `calc(var(--media-browser-max-height, 100%) - ${this._headerOffsetHeight}px)`;
+ }
+
+ private _animateHeaderHeight() {
+ let start;
+ const animate = (time) => {
+ if (start === undefined) {
+ start = time;
+ }
+ const elapsed = time - start;
+ this._setHeaderHeight();
+ if (elapsed < 400) {
+ requestAnimationFrame(animate);
+ }
+ };
+ requestAnimationFrame(animate);
+ }
+
+ @eventOptions({ passive: true })
+ private _scroll(ev: Event): void {
+ const content = ev.currentTarget as HTMLDivElement;
+ if (!this._scrolled && content.scrollTop > this._headerOffsetHeight) {
+ this._scrolled = true;
+ } else if (this._scrolled && content.scrollTop < this._headerOffsetHeight) {
+ this._scrolled = false;
+ }
+ }
+
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -623,12 +715,17 @@ export class HaMediaPlayerBrowse extends LitElement {
padding: 16px;
}
+ .no-items {
+ padding-left: 32px;
+ }
+
.content {
overflow-y: auto;
- padding-bottom: 20px;
box-sizing: border-box;
}
+ /* HEADER */
+
.header {
display: flex;
justify-content: space-between;
@@ -639,30 +736,26 @@ export class HaMediaPlayerBrowse extends LitElement {
right: 0;
left: 0;
z-index: 5;
- padding: 20px 24px 10px;
+ padding: 16px;
}
-
.header_button {
position: relative;
right: -8px;
}
-
.header-content {
display: flex;
flex-wrap: wrap;
flex-grow: 1;
align-items: flex-start;
}
-
.header-content .img {
- height: 200px;
- width: 200px;
+ height: 175px;
+ width: 175px;
margin-right: 16px;
background-size: cover;
- border-radius: 4px;
+ border-radius: 2px;
transition: width 0.4s, height 0.4s;
}
-
.header-info {
display: flex;
flex-direction: column;
@@ -671,19 +764,18 @@ export class HaMediaPlayerBrowse extends LitElement {
min-width: 0;
flex: 1;
}
-
.header-info mwc-button {
display: block;
--mdc-theme-primary: var(--primary-color);
+ padding-bottom: 16px;
}
-
.breadcrumb {
display: flex;
flex-direction: column;
overflow: hidden;
flex-grow: 1;
+ padding-top: 16px;
}
-
.breadcrumb .title {
font-size: 32px;
line-height: 1.2;
@@ -695,7 +787,6 @@ export class HaMediaPlayerBrowse extends LitElement {
-webkit-line-clamp: 2;
padding-right: 8px;
}
-
.breadcrumb .previous-title {
font-size: 14px;
padding-bottom: 8px;
@@ -705,7 +796,6 @@ export class HaMediaPlayerBrowse extends LitElement {
cursor: pointer;
--mdc-icon-size: 14px;
}
-
.breadcrumb .subtitle {
font-size: 16px;
overflow: hidden;
@@ -738,8 +828,7 @@ export class HaMediaPlayerBrowse extends LitElement {
minmax(var(--media-browse-item-size, 175px), 0.1fr)
);
grid-gap: 16px;
- padding: 0px 24px;
- margin: 8px 0px;
+ padding: 16px;
}
:host([dialog]) .children {
@@ -755,42 +844,60 @@ export class HaMediaPlayerBrowse extends LitElement {
cursor: pointer;
}
- .ha-card-parent {
+ ha-card {
position: relative;
width: 100%;
+ box-sizing: border-box;
}
- .children ha-card {
+ .children ha-card .thumbnail {
width: 100%;
- padding-bottom: 100%;
position: relative;
box-sizing: border-box;
- background-size: cover;
- background-repeat: no-repeat;
- background-position: center;
transition: padding-bottom 0.1s ease-out;
+ padding-bottom: 100%;
}
- .portrait.children ha-card {
+ .portrait.children ha-card .thumbnail {
padding-bottom: 150%;
}
- .child .folder,
- .child .play {
+ ha-card .image {
+ border-radius: 3px 3px 0 0;
+ }
+
+ .image {
position: absolute;
+ top: 0;
+ right: 0;
+ left: 0;
+ bottom: 0;
+ background-size: cover;
+ background-repeat: no-repeat;
+ background-position: center;
+ }
+
+ .centered-image {
+ margin: 0 8px;
+ background-size: contain;
+ }
+
+ .children ha-card .icon-holder {
+ display: flex;
+ justify-content: center;
+ align-items: center;
}
.child .folder {
color: var(--secondary-text-color);
- top: calc(50% - (var(--mdc-icon-size) / 2));
- left: calc(50% - (var(--mdc-icon-size) / 2));
--mdc-icon-size: calc(var(--media-browse-item-size, 175px) * 0.4);
}
.child .play {
+ position: absolute;
transition: color 0.5s;
border-radius: 50%;
- bottom: calc(50% - 35px);
+ top: calc(50% - 50px);
right: calc(50% - 35px);
opacity: 0;
transition: opacity 0.1s ease-out;
@@ -801,45 +908,57 @@ export class HaMediaPlayerBrowse extends LitElement {
--mdc-icon-size: 48px;
}
- .ha-card-parent:hover .play:not(.can_expand) {
+ ha-card:hover .play {
opacity: 1;
+ }
+
+ ha-card:hover .play:not(.can_expand) {
color: var(--primary-color);
}
+ ha-card:hover .play.can_expand {
+ bottom: 8px;
+ }
+
.child .play.can_expand {
- opacity: 1;
background-color: rgba(var(--rgb-card-background-color), 0.5);
- bottom: 4px;
- right: 4px;
+ top: auto;
+ bottom: 0px;
+ right: 8px;
+ transition: bottom 0.1s ease-out, opacity 0.1s ease-out;
}
.child .play:hover {
color: var(--primary-color);
}
- .ha-card-parent:hover ha-card {
+ ha-card:hover .lazythumbnail {
opacity: 0.5;
}
.child .title {
font-size: 16px;
- padding-top: 8px;
+ padding-top: 16px;
padding-left: 2px;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
- -webkit-line-clamp: 2;
+ -webkit-line-clamp: 1;
text-overflow: ellipsis;
}
- .child .type {
- font-size: 12px;
- color: var(--secondary-text-color);
- padding-left: 2px;
+ .child ha-card .title {
+ margin-bottom: 16px;
+ padding-left: 16px;
}
mwc-list-item .graphic {
- background-size: cover;
+ background-size: contain;
+ border-radius: 2px;
+ display: flex;
+ align-content: center;
+ align-items: center;
+ line-height: initial;
}
mwc-list-item .graphic .play {
@@ -852,7 +971,7 @@ export class HaMediaPlayerBrowse extends LitElement {
mwc-list-item:hover .graphic .play {
opacity: 1;
- color: var(--primary-color);
+ color: var(--primary-text-color);
}
mwc-list-item .graphic .play.show {
@@ -874,29 +993,32 @@ export class HaMediaPlayerBrowse extends LitElement {
padding: 0;
}
+ :host([narrow]) .media-source {
+ padding: 0 24px;
+ }
+
+ :host([narrow]) .children {
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) !important;
+ }
+
:host([narrow]) .breadcrumb .title {
font-size: 24px;
}
-
:host([narrow]) .header {
padding: 0;
}
-
:host([narrow]) .header.no-dialog {
display: block;
}
-
:host([narrow]) .header_button {
position: absolute;
top: 14px;
right: 8px;
}
-
:host([narrow]) .header-content {
flex-direction: column;
flex-wrap: nowrap;
}
-
:host([narrow]) .header-content .img {
height: auto;
width: 100%;
@@ -908,94 +1030,75 @@ export class HaMediaPlayerBrowse extends LitElement {
border-radius: 0;
transition: width 0.4s, height 0.4s, padding-bottom 0.4s;
}
-
ha-fab {
position: absolute;
--mdc-theme-secondary: var(--primary-color);
bottom: -20px;
right: 20px;
}
-
:host([narrow]) .header-info mwc-button {
margin-top: 16px;
margin-bottom: 8px;
}
-
:host([narrow]) .header-info {
- padding: 20px 24px 10px;
- }
-
- :host([narrow]) .media-source {
- padding: 0 24px;
- }
-
- :host([narrow]) .children {
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) !important;
+ padding: 0 16px 8px;
}
/* ============= Scroll ============= */
-
:host([scroll]) .breadcrumb .subtitle {
height: 0;
margin: 0;
}
-
:host([scroll]) .breadcrumb .title {
-webkit-line-clamp: 1;
}
-
:host(:not([narrow])[scroll]) .header:not(.no-img) ha-icon-button {
align-self: center;
}
-
:host([scroll]) .header-info mwc-button,
.no-img .header-info mwc-button {
padding-right: 4px;
}
-
:host([scroll][narrow]) .no-img .header-info mwc-button {
padding-right: 16px;
}
-
:host([scroll]) .header-info {
flex-direction: row;
}
-
:host([scroll]) .header-info mwc-button {
align-self: center;
margin-top: 0;
margin-bottom: 0;
+ padding-bottom: 0;
}
-
:host([scroll][narrow]) .no-img .header-info {
flex-direction: row-reverse;
}
-
:host([scroll][narrow]) .header-info {
padding: 20px 24px 10px 24px;
align-items: center;
}
-
:host([scroll]) .header-content {
align-items: flex-end;
flex-direction: row;
}
-
:host([scroll]) .header-content .img {
height: 75px;
width: 75px;
}
-
+ :host([scroll]) .breadcrumb {
+ padding-top: 0;
+ align-self: center;
+ }
:host([scroll][narrow]) .header-content .img {
height: 100px;
width: 100px;
padding-bottom: initial;
margin-bottom: 0;
}
-
:host([scroll]) ha-fab {
- bottom: 4px;
- right: 4px;
+ bottom: 0px;
+ right: -24px;
--mdc-fab-box-shadow: none;
--mdc-theme-secondary: rgba(var(--rgb-primary-color), 0.5);
}
diff --git a/src/components/trace/ha-trace-blueprint-config.ts b/src/components/trace/ha-trace-blueprint-config.ts
index 64e4bbc810..ec2a0e5440 100644
--- a/src/components/trace/ha-trace-blueprint-config.ts
+++ b/src/components/trace/ha-trace-blueprint-config.ts
@@ -17,6 +17,7 @@ export class HaTraceBlueprintConfig extends LitElement {
`;
}
diff --git a/src/components/trace/ha-trace-config.ts b/src/components/trace/ha-trace-config.ts
index 237a051110..64b71f78cd 100644
--- a/src/components/trace/ha-trace-config.ts
+++ b/src/components/trace/ha-trace-config.ts
@@ -17,6 +17,7 @@ export class HaTraceConfig extends LitElement {
`;
}
diff --git a/src/components/trace/ha-trace-path-details.ts b/src/components/trace/ha-trace-path-details.ts
index 5f1e8c56c5..b27ead74a0 100644
--- a/src/components/trace/ha-trace-path-details.ts
+++ b/src/components/trace/ha-trace-path-details.ts
@@ -150,6 +150,7 @@ export class HaTracePathDetails extends LitElement {
? html` `
: "Unable to find config";
}
diff --git a/src/data/cloud.ts b/src/data/cloud.ts
index a2ddc98c1b..4679d97a75 100644
--- a/src/data/cloud.ts
+++ b/src/data/cloud.ts
@@ -51,11 +51,13 @@ export interface CloudStatusLoggedIn {
google_registered: boolean;
google_entities: EntityFilter;
google_domains: string[];
+ alexa_registered: boolean;
alexa_entities: EntityFilter;
prefs: CloudPreferences;
remote_domain: string | undefined;
remote_connected: boolean;
remote_certificate: undefined | CertificateInformation;
+ http_use_ssl: boolean;
}
export type CloudStatus = CloudStatusNotLoggedIn | CloudStatusLoggedIn;
diff --git a/src/data/config_flow.ts b/src/data/config_flow.ts
index 95af6f6e12..41842496d0 100644
--- a/src/data/config_flow.ts
+++ b/src/data/config_flow.ts
@@ -114,5 +114,8 @@ export const localizeConfigFlowTitle = (
args.push(key);
args.push(placeholders[key]);
});
- return localize(`component.${flow.handler}.config.flow_title`, ...args);
+ return localize(`component.${flow.handler}.config.flow_title`, ...args) ||
+ "name" in placeholders
+ ? placeholders.name
+ : domainToName(localize, flow.handler);
};
diff --git a/src/data/device_registry.ts b/src/data/device_registry.ts
index 7a16c1ec10..b2610c109c 100644
--- a/src/data/device_registry.ts
+++ b/src/data/device_registry.ts
@@ -1,5 +1,6 @@
import { Connection, createCollection } from "home-assistant-js-websocket";
import { computeStateName } from "../common/entity/compute_state_name";
+import { caseInsensitiveStringCompare } from "../common/string/compare";
import { debounce } from "../common/util/debounce";
import { HomeAssistant } from "../types";
import { EntityRegistryEntry } from "./entity_registry";
@@ -99,3 +100,8 @@ export const subscribeDeviceRegistry = (
conn,
onChange
);
+
+export const sortDeviceRegistryByName = (entries: DeviceRegistryEntry[]) =>
+ entries.sort((entry1, entry2) =>
+ caseInsensitiveStringCompare(entry1.name || "", entry2.name || "")
+ );
diff --git a/src/data/diagnostics.ts b/src/data/diagnostics.ts
new file mode 100644
index 0000000000..e533244232
--- /dev/null
+++ b/src/data/diagnostics.ts
@@ -0,0 +1,33 @@
+import { HomeAssistant } from "../types";
+
+export interface DiagnosticInfo {
+ domain: string;
+ handlers: {
+ config_entry: boolean;
+ device: boolean;
+ };
+}
+
+export const fetchDiagnosticHandlers = (
+ hass: HomeAssistant
+): Promise =>
+ hass.callWS({
+ type: "diagnostics/list",
+ });
+
+export const fetchDiagnosticHandler = (
+ hass: HomeAssistant,
+ domain: string
+): Promise =>
+ hass.callWS({
+ type: "diagnostics/get",
+ domain,
+ });
+
+export const getConfigEntryDiagnosticsDownloadUrl = (entry_id: string) =>
+ `/api/diagnostics/config_entry/${entry_id}`;
+
+export const getDeviceDiagnosticsDownloadUrl = (
+ entry_id: string,
+ device_id: string
+) => `/api/diagnostics/config_entry/${entry_id}/device/${device_id}`;
diff --git a/src/data/entity_attributes.ts b/src/data/entity_attributes.ts
new file mode 100644
index 0000000000..faa42e8fab
--- /dev/null
+++ b/src/data/entity_attributes.ts
@@ -0,0 +1,105 @@
+import { html, TemplateResult } from "lit";
+import { until } from "lit/directives/until";
+import checkValidDate from "../common/datetime/check_valid_date";
+import { formatDate } from "../common/datetime/format_date";
+import { formatDateTimeWithSeconds } from "../common/datetime/format_date_time";
+import { formatNumber } from "../common/number/format_number";
+import { capitalizeFirstLetter } from "../common/string/capitalize-first-letter";
+import { isDate } from "../common/string/is_date";
+import { isTimestamp } from "../common/string/is_timestamp";
+import { HomeAssistant } from "../types";
+
+let jsYamlPromise: Promise;
+
+export const STATE_ATTRIBUTES = [
+ "assumed_state",
+ "attribution",
+ "custom_ui_more_info",
+ "custom_ui_state_card",
+ "device_class",
+ "editable",
+ "emulated_hue_name",
+ "emulated_hue",
+ "entity_picture",
+ "friendly_name",
+ "haaska_hidden",
+ "haaska_name",
+ "icon",
+ "initial_state",
+ "last_reset",
+ "restored",
+ "state_class",
+ "supported_features",
+ "unit_of_measurement",
+];
+
+// Convert from internal snake_case format to user-friendly format
+export function formatAttributeName(value: string): string {
+ value = value
+ .replace(/_/g, " ")
+ .replace(/\bid\b/g, "ID")
+ .replace(/\bip\b/g, "IP")
+ .replace(/\bmac\b/g, "MAC")
+ .replace(/\bgps\b/g, "GPS");
+ return capitalizeFirstLetter(value);
+}
+
+export function formatAttributeValue(
+ hass: HomeAssistant,
+ value: any
+): string | TemplateResult {
+ if (value === null) {
+ return "—";
+ }
+
+ // YAML handling
+ if (
+ (Array.isArray(value) && value.some((val) => val instanceof Object)) ||
+ (!Array.isArray(value) && value instanceof Object)
+ ) {
+ if (!jsYamlPromise) {
+ jsYamlPromise = import("../resources/js-yaml-dump");
+ }
+ const yaml = jsYamlPromise.then((jsYaml) => jsYaml.dump(value));
+ return html`${until(yaml, "")} `;
+ }
+
+ if (typeof value === "number") {
+ return formatNumber(value, hass.locale);
+ }
+
+ if (typeof value === "string") {
+ // URL handling
+ if (value.startsWith("http")) {
+ try {
+ // If invalid URL, exception will be raised
+ const url = new URL(value);
+ if (url.protocol === "http:" || url.protocol === "https:")
+ return html`${value} `;
+ } catch (_) {
+ // Nothing to do here
+ }
+ }
+
+ // Date handling
+ if (isDate(value, true)) {
+ // Timestamp handling
+ if (isTimestamp(value)) {
+ const date = new Date(value);
+ if (checkValidDate(date)) {
+ return formatDateTimeWithSeconds(date, hass.locale);
+ }
+ }
+
+ // Value was not a timestamp, so only do date formatting
+ const date = new Date(value);
+ if (checkValidDate(date)) {
+ return formatDate(date, hass.locale);
+ }
+ }
+ }
+
+ return Array.isArray(value) ? value.join(", ") : value;
+}
diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts
index 84fdbccec5..83fcc601b5 100644
--- a/src/data/entity_registry.ts
+++ b/src/data/entity_registry.ts
@@ -1,6 +1,7 @@
import { Connection, createCollection } from "home-assistant-js-websocket";
import { Store } from "home-assistant-js-websocket/dist/store";
import { computeStateName } from "../common/entity/compute_state_name";
+import { caseInsensitiveStringCompare } from "../common/string/compare";
import { debounce } from "../common/util/debounce";
import { HomeAssistant } from "../types";
@@ -133,3 +134,8 @@ export const subscribeEntityRegistry = (
conn,
onChange
);
+
+export const sortEntityRegistryByName = (entries: EntityRegistryEntry[]) =>
+ entries.sort((entry1, entry2) =>
+ caseInsensitiveStringCompare(entry1.name || "", entry2.name || "")
+ );
diff --git a/src/data/google_assistant.ts b/src/data/google_assistant.ts
index 982f3c456a..2665be90b3 100644
--- a/src/data/google_assistant.ts
+++ b/src/data/google_assistant.ts
@@ -8,3 +8,6 @@ export interface GoogleEntity {
export const fetchCloudGoogleEntities = (hass: HomeAssistant) =>
hass.callWS({ type: "cloud/google_assistant/entities" });
+
+export const syncCloudGoogleEntities = (hass: HomeAssistant) =>
+ hass.callApi("POST", "cloud/google_actions/sync");
diff --git a/src/data/hassio/addon.ts b/src/data/hassio/addon.ts
index 93ac68c506..ccd099edc7 100644
--- a/src/data/hassio/addon.ts
+++ b/src/data/hassio/addon.ts
@@ -302,7 +302,8 @@ export const installHassioAddon = async (
export const updateHassioAddon = async (
hass: HomeAssistant,
- slug: string
+ slug: string,
+ backup: boolean
): Promise => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
@@ -310,11 +311,13 @@ export const updateHassioAddon = async (
endpoint: `/store/addons/${slug}/update`,
method: "post",
timeout: null,
+ data: { backup },
});
} else {
await hass.callApi>(
"POST",
- `hassio/addons/${slug}/update`
+ `hassio/addons/${slug}/update`,
+ { backup }
);
}
};
diff --git a/src/data/hassio/backup.ts b/src/data/hassio/backup.ts
index c5af101857..c01b9440da 100644
--- a/src/data/hassio/backup.ts
+++ b/src/data/hassio/backup.ts
@@ -20,6 +20,7 @@ export interface HassioBackup {
slug: string;
date: string;
name: string;
+ size: number;
type: "full" | "partial";
protected: boolean;
content: BackupContent;
diff --git a/src/data/input_button.ts b/src/data/input_button.ts
new file mode 100644
index 0000000000..785a381a48
--- /dev/null
+++ b/src/data/input_button.ts
@@ -0,0 +1,41 @@
+import { HomeAssistant } from "../types";
+
+export interface InputButton {
+ id: string;
+ name: string;
+ icon?: string;
+}
+
+export interface InputButtonMutableParams {
+ name: string;
+ icon: string;
+}
+
+export const fetchInputButton = (hass: HomeAssistant) =>
+ hass.callWS({ type: "input_button/list" });
+
+export const createInputButton = (
+ hass: HomeAssistant,
+ values: InputButtonMutableParams
+) =>
+ hass.callWS({
+ type: "input_button/create",
+ ...values,
+ });
+
+export const updateInputButton = (
+ hass: HomeAssistant,
+ id: string,
+ updates: Partial
+) =>
+ hass.callWS({
+ type: "input_button/update",
+ input_button_id: id,
+ ...updates,
+ });
+
+export const deleteInputButton = (hass: HomeAssistant, id: string) =>
+ hass.callWS({
+ type: "input_button/delete",
+ input_button_id: id,
+ });
diff --git a/src/data/media-player.ts b/src/data/media-player.ts
index ee4245f1b9..ee15481785 100644
--- a/src/data/media-player.ts
+++ b/src/data/media-player.ts
@@ -88,7 +88,7 @@ export const BROWSER_PLAYER = "browser";
export type MediaClassBrowserSetting = {
icon: string;
thumbnail_ratio?: string;
- layout?: string;
+ layout?: "grid";
show_list_images?: boolean;
};
@@ -320,3 +320,25 @@ export const computeMediaControls = (
return buttons.length > 0 ? buttons : undefined;
};
+
+export const formatMediaTime = (seconds: number): string => {
+ if (!seconds) {
+ return "";
+ }
+
+ let secondsString = new Date(seconds * 1000).toISOString();
+ secondsString =
+ seconds > 3600
+ ? secondsString.substring(11, 16)
+ : secondsString.substring(14, 19);
+ return secondsString.replace(/^0+/, "").padStart(4, "0");
+};
+
+export const cleanupMediaTitle = (title?: string): string | undefined => {
+ if (!title) {
+ return undefined;
+ }
+
+ const index = title.indexOf("?authSig=");
+ return index > 0 ? title.slice(0, index) : title;
+};
diff --git a/src/data/media_source.ts b/src/data/media_source.ts
new file mode 100644
index 0000000000..0b2b70b989
--- /dev/null
+++ b/src/data/media_source.ts
@@ -0,0 +1,15 @@
+import { HomeAssistant } from "../types";
+
+export interface ResolvedMediaSource {
+ url: string;
+ mime_type: string;
+}
+
+export const resolveMediaSource = (
+ hass: HomeAssistant,
+ media_content_id: string
+) =>
+ hass.callWS({
+ type: "media_source/resolve_media",
+ media_content_id,
+ });
diff --git a/src/data/scene.ts b/src/data/scene.ts
index 568db76998..f97cc2fa59 100644
--- a/src/data/scene.ts
+++ b/src/data/scene.ts
@@ -6,13 +6,15 @@ import { navigate } from "../common/navigate";
import { HomeAssistant, ServiceCallResponse } from "../types";
export const SCENE_IGNORED_DOMAINS = [
- "sensor",
"binary_sensor",
- "device_tracker",
- "person",
- "persistent_notification",
+ "button",
"configuration",
+ "device_tracker",
"image_processing",
+ "input_button",
+ "persistent_notification",
+ "person",
+ "sensor",
"sun",
"weather",
"zone",
diff --git a/src/data/supervisor/core.ts b/src/data/supervisor/core.ts
index 2d0cec2df7..5e816a8822 100644
--- a/src/data/supervisor/core.ts
+++ b/src/data/supervisor/core.ts
@@ -6,15 +6,18 @@ export const restartCore = async (hass: HomeAssistant) => {
await hass.callService("homeassistant", "restart");
};
-export const updateCore = async (hass: HomeAssistant) => {
+export const updateCore = async (hass: HomeAssistant, backup: boolean) => {
if (atLeastVersion(hass.config.version, 2021, 2, 4)) {
await hass.callWS({
type: "supervisor/api",
endpoint: "/core/update",
method: "post",
timeout: null,
+ data: { backup },
});
} else {
- await hass.callApi>("POST", `hassio/core/update`);
+ await hass.callApi>("POST", `hassio/core/update`, {
+ backup,
+ });
}
};
diff --git a/src/data/supervisor/root.ts b/src/data/supervisor/root.ts
new file mode 100644
index 0000000000..51fe449ecd
--- /dev/null
+++ b/src/data/supervisor/root.ts
@@ -0,0 +1,58 @@
+import { HomeAssistant } from "../../types";
+
+interface SupervisorBaseAvailableUpdates {
+ panel_path?: string;
+ update_type?: string;
+ version_latest?: string;
+}
+
+interface SupervisorAddonAvailableUpdates
+ extends SupervisorBaseAvailableUpdates {
+ update_type?: "addon";
+ icon?: string;
+ name?: string;
+}
+
+interface SupervisorCoreAvailableUpdates
+ extends SupervisorBaseAvailableUpdates {
+ update_type?: "core";
+}
+
+interface SupervisorOsAvailableUpdates extends SupervisorBaseAvailableUpdates {
+ update_type?: "os";
+}
+
+interface SupervisorSupervisorAvailableUpdates
+ extends SupervisorBaseAvailableUpdates {
+ update_type?: "supervisor";
+}
+
+export type SupervisorAvailableUpdates =
+ | SupervisorAddonAvailableUpdates
+ | SupervisorCoreAvailableUpdates
+ | SupervisorOsAvailableUpdates
+ | SupervisorSupervisorAvailableUpdates;
+
+export interface SupervisorAvailableUpdatesResponse {
+ available_updates: SupervisorAvailableUpdates[];
+}
+
+export const fetchSupervisorAvailableUpdates = async (
+ hass: HomeAssistant
+): Promise =>
+ (
+ await hass.callWS({
+ type: "supervisor/api",
+ endpoint: "/available_updates",
+ method: "get",
+ })
+ ).available_updates;
+
+export const refreshSupervisorAvailableUpdates = async (
+ hass: HomeAssistant
+): Promise =>
+ hass.callWS({
+ type: "supervisor/api",
+ endpoint: "/refresh_updates",
+ method: "post",
+ });
diff --git a/src/data/supervisor/supervisor.ts b/src/data/supervisor/supervisor.ts
index 20926b50ce..1a38c4cbe2 100644
--- a/src/data/supervisor/supervisor.ts
+++ b/src/data/supervisor/supervisor.ts
@@ -70,42 +70,6 @@ export interface Supervisor {
localize: LocalizeFunc;
}
-interface SupervisorBaseAvailableUpdates {
- panel_path?: string;
- update_type?: string;
- version_latest?: string;
-}
-
-interface SupervisorAddonAvailableUpdates
- extends SupervisorBaseAvailableUpdates {
- update_type?: "addon";
- icon?: string;
- name?: string;
-}
-
-interface SupervisorCoreAvailableUpdates
- extends SupervisorBaseAvailableUpdates {
- update_type?: "core";
-}
-
-interface SupervisorOsAvailableUpdates extends SupervisorBaseAvailableUpdates {
- update_type?: "os";
-}
-
-interface SupervisorSupervisorAvailableUpdates
- extends SupervisorBaseAvailableUpdates {
- update_type?: "supervisor";
-}
-
-export type SupervisorAvailableUpdates =
- | SupervisorAddonAvailableUpdates
- | SupervisorCoreAvailableUpdates
- | SupervisorOsAvailableUpdates
- | SupervisorSupervisorAvailableUpdates;
-
-export interface SupervisorAvailableUpdatesResponse {
- available_updates: SupervisorAvailableUpdates[];
-}
export const supervisorApiWsRequest = (
conn: Connection,
request: supervisorApiRequest
@@ -175,14 +139,3 @@ export const subscribeSupervisorEvents = (
getSupervisorEventCollection(hass.connection, key, endpoint).subscribe(
onChange
);
-
-export const fetchSupervisorAvailableUpdates = async (
- hass: HomeAssistant
-): Promise =>
- (
- await hass.callWS({
- type: "supervisor/api",
- endpoint: "/supervisor/available_updates",
- method: "get",
- })
- ).available_updates;
diff --git a/src/data/system_log.ts b/src/data/system_log.ts
index f2acb5f82b..ced7085658 100644
--- a/src/data/system_log.ts
+++ b/src/data/system_log.ts
@@ -14,7 +14,7 @@ export interface LoggedError {
}
export const fetchSystemLog = (hass: HomeAssistant) =>
- hass.callApi("GET", "error/all");
+ hass.callWS({ type: "system_log/list" });
export const getLoggedErrorIntegration = (item: LoggedError) => {
// Try to derive from logger name
diff --git a/src/data/user.ts b/src/data/user.ts
index c4afd4aa68..39493be53e 100644
--- a/src/data/user.ts
+++ b/src/data/user.ts
@@ -1,3 +1,9 @@
+import {
+ mdiCrownCircleOutline,
+ mdiAlphaSCircleOutline,
+ mdiHomeCircleOutline,
+ mdiCancel,
+} from "@mdi/js";
import { HomeAssistant } from "../types";
import { Credential } from "./auth";
@@ -73,7 +79,36 @@ export const computeUserInitials = (name: string) => {
.split(" ")
.slice(0, 3)
// Of each word, take first letter
- .map((s) => s.substr(0, 1))
+ .map((s) => s.substring(0, 1))
.join("")
);
};
+
+const OWNER_ICON = mdiCrownCircleOutline;
+const SYSTEM_ICON = mdiAlphaSCircleOutline;
+const LOCAL_ICON = mdiHomeCircleOutline;
+const DISABLED_ICON = mdiCancel;
+
+export const computeUserBadges = (
+ hass: HomeAssistant,
+ user: User,
+ includeSystem: boolean
+) => {
+ const labels: [string, string][] = [];
+ const translate = (key) => hass.localize(`ui.panel.config.users.${key}`);
+
+ if (user.is_owner) {
+ labels.push([OWNER_ICON, translate("is_owner")]);
+ }
+ if (includeSystem && user.system_generated) {
+ labels.push([SYSTEM_ICON, translate("is_system")]);
+ }
+ if (user.local_only) {
+ labels.push([LOCAL_ICON, translate("is_local")]);
+ }
+ if (!user.is_active) {
+ labels.push([DISABLED_ICON, translate("is_not_active")]);
+ }
+
+ return labels;
+};
diff --git a/src/data/weather.ts b/src/data/weather.ts
index be668288d1..e3ef7fa9ec 100644
--- a/src/data/weather.ts
+++ b/src/data/weather.ts
@@ -436,3 +436,19 @@ export const getWeatherStateIcon = (
return undefined;
};
+
+const DAY_IN_MILLISECONDS = 86400000;
+
+export const isForecastHourly = (
+ forecast?: ForecastAttribute[]
+): boolean | undefined => {
+ if (forecast && forecast?.length && forecast?.length > 2) {
+ const date1 = new Date(forecast[1].datetime);
+ const date2 = new Date(forecast[2].datetime);
+ const timeDiff = date2.getTime() - date1.getTime();
+
+ return timeDiff < DAY_IN_MILLISECONDS;
+ }
+
+ return undefined;
+};
diff --git a/src/data/webhook.ts b/src/data/webhook.ts
index c3d96dc11f..1332de74b2 100644
--- a/src/data/webhook.ts
+++ b/src/data/webhook.ts
@@ -4,6 +4,7 @@ export interface Webhook {
webhook_id: string;
domain: string;
name: string;
+ local_only: boolean;
}
export interface WebhookError {
code: number;
diff --git a/src/dialogs/config-flow/dialog-data-entry-flow.ts b/src/dialogs/config-flow/dialog-data-entry-flow.ts
index e7fcb1572b..c8273d7825 100644
--- a/src/dialogs/config-flow/dialog-data-entry-flow.ts
+++ b/src/dialogs/config-flow/dialog-data-entry-flow.ts
@@ -252,6 +252,7 @@ class DataEntryFlowDialog extends LitElement {
`
diff --git a/src/dialogs/config-flow/show-dialog-data-entry-flow.ts b/src/dialogs/config-flow/show-dialog-data-entry-flow.ts
index c1141d729a..b0217ec057 100644
--- a/src/dialogs/config-flow/show-dialog-data-entry-flow.ts
+++ b/src/dialogs/config-flow/show-dialog-data-entry-flow.ts
@@ -96,6 +96,7 @@ export type LoadingReason =
export interface DataEntryFlowDialogParams {
startFlowHandler?: string;
+ searchQuery?: string;
continueFlowId?: string;
dialogClosedCallback?: (params: {
flowFinished: boolean;
diff --git a/src/dialogs/config-flow/step-flow-create-entry.ts b/src/dialogs/config-flow/step-flow-create-entry.ts
index 39be634cf0..f2f1c36a53 100644
--- a/src/dialogs/config-flow/step-flow-create-entry.ts
+++ b/src/dialogs/config-flow/step-flow-create-entry.ts
@@ -8,6 +8,7 @@ import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-area-picker";
import { DataEntryFlowStepCreateEntry } from "../../data/data_entry_flow";
import {
+ computeDeviceName,
DeviceRegistryEntry,
updateDeviceRegistryEntry,
} from "../../data/device_registry";
@@ -50,7 +51,7 @@ class StepFlowCreateEntry extends LitElement {
html`
- ${device.name}
+ ${computeDeviceName(device, this.hass)}
${device.model} (${device.manufacturer})
{
const handlers: HandlerObj[] = h.map((handler) => ({
name: domainToName(this.hass.localize, handler),
@@ -66,11 +75,7 @@ class StepFlowPickHandler extends LitElement {
);
protected render(): TemplateResult {
- const handlers = this._getHandlers(
- this.handlers,
- this._filter,
- this.hass.localize
- );
+ const handlers = this._getHandlers();
return html`
${this.hass.localize("ui.panel.config.integrations.new")}
@@ -80,6 +85,7 @@ class StepFlowPickHandler extends LitElement {
.filter=${this._filter}
@value-changed=${this._filterChanged}
.label=${this.hass.localize("ui.panel.config.integrations.search")}
+ @keypress=${this._maybeSubmit}
>
0) {
+ fireEvent(this, "handler-picked", {
+ handler: handlers[0].slug,
+ });
+ }
+ }
+
static get styles(): CSSResultGroup {
return [
configFlowContentStyles,
diff --git a/src/dialogs/make-dialog-manager.ts b/src/dialogs/make-dialog-manager.ts
index 5471d4ce03..9a96b34312 100644
--- a/src/dialogs/make-dialog-manager.ts
+++ b/src/dialogs/make-dialog-manager.ts
@@ -26,6 +26,7 @@ interface ShowDialogParams
{
dialogTag: keyof HTMLElementTagNameMap;
dialogImport: () => Promise;
dialogParams: T;
+ addHistory?: boolean;
}
export interface DialogClosedParams {
@@ -124,8 +125,15 @@ export const makeDialogManager = (
element.addEventListener(
"show-dialog",
(e: HASSDomEvent>) => {
- const { dialogTag, dialogImport, dialogParams } = e.detail;
- showDialog(element, root, dialogTag, dialogParams, dialogImport);
+ const { dialogTag, dialogImport, dialogParams, addHistory } = e.detail;
+ showDialog(
+ element,
+ root,
+ dialogTag,
+ dialogParams,
+ dialogImport,
+ addHistory
+ );
}
);
};
diff --git a/src/dialogs/more-info/controls/more-info-climate.ts b/src/dialogs/more-info/controls/more-info-climate.ts
index dbae025e7e..6074e2be62 100644
--- a/src/dialogs/more-info/controls/more-info-climate.ts
+++ b/src/dialogs/more-info/controls/more-info-climate.ts
@@ -103,8 +103,9 @@ class MoreInfoClimate extends LitElement {
stateObj.attributes.temperature !== null
? html`
@@ -169,48 +175,49 @@ class MoreInfoWeather extends LitElement {
${this.hass.localize("ui.card.weather.forecast")}:
- ${this.stateObj.attributes.forecast.map(
- (item) => html`
-
- ${item.condition
- ? html`
-
- `
- : ""}
- ${!this._showValue(item.templow)
- ? html`
-
- ${formatTimeWeekday(
- new Date(item.datetime),
- this.hass.locale
- )}
-
- `
- : ""}
- ${this._showValue(item.templow)
- ? html`
-
- ${formatDateWeekday(
- new Date(item.datetime),
- this.hass.locale
- )}
-
-
- ${formatNumber(item.templow, this.hass.locale)}
- ${getWeatherUnit(this.hass, "temperature")}
-
- `
- : ""}
-
- ${this._showValue(item.temperature)
- ? `${formatNumber(item.temperature, this.hass.locale)}
- ${getWeatherUnit(this.hass, "temperature")}`
+ ${this.stateObj.attributes.forecast.map((item) =>
+ this._showValue(item.templow) || this._showValue(item.temperature)
+ ? html`
+ ${item.condition
+ ? html`
+
+ `
: ""}
-
-
- `
+ ${hourly
+ ? html`
+
+ ${formatTimeWeekday(
+ new Date(item.datetime),
+ this.hass.locale
+ )}
+
+ `
+ : html`
+
+ ${formatDateWeekday(
+ new Date(item.datetime),
+ this.hass.locale
+ )}
+
+ `}
+
+ ${this._showValue(item.templow)
+ ? `${formatNumber(item.templow, this.hass.locale)}
+ ${getWeatherUnit(this.hass, "temperature")}`
+ : hourly
+ ? ""
+ : "—"}
+
+
+ ${this._showValue(item.temperature)
+ ? `${formatNumber(item.temperature, this.hass.locale)}
+ ${getWeatherUnit(this.hass, "temperature")}`
+ : "—"}
+
+
`
+ : ""
)}
`
: ""}
diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts
index 3394c575a2..d95f5743c1 100644
--- a/src/dialogs/quick-bar/ha-quick-bar.ts
+++ b/src/dialogs/quick-bar/ha-quick-bar.ts
@@ -1,3 +1,4 @@
+import "../../components/ha-textfield";
import { Layout1d, scroll } from "@lit-labs/virtualizer";
import "@material/mwc-list/mwc-list";
import type { List } from "@material/mwc-list/mwc-list";
@@ -33,7 +34,6 @@ import {
import { debounce } from "../../common/util/debounce";
import "../../components/ha-chip";
import "../../components/ha-circular-progress";
-import "../../components/ha-dialog";
import "../../components/ha-header-bar";
import "../../components/ha-icon-button";
import { domainToName } from "../../data/integration";
@@ -95,7 +95,11 @@ export class QuickBar extends LitElement {
@state() private _done = false;
- @query("paper-input", false) private _filterInputField?: HTMLElement;
+ @state() private _narrow = false;
+
+ @state() private _hint?: string;
+
+ @query("ha-textfield", false) private _filterInputField?: HTMLElement;
private _focusSet = false;
@@ -103,6 +107,8 @@ export class QuickBar extends LitElement {
public async showDialog(params: QuickBarParams) {
this._commandMode = params.commandMode || this._toggleIfAlreadyOpened();
+ this._hint = params.hint;
+ this._narrow = matchMedia("(max-width: 600px)").matches;
this._initializeItemsIfNeeded();
this._opened = true;
}
@@ -137,63 +143,90 @@ export class QuickBar extends LitElement {
@closed=${this.closeDialog}
hideActions
>
- ${this._search}` : this._search}
- @keydown=${this._handleInputKeyDown}
- @focus=${this._setFocusFirstListItem}
- >
- ${this._commandMode
- ? html` `
- : html` `}
- ${this._search &&
- html`
-
- `}
-
+
+ ${this._search}` : this._search}
+ .icon=${true}
+ .iconTrailing=${this._search !== undefined}
+ @input=${this._handleSearchChange}
+ @keydown=${this._handleInputKeyDown}
+ @focus=${this._setFocusFirstListItem}
+ >
+ ${this._commandMode
+ ? html`
+
+ `
+ : html`
+
+ `}
+ ${this._search &&
+ html`
+
+ `}
+
+ ${this._narrow
+ ? html`
+
+ `
+ : ""}
+
${!items
? html` `
- : html`
- ${scroll({
- items,
- layout: Layout1d,
- renderItem: (item: QuickBarItem, index) =>
- this._renderItem(item, index),
- })}
- `}
+ : items.length === 0
+ ? html`
+
+ ${this.hass.localize("ui.dialogs.quick-bar.nothing_found")}
+
+ `
+ : html`
+
+ ${scroll({
+ items,
+ layout: Layout1d,
+ renderItem: (item: QuickBarItem, index) =>
+ this._renderItem(item, index),
+ })}
+
+ `}
+ ${!this._narrow && this._hint
+ ? html`${this._hint}
`
+ : ""}
`;
}
@@ -337,15 +370,29 @@ export class QuickBar extends LitElement {
}
private _handleSearchChange(ev: CustomEvent): void {
- const newFilter = ev.detail.value;
+ const newFilter = (ev.currentTarget as any).value;
const oldCommandMode = this._commandMode;
+ const oldSearch = this._search;
+ let newCommandMode: boolean;
+ let newSearch: string;
if (newFilter.startsWith(">")) {
- this._commandMode = true;
- this._search = newFilter.substring(1);
+ newCommandMode = true;
+ newSearch = newFilter.substring(1);
} else {
- this._commandMode = false;
- this._search = newFilter;
+ newCommandMode = false;
+ newSearch = newFilter;
+ }
+
+ if (oldCommandMode === newCommandMode && oldSearch === newSearch) {
+ return;
+ }
+
+ this._commandMode = newCommandMode;
+ this._search = newSearch;
+
+ if (this._hint) {
+ this._hint = undefined;
}
if (oldCommandMode !== this._commandMode) {
@@ -428,32 +475,54 @@ export class QuickBar extends LitElement {
}
private _generateReloadCommands(): CommandItem[] {
- const reloadableDomains = componentsWithService(this.hass, "reload").sort();
+ // Get all domains that have a direct "reload" service
+ const reloadableDomains = componentsWithService(this.hass, "reload");
- return reloadableDomains.map((domain) => {
- const commandItem = {
- primaryText:
- this.hass.localize(
- `ui.dialogs.quick-bar.commands.reload.${domain}`
- ) ||
- this.hass.localize(
- "ui.dialogs.quick-bar.commands.reload.reload",
- "domain",
- domainToName(this.hass.localize, domain)
- ),
- action: () => this.hass.callService(domain, "reload"),
- iconPath: mdiReload,
- categoryText: this.hass.localize(
- `ui.dialogs.quick-bar.commands.types.reload`
+ const commands = reloadableDomains.map((domain) => ({
+ primaryText:
+ this.hass.localize(`ui.dialogs.quick-bar.commands.reload.${domain}`) ||
+ this.hass.localize(
+ "ui.dialogs.quick-bar.commands.reload.reload",
+ "domain",
+ domainToName(this.hass.localize, domain)
),
- };
+ action: () => this.hass.callService(domain, "reload"),
+ iconPath: mdiReload,
+ categoryText: this.hass.localize(
+ `ui.dialogs.quick-bar.commands.types.reload`
+ ),
+ }));
- return {
- ...commandItem,
- categoryKey: "reload",
- strings: [`${commandItem.categoryText} ${commandItem.primaryText}`],
- };
+ // Add "frontend.reload_themes"
+ commands.push({
+ primaryText: this.hass.localize(
+ "ui.dialogs.quick-bar.commands.reload.themes"
+ ),
+ action: () => this.hass.callService("frontend", "reload_themes"),
+ iconPath: mdiReload,
+ categoryText: this.hass.localize(
+ "ui.dialogs.quick-bar.commands.types.reload"
+ ),
});
+
+ // Add "homeassistant.reload_core_config"
+ commands.push({
+ primaryText: this.hass.localize(
+ "ui.dialogs.quick-bar.commands.reload.core"
+ ),
+ action: () =>
+ this.hass.callService("homeassistant", "reload_core_config"),
+ iconPath: mdiReload,
+ categoryText: this.hass.localize(
+ "ui.dialogs.quick-bar.commands.types.reload"
+ ),
+ });
+
+ return commands.map((command) => ({
+ ...command,
+ categoryKey: "reload",
+ strings: [`${command.categoryText} ${command.primaryText}`],
+ }));
}
private _generateServerControlCommands(): CommandItem[] {
@@ -517,21 +586,27 @@ export class QuickBar extends LitElement {
for (const sectionKey of Object.keys(configSections)) {
for (const page of configSections[sectionKey]) {
- if (canShowPage(this.hass, page)) {
- if (page.component) {
- const info = this._getNavigationInfoFromConfig(page);
-
- // Add to list, but only if we do not already have an entry for the same path and component
- if (
- info &&
- !items.some(
- (e) => e.path === info.path && e.component === info.component
- )
- ) {
- items.push(info);
- }
- }
+ if (!canShowPage(this.hass, page)) {
+ continue;
}
+ if (!page.component) {
+ continue;
+ }
+ const info = this._getNavigationInfoFromConfig(page);
+
+ if (!info) {
+ continue;
+ }
+ // Add to list, but only if we do not already have an entry for the same path and component
+ if (
+ items.some(
+ (e) => e.path === info.path && e.component === info.component
+ )
+ ) {
+ continue;
+ }
+
+ items.push(info);
}
}
@@ -541,14 +616,15 @@ export class QuickBar extends LitElement {
private _getNavigationInfoFromConfig(
page: PageNavigation
): NavigationInfo | undefined {
- if (page.component) {
- const caption = this.hass.localize(
- `ui.dialogs.quick-bar.commands.navigation.${page.component}`
- );
+ if (!page.component) {
+ return undefined;
+ }
+ const caption = this.hass.localize(
+ `ui.dialogs.quick-bar.commands.navigation.${page.component}`
+ );
- if (page.translationKey && caption) {
- return { ...page, primaryText: caption };
- }
+ if (page.translationKey && caption) {
+ return { ...page, primaryText: caption };
}
return undefined;
@@ -605,7 +681,13 @@ export class QuickBar extends LitElement {
haStyleDialog,
css`
.heading {
- padding: 8px 20px 0px;
+ display: flex;
+ align-items: center;
+ --mdc-theme-primary: var(--primary-text-color);
+ }
+
+ .heading ha-textfield {
+ flex-grow: 1;
}
ha-dialog {
@@ -629,11 +711,10 @@ export class QuickBar extends LitElement {
}
ha-svg-icon.prefix {
- margin: 8px;
color: var(--primary-text-color);
}
- paper-input ha-icon-button {
+ ha-textfield ha-icon-button {
--mdc-icon-button-size: 24px;
color: var(--primary-text-color);
}
@@ -666,6 +747,17 @@ export class QuickBar extends LitElement {
mwc-list-item.command-item {
text-transform: capitalize;
}
+
+ .hint {
+ padding: 20px;
+ font-style: italic;
+ text-align: center;
+ }
+
+ .nothing-found {
+ padding: 16px 0px;
+ text-align: center;
+ }
`,
];
}
diff --git a/src/dialogs/quick-bar/show-dialog-quick-bar.ts b/src/dialogs/quick-bar/show-dialog-quick-bar.ts
index 09ba816341..01e2af978e 100644
--- a/src/dialogs/quick-bar/show-dialog-quick-bar.ts
+++ b/src/dialogs/quick-bar/show-dialog-quick-bar.ts
@@ -3,6 +3,7 @@ import { fireEvent } from "../../common/dom/fire_event";
export interface QuickBarParams {
entityFilter?: string;
commandMode?: boolean;
+ hint?: string;
}
export const loadQuickBar = () => import("./ha-quick-bar");
@@ -15,5 +16,6 @@ export const showQuickBar = (
dialogTag: "ha-quick-bar",
dialogImport: loadQuickBar,
dialogParams,
+ addHistory: false,
});
};
diff --git a/src/external_app/external_app_entrypoint.ts b/src/external_app/external_app_entrypoint.ts
new file mode 100644
index 0000000000..46d5a39dee
--- /dev/null
+++ b/src/external_app/external_app_entrypoint.ts
@@ -0,0 +1,52 @@
+/*
+All commands that do UI stuff need to be loaded from the app bundle as UI stuff
+in core bundle slows things down and causes duplicate registration.
+
+This is the entry point for providing external app stuff from app entrypoint.
+*/
+
+import { fireEvent } from "../common/dom/fire_event";
+import { HomeAssistantMain } from "../layouts/home-assistant-main";
+import type { EMExternalMessageCommands } from "./external_messaging";
+
+export const attachExternalToApp = (hassMainEl: HomeAssistantMain) => {
+ window.addEventListener("haptic", (ev) =>
+ hassMainEl.hass.auth.external!.fireMessage({
+ type: "haptic",
+ payload: { hapticType: ev.detail },
+ })
+ );
+
+ hassMainEl.hass.auth.external!.addCommandHandler((msg) =>
+ handleExternalMessage(hassMainEl, msg)
+ );
+};
+
+const handleExternalMessage = (
+ hassMainEl: HomeAssistantMain,
+ msg: EMExternalMessageCommands
+): boolean => {
+ const bus = hassMainEl.hass.auth.external!;
+
+ if (msg.command === "restart") {
+ hassMainEl.hass.connection.reconnect(true);
+ bus.fireMessage({
+ id: msg.id,
+ type: "result",
+ success: true,
+ result: null,
+ });
+ } else if (msg.command === "notifications/show") {
+ fireEvent(hassMainEl, "hass-show-notifications");
+ bus.fireMessage({
+ id: msg.id,
+ type: "result",
+ success: true,
+ result: null,
+ });
+ } else {
+ return false;
+ }
+
+ return true;
+};
diff --git a/src/external_app/external_auth.ts b/src/external_app/external_auth.ts
index 1917e4bec2..26b1f1f892 100644
--- a/src/external_app/external_auth.ts
+++ b/src/external_app/external_auth.ts
@@ -128,14 +128,14 @@ export class ExternalAuth extends Auth {
}
}
-export const createExternalAuth = (hassUrl: string) => {
+export const createExternalAuth = async (hassUrl: string) => {
const auth = new ExternalAuth(hassUrl);
if (
(window.externalApp && window.externalApp.externalBus) ||
(window.webkit && window.webkit.messageHandlers.externalBus)
) {
auth.external = new ExternalMessaging();
- auth.external.attach();
+ await auth.external.attach();
}
return auth;
};
diff --git a/src/external_app/external_config.ts b/src/external_app/external_config.ts
deleted file mode 100644
index 27728deaf0..0000000000
--- a/src/external_app/external_config.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { ExternalMessaging } from "./external_messaging";
-
-export interface ExternalConfig {
- hasSettingsScreen: boolean;
- canWriteTag: boolean;
- hasExoPlayer: boolean;
-}
-
-export const getExternalConfig = (
- bus: ExternalMessaging
-): Promise => {
- if (!bus.cache.cfg) {
- bus.cache.cfg = bus.sendMessage({
- type: "config/get",
- });
- }
- return bus.cache.cfg;
-};
diff --git a/src/external_app/external_events_forwarder.ts b/src/external_app/external_events_forwarder.ts
deleted file mode 100644
index 4ac2258105..0000000000
--- a/src/external_app/external_events_forwarder.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { ExternalMessaging } from "./external_messaging";
-
-export const externalForwardConnectionEvents = (bus: ExternalMessaging) => {
- window.addEventListener("connection-status", (ev) =>
- bus.fireMessage({
- type: "connection-status",
- payload: { event: ev.detail },
- })
- );
-};
-
-export const externalForwardHaptics = (bus: ExternalMessaging) =>
- window.addEventListener("haptic", (ev) =>
- bus.fireMessage({ type: "haptic", payload: { hapticType: ev.detail } })
- );
diff --git a/src/external_app/external_messaging.ts b/src/external_app/external_messaging.ts
index 7dd03095cb..fdab1ab2eb 100644
--- a/src/external_app/external_messaging.ts
+++ b/src/external_app/external_messaging.ts
@@ -1,9 +1,3 @@
-import { Connection } from "home-assistant-js-websocket";
-import {
- externalForwardConnectionEvents,
- externalForwardHaptics,
-} from "./external_events_forwarder";
-
const CALLBACK_EXTERNAL_BUS = "externalBus";
interface CommandInFlight {
@@ -42,24 +36,54 @@ interface EMExternalMessageRestart {
command: "restart";
}
+interface EMExternMessageShowNotifications {
+ id: number;
+ type: "command";
+ command: "notifications/show";
+}
+
+export type EMExternalMessageCommands =
+ | EMExternalMessageRestart
+ | EMExternMessageShowNotifications;
+
type ExternalMessage =
| EMMessageResultSuccess
| EMMessageResultError
- | EMExternalMessageRestart;
+ | EMExternalMessageCommands;
+
+type ExternalMessageHandler = (msg: EMExternalMessageCommands) => boolean;
+
+export interface ExternalConfig {
+ hasSettingsScreen: boolean;
+ hasSidebar: boolean;
+ canWriteTag: boolean;
+ hasExoPlayer: boolean;
+}
export class ExternalMessaging {
+ public config!: ExternalConfig;
+
public commands: { [msgId: number]: CommandInFlight } = {};
- public connection?: Connection;
-
- public cache: Record = {};
-
public msgId = 0;
- public attach() {
- externalForwardConnectionEvents(this);
- externalForwardHaptics(this);
+ private _commandHandler?: ExternalMessageHandler;
+
+ public async attach() {
window[CALLBACK_EXTERNAL_BUS] = (msg) => this.receiveMessage(msg);
+ window.addEventListener("connection-status", (ev) =>
+ this.fireMessage({
+ type: "connection-status",
+ payload: { event: ev.detail },
+ })
+ );
+ this.config = await this.sendMessage({
+ type: "config/get",
+ });
+ }
+
+ public addCommandHandler(handler: ExternalMessageHandler) {
+ this._commandHandler = handler;
}
/**
@@ -97,36 +121,25 @@ export class ExternalMessaging {
}
if (msg.type === "command") {
- if (!this.connection) {
+ if (!this._commandHandler || !this._commandHandler(msg)) {
+ let code: string;
+ let message: string;
+ if (this._commandHandler) {
+ code = "not_ready";
+ message = "Command handler not ready";
+ } else {
+ code = "unknown_command";
+ message = `Unknown command ${msg.command}`;
+ }
// eslint-disable-next-line no-console
- console.warn("Received command without having connection set", msg);
+ console.warn(message, msg);
this.fireMessage({
id: msg.id,
type: "result",
success: false,
error: {
- code: "commands_not_init",
- message: `Commands connection not set`,
- },
- });
- } else if (msg.command === "restart") {
- this.connection.reconnect(true);
- this.fireMessage({
- id: msg.id,
- type: "result",
- success: true,
- result: null,
- });
- } else {
- // eslint-disable-next-line no-console
- console.warn("Received unknown command", msg.command, msg);
- this.fireMessage({
- id: msg.id,
- type: "result",
- success: false,
- error: {
- code: "unknown_command",
- message: `Unknown command ${msg.command}`,
+ code,
+ message,
},
});
}
diff --git a/src/fake_data/entity.ts b/src/fake_data/entity.ts
index 89bdbfb5b0..c7c31d5702 100644
--- a/src/fake_data/entity.ts
+++ b/src/fake_data/entity.ts
@@ -247,6 +247,44 @@ class InputNumberEntity extends Entity {
}
}
+class InputTextEntity extends Entity {
+ public async handleService(
+ domain,
+ service,
+ // @ts-ignore
+ data
+ ) {
+ if (domain !== this.domain) {
+ return;
+ }
+
+ if (service === "set_value") {
+ this.update("" + data.value);
+ } else {
+ super.handleService(domain, service, data);
+ }
+ }
+}
+
+class InputSelectEntity extends Entity {
+ public async handleService(
+ domain,
+ service,
+ // @ts-ignore
+ data
+ ) {
+ if (domain !== this.domain) {
+ return;
+ }
+
+ if (service === "select_option") {
+ this.update("" + data.option);
+ } else {
+ super.handleService(domain, service, data);
+ }
+ }
+}
+
class ClimateEntity extends Entity {
public async handleService(domain, service, data) {
if (domain !== this.domain) {
@@ -301,6 +339,8 @@ const TYPES = {
group: GroupEntity,
input_boolean: ToggleEntity,
input_number: InputNumberEntity,
+ input_text: InputTextEntity,
+ input_select: InputSelectEntity,
light: LightEntity,
lock: LockEntity,
media_player: MediaPlayerEntity,
diff --git a/src/html/_style_base.html.template b/src/html/_style_base.html.template
index 21d047c1b8..e1e60a32b6 100644
--- a/src/html/_style_base.html.template
+++ b/src/html/_style_base.html.template
@@ -1,4 +1,4 @@
-
+