diff --git a/.eslintrc.json b/.eslintrc.json index 49546398a7..9cfa58084a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -75,13 +75,16 @@ "object-curly-newline": 0, "default-case": 0, "wc/no-self-class": 0, + "no-shadow": 0, "@typescript-eslint/camelcase": 0, - "@typescript-eslint/ban-ts-ignore": 0, + "@typescript-eslint/ban-ts-comment": 0, "@typescript-eslint/no-use-before-define": 0, "@typescript-eslint/no-non-null-assertion": 0, "@typescript-eslint/no-explicit-any": 0, "@typescript-eslint/no-unused-vars": 0, - "@typescript-eslint/explicit-function-return-type": 0 + "@typescript-eslint/explicit-function-return-type": 0, + "@typescript-eslint/explicit-module-boundary-types": 0, + "@typescript-eslint/no-shadow": ["error"] }, "plugins": ["disable", "import", "lit", "prettier", "@typescript-eslint"], "processor": "disable/disable" diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md deleted file mode 100644 index 640740f5ac..0000000000 --- a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: Request a feature for the UI, Frontend or Lovelace -about: Request an new feature for the Home Assistant frontend. -labels: feature request ---- - - - -## The request - - - -## The alternatives - - - -## Additional information diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index b7fc0dc4fb..7468455df2 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: + - name: Request a feature for the UI, Frontend or Lovelace + url: https://github.com/home-assistant/frontend/discussions/category_choices + about: Request an new feature for the Home Assistant frontend. - name: Report a bug that is NOT related to the UI, Frontend or Lovelace url: https://github.com/home-assistant/core/issues about: This is the issue tracker for our frontend. Please report other issues with the backend repository. diff --git a/README.md b/README.md index c5b6edde12..c793fb3453 100644 --- a/README.md +++ b/README.md @@ -26,4 +26,4 @@ A complete guide can be found at the following [link](https://www.home-assistant Home Assistant is open-source and Apache 2 licensed. Feel free to browse the repository, learn and reuse parts in your own projects. -We use [BrowserStack](https://www.browserstack.com) to test Home Assistant on a large variation of devices. +We use [BrowserStack](https://www.browserstack.com) to test Home Assistant on a large variety of devices. diff --git a/build-scripts/bundle.js b/build-scripts/bundle.js index 50e24db416..41f60aa66d 100644 --- a/build-scripts/bundle.js +++ b/build-scripts/bundle.js @@ -52,7 +52,13 @@ module.exports.terserOptions = (latestBuild) => ({ module.exports.babelOptions = ({ latestBuild }) => ({ babelrc: false, presets: [ - !latestBuild && [require("@babel/preset-env").default, { modules: false }], + !latestBuild && [ + require("@babel/preset-env").default, + { + useBuiltIns: "entry", + corejs: "3.6", + }, + ], require("@babel/preset-typescript").default, ].filter(Boolean), plugins: [ @@ -62,7 +68,8 @@ module.exports.babelOptions = ({ latestBuild }) => ({ { loose: true, useBuiltIns: true }, ], // Only support the syntax, Webpack will handle it. - "@babel/syntax-dynamic-import", + "@babel/plugin-syntax-import-meta", + "@babel/plugin-syntax-dynamic-import", "@babel/plugin-proposal-optional-chaining", "@babel/plugin-proposal-nullish-coalescing-operator", [ diff --git a/build-scripts/gulp/translations.js b/build-scripts/gulp/translations.js index 19d329a7e0..e357d38edf 100755 --- a/build-scripts/gulp/translations.js +++ b/build-scripts/gulp/translations.js @@ -7,7 +7,6 @@ const gulp = require("gulp"); const fs = require("fs"); const foreach = require("gulp-foreach"); const merge = require("gulp-merge-json"); -const minify = require("gulp-jsonminify"); const rename = require("gulp-rename"); const transform = require("gulp-json-transform"); const { mapFiles } = require("../util"); @@ -301,7 +300,6 @@ gulp.task("build-flattened-translations", function () { return flatten(data); }) ) - .pipe(minify()) .pipe( rename((filePath) => { if (filePath.dirname === "core") { diff --git a/build-scripts/webpack.js b/build-scripts/webpack.js index ee2fdc406e..65d4287a34 100644 --- a/build-scripts/webpack.js +++ b/build-scripts/webpack.js @@ -2,7 +2,6 @@ const webpack = require("webpack"); const path = require("path"); const TerserPlugin = require("terser-webpack-plugin"); const ManifestPlugin = require("webpack-manifest-plugin"); -const WorkerPlugin = require("worker-plugin"); const paths = require("./paths.js"); const bundle = require("./bundle"); @@ -30,7 +29,7 @@ const createWebpackConfig = ({ module: { rules: [ { - test: /\.js$|\.ts$/, + test: /\.m?js$|\.ts$/, exclude: bundle.babelExclude(), use: { loader: "babel-loader", @@ -46,16 +45,13 @@ const createWebpackConfig = ({ optimization: { minimizer: [ new TerserPlugin({ - cache: true, parallel: true, extractComments: true, - sourceMap: true, terserOptions: bundle.terserOptions(latestBuild), }), ], }, plugins: [ - new WorkerPlugin(), new ManifestPlugin({ // Only include the JS of entrypoints filter: (file) => file.isInitial && !file.name.endsWith(".map"), @@ -99,6 +95,15 @@ const createWebpackConfig = ({ new RegExp(bundle.emptyPackages({ latestBuild }).join("|")), path.resolve(paths.polymer_dir, "src/util/empty.js") ), + // We need to change the import of the polyfill for EventTarget, so we replace the polyfill file with our customized one + new webpack.NormalModuleReplacementPlugin( + new RegExp( + require.resolve( + "lit-virtualizer/lib/uni-virtualizer/lib/polyfillLoaders/EventTarget.js" + ) + ), + path.resolve(paths.polymer_dir, "src/resources/EventTarget-ponyfill.js") + ), ], resolve: { extensions: [".ts", ".js", ".json"], @@ -110,6 +115,22 @@ const createWebpackConfig = ({ } return `${chunk.name}.${chunk.hash.substr(0, 8)}.js`; }, + environment: { + // The environment supports arrow functions ('() => { ... }'). + arrowFunction: latestBuild, + // The environment supports BigInt as literal (123n). + bigIntLiteral: false, + // The environment supports const and let for variable declarations. + const: latestBuild, + // The environment supports destructuring ('{ a, b } = obj'). + destructuring: latestBuild, + // The environment supports an async import() function to import EcmaScript modules. + dynamicImport: latestBuild, + // The environment supports 'for of' iteration ('for (const x of array) { ... }'). + forOf: latestBuild, + // The environment supports ECMAScript Module syntax to import ECMAScript modules (import ... from '...'). + module: latestBuild, + }, chunkFilename: isProdBuild && !isStatsBuild ? "chunk.[chunkhash].js" diff --git a/cast/src/launcher/layout/hc-layout.ts b/cast/src/launcher/layout/hc-layout.ts index f9d82223b2..1c7a850a30 100644 --- a/cast/src/launcher/layout/hc-layout.ts +++ b/cast/src/launcher/layout/hc-layout.ts @@ -30,7 +30,7 @@ class HcLayout extends LitElement {
-
+

Home Assistant Cast${this.subtitle ? ` – ${this.subtitle}` : ""} ${this.auth ? html` @@ -44,7 +44,7 @@ class HcLayout extends LitElement {

` : ""} -
+
diff --git a/cast/src/receiver/entrypoint.ts b/cast/src/receiver/entrypoint.ts index 2807d597a7..e5c9791276 100644 --- a/cast/src/receiver/entrypoint.ts +++ b/cast/src/receiver/entrypoint.ts @@ -45,6 +45,8 @@ const showMediaPlayer = () => { style.innerHTML = ` body { --logo-image: url('https://www.home-assistant.io/images/home-assistant-logo.svg'); + --logo-repeat: no-repeat; + --playback-logo-image: url('https://www.home-assistant.io/images/home-assistant-logo.svg'); --theme-hue: 200; --progress-color: #03a9f4; --splash-image: url('https://home-assistant.io/images/cast/splash.png'); diff --git a/cast/src/receiver/layout/hc-lovelace.ts b/cast/src/receiver/layout/hc-lovelace.ts index 0151f39073..3812da11d0 100644 --- a/cast/src/receiver/layout/hc-lovelace.ts +++ b/cast/src/receiver/layout/hc-lovelace.ts @@ -49,7 +49,6 @@ class HcLovelace extends LitElement { .hass=${this.hass} .lovelace=${lovelace} .index=${index} - columns="2" > `; } @@ -67,7 +66,7 @@ class HcLovelace extends LitElement { if (configBackground) { (this.shadowRoot!.querySelector( - "hui-view, hui-panel-view" + "hui-view" ) as HTMLElement)!.style.setProperty( "--lovelace-background", configBackground diff --git a/demo/src/configs/teachingbirds/lovelace.ts b/demo/src/configs/teachingbirds/lovelace.ts index f7684fde6d..135dde6a0b 100644 --- a/demo/src/configs/teachingbirds/lovelace.ts +++ b/demo/src/configs/teachingbirds/lovelace.ts @@ -7,205 +7,183 @@ export const demoLovelaceTeachingbirds: DemoConfig["lovelace"] = () => ({ cards: [ { type: "custom:ha-demo-card" }, { + type: "grid", + columns: 4, cards: [ { - cards: [ + image: "/assets/teachingbirds/isa_square.jpg", + type: "picture-entity", + show_name: false, + tap_action: { + action: "more-info", + }, + entity: "sensor.presence_isa", + }, + { + image: "/assets/teachingbirds/Stefan_square.jpg", + type: "picture-entity", + show_name: false, + tap_action: { + action: "more-info", + }, + entity: "sensor.presence_stefan", + }, + { + image: "/assets/teachingbirds/background_square.png", + elements: [ { - image: "/assets/teachingbirds/isa_square.jpg", - type: "picture-entity", - show_name: false, + state_image: { + on: "/assets/teachingbirds/radiator_on.jpg", + off: "/assets/teachingbirds/radiator_off.jpg", + }, + type: "image", + style: { + width: "100%", + top: "50%", + left: "50%", + }, tap_action: { action: "more-info", }, - entity: "sensor.presence_isa", + entity: "switch.stefan_radiator_3", }, { - image: "/assets/teachingbirds/Stefan_square.jpg", - type: "picture-entity", - show_name: false, - tap_action: { - action: "more-info", + style: { + top: "90%", + left: "50%", }, - entity: "sensor.presence_stefan", - }, - { - image: "/assets/teachingbirds/background_square.png", - elements: [ - { - state_image: { - on: "/assets/teachingbirds/radiator_on.jpg", - off: "/assets/teachingbirds/radiator_off.jpg", - }, - type: "image", - style: { - width: "100%", - top: "50%", - left: "50%", - }, - tap_action: { - action: "more-info", - }, - entity: "switch.stefan_radiator_3", - }, - { - style: { - top: "90%", - left: "50%", - }, - type: "state-label", - entity: "sensor.temperature_stefan", - }, - ], - type: "picture-elements", - }, - { - image: "/assets/teachingbirds/background_square.png", - elements: [ - { - style: { - "--mdc-icon-size": "100%", - top: "50%", - left: "50%", - }, - type: "icon", - tap_action: { - action: "navigate", - navigation_path: "/lovelace/home_info", - }, - icon: "mdi:car", - }, - ], - type: "picture-elements", - }, - ], - type: "horizontal-stack", - }, - { - cards: [ - { - show_name: false, - type: "picture-entity", - name: "Alarm", - image: "/assets/teachingbirds/House_square.jpg", - entity: "alarm_control_panel.house", - }, - { - name: "Roomba", - image: "/assets/teachingbirds/roomba_square.jpg", - show_name: false, - type: "picture-entity", - state_image: { - "Not Today": "/assets/teachingbirds/roomba_bw_square.jpg", - }, - entity: "input_select.roomba_mode", - }, - { - show_name: false, - type: "picture-entity", - state_image: { - Mail: "/assets/teachingbirds/mailbox_square.jpg", - "Package and mail": - "/assets/teachingbirds/mailbox_square.jpg", - Empty: "/assets/teachingbirds/mailbox_bw_square.jpg", - Package: "/assets/teachingbirds/mailbox_square.jpg", - }, - entity: "sensor.mailbox", - }, - { - show_name: false, - state_image: { - "Put out": "/assets/teachingbirds/trash_square.jpg", - "Take in": "/assets/teachingbirds/trash_square.jpg", - }, - type: "picture-entity", - image: "/assets/teachingbirds/trash_bear_bw_square.jpg", - entity: "sensor.trash_status", - }, - ], - type: "horizontal-stack", - }, - { - cards: [ - { - state_image: { - Idle: "/assets/teachingbirds/washer_square.jpg", - Running: "/assets/teachingbirds/laundry_running_square.jpg", - Clean: "/assets/teachingbirds/laundry_clean_2_square.jpg", - }, - entity: "input_select.washing_machine_status", - type: "picture-entity", - show_name: false, - name: "Washer", - }, - { - state_image: { - Idle: "/assets/teachingbirds/dryer_square.jpg", - Running: "/assets/teachingbirds/clothes_drying_square.jpg", - Clean: "/assets/teachingbirds/folded_clothes_square.jpg", - }, - entity: "input_select.dryer_status", - type: "picture-entity", - show_name: false, - name: "Dryer", - }, - { - image: "/assets/teachingbirds/guests_square.jpg", - type: "picture-entity", - show_name: false, - tap_action: { - action: "toggle", - }, - entity: "input_boolean.guest_mode", - }, - { - image: "/assets/teachingbirds/cleaning_square.jpg", - type: "picture-entity", - show_name: false, - tap_action: { - action: "toggle", - }, - entity: "input_boolean.cleaning_day", - }, - ], - type: "horizontal-stack", - }, - ], - type: "vertical-stack", - }, - { - type: "vertical-stack", - cards: [ - { - cards: [ - { - graph: "line", - type: "sensor", - entity: "sensor.temperature_bedroom", - }, - { - graph: "line", - type: "sensor", - name: "S's room", + type: "state-label", entity: "sensor.temperature_stefan", }, ], - type: "horizontal-stack", + type: "picture-elements", }, { - cards: [ + image: "/assets/teachingbirds/background_square.png", + elements: [ { - graph: "line", - type: "sensor", - entity: "sensor.temperature_passage", - }, - { - graph: "line", - type: "sensor", - name: "Laundry", - entity: "sensor.temperature_downstairs_bathroom", + style: { + "--mdc-icon-size": "100%", + top: "50%", + left: "50%", + }, + type: "icon", + tap_action: { + action: "navigate", + navigation_path: "/lovelace/home_info", + }, + icon: "mdi:car", }, ], - type: "horizontal-stack", + type: "picture-elements", + }, + + { + show_name: false, + type: "picture-entity", + name: "Alarm", + image: "/assets/teachingbirds/House_square.jpg", + entity: "alarm_control_panel.house", + }, + { + name: "Roomba", + image: "/assets/teachingbirds/roomba_square.jpg", + show_name: false, + type: "picture-entity", + state_image: { + "Not Today": "/assets/teachingbirds/roomba_bw_square.jpg", + }, + entity: "input_select.roomba_mode", + }, + { + show_name: false, + type: "picture-entity", + state_image: { + Mail: "/assets/teachingbirds/mailbox_square.jpg", + "Package and mail": "/assets/teachingbirds/mailbox_square.jpg", + Empty: "/assets/teachingbirds/mailbox_bw_square.jpg", + Package: "/assets/teachingbirds/mailbox_square.jpg", + }, + entity: "sensor.mailbox", + }, + { + show_name: false, + state_image: { + "Put out": "/assets/teachingbirds/trash_square.jpg", + "Take in": "/assets/teachingbirds/trash_square.jpg", + }, + type: "picture-entity", + image: "/assets/teachingbirds/trash_bear_bw_square.jpg", + entity: "sensor.trash_status", + }, + + { + state_image: { + Idle: "/assets/teachingbirds/washer_square.jpg", + Running: "/assets/teachingbirds/laundry_running_square.jpg", + Clean: "/assets/teachingbirds/laundry_clean_2_square.jpg", + }, + entity: "input_select.washing_machine_status", + type: "picture-entity", + show_name: false, + name: "Washer", + }, + { + state_image: { + Idle: "/assets/teachingbirds/dryer_square.jpg", + Running: "/assets/teachingbirds/clothes_drying_square.jpg", + Clean: "/assets/teachingbirds/folded_clothes_square.jpg", + }, + entity: "input_select.dryer_status", + type: "picture-entity", + show_name: false, + name: "Dryer", + }, + { + image: "/assets/teachingbirds/guests_square.jpg", + type: "picture-entity", + show_name: false, + tap_action: { + action: "toggle", + }, + entity: "input_boolean.guest_mode", + }, + { + image: "/assets/teachingbirds/cleaning_square.jpg", + type: "picture-entity", + show_name: false, + tap_action: { + action: "toggle", + }, + entity: "input_boolean.cleaning_day", + }, + ], + }, + { + type: "grid", + columns: 2, + cards: [ + { + graph: "line", + type: "sensor", + entity: "sensor.temperature_bedroom", + }, + { + graph: "line", + type: "sensor", + name: "S's room", + entity: "sensor.temperature_stefan", + }, + { + graph: "line", + type: "sensor", + entity: "sensor.temperature_passage", + }, + { + graph: "line", + type: "sensor", + name: "Laundry", + entity: "sensor.temperature_downstairs_bathroom", }, ], }, diff --git a/demo/src/stubs/template.ts b/demo/src/stubs/template.ts index 726926461c..0e3c1a2638 100644 --- a/demo/src/stubs/template.ts +++ b/demo/src/stubs/template.ts @@ -6,4 +6,11 @@ export const mockTemplate = (hass: MockHomeAssistant) => { body: { message: "Template dev tool does not work in the demo." }, }) ); + hass.mockWS("render_template", (msg, onChange) => { + onChange!({ + result: msg.template, + listeners: { all: false, domains: [], entities: [], time: false }, + }); + return () => {}; + }); }; diff --git a/gallery/src/components/demo-cards.js b/gallery/src/components/demo-cards.js index 26ac82e3f7..9e076371c4 100644 --- a/gallery/src/components/demo-cards.js +++ b/gallery/src/components/demo-cards.js @@ -5,11 +5,16 @@ import { PolymerElement } from "@polymer/polymer/polymer-element"; import "../../../src/components/ha-switch"; import "../../../src/components/ha-formfield"; import "./demo-card"; +import { applyThemesOnElement } from "../../../src/common/dom/apply_themes_on_element"; class DemoCards extends PolymerElement { static get template() { return html`
@@ -31,16 +39,21 @@ class DemoCards extends PolymerElement { + + +
-
- +
+
+ +
`; } @@ -59,6 +72,12 @@ class DemoCards extends PolymerElement { _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-more-info.js b/gallery/src/components/demo-more-info.js index 9e4a34023f..0805e77c37 100644 --- a/gallery/src/components/demo-more-info.js +++ b/gallery/src/components/demo-more-info.js @@ -3,7 +3,7 @@ import { html } from "@polymer/polymer/lib/utils/html-tag"; import { PolymerElement } from "@polymer/polymer/polymer-element"; import "../../../src/components/ha-card"; import "../../../src/state-summary/state-card-content"; -import "./more-info-content"; +import "../../../src/dialogs/more-info/more-info-content"; class DemoMoreInfo extends PolymerElement { static get template() { @@ -16,15 +16,12 @@ class DemoMoreInfo extends PolymerElement { ha-card { width: 333px; + padding: 20px 24px; } state-card-content { display: block; - padding: 16px; - } - - more-info-content { - padding: 0 16px; + margin-bottom: 16px; } pre { diff --git a/gallery/src/components/more-info-content.ts b/gallery/src/components/more-info-content.ts deleted file mode 100644 index 549ac4fa2d..0000000000 --- a/gallery/src/components/more-info-content.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { HassEntity } from "home-assistant-js-websocket"; -import { property, PropertyValues, UpdatingElement } from "lit-element"; -import dynamicContentUpdater from "../../../src/common/dom/dynamic_content_updater"; -import { stateMoreInfoType } from "../../../src/dialogs/more-info/state_more_info_control"; -import "../../../src/dialogs/more-info/controls/more-info-alarm_control_panel"; -import "../../../src/dialogs/more-info/controls/more-info-automation"; -import "../../../src/dialogs/more-info/controls/more-info-camera"; -import "../../../src/dialogs/more-info/controls/more-info-climate"; -import "../../../src/dialogs/more-info/controls/more-info-configurator"; -import "../../../src/dialogs/more-info/controls/more-info-counter"; -import "../../../src/dialogs/more-info/controls/more-info-cover"; -import "../../../src/dialogs/more-info/controls/more-info-default"; -import "../../../src/dialogs/more-info/controls/more-info-fan"; -import "../../../src/dialogs/more-info/controls/more-info-group"; -import "../../../src/dialogs/more-info/controls/more-info-humidifier"; -import "../../../src/dialogs/more-info/controls/more-info-input_datetime"; -import "../../../src/dialogs/more-info/controls/more-info-light"; -import "../../../src/dialogs/more-info/controls/more-info-lock"; -import "../../../src/dialogs/more-info/controls/more-info-media_player"; -import "../../../src/dialogs/more-info/controls/more-info-person"; -import "../../../src/dialogs/more-info/controls/more-info-script"; -import "../../../src/dialogs/more-info/controls/more-info-sun"; -import "../../../src/dialogs/more-info/controls/more-info-timer"; -import "../../../src/dialogs/more-info/controls/more-info-vacuum"; -import "../../../src/dialogs/more-info/controls/more-info-water_heater"; -import "../../../src/dialogs/more-info/controls/more-info-weather"; -import { HomeAssistant } from "../../../src/types"; - -class MoreInfoContent extends UpdatingElement { - @property({ attribute: false }) public hass?: HomeAssistant; - - @property() public stateObj?: HassEntity; - - private _detachedChild?: ChildNode; - - protected firstUpdated(): void { - this.style.position = "relative"; - this.style.display = "block"; - } - - // This is not a lit element, but an updating element, so we implement update - protected update(changedProps: PropertyValues): void { - super.update(changedProps); - const stateObj = this.stateObj; - const hass = this.hass; - - if (!stateObj || !hass) { - if (this.lastChild) { - this._detachedChild = this.lastChild; - // Detach child to prevent it from doing work. - this.removeChild(this.lastChild); - } - return; - } - - if (this._detachedChild) { - this.appendChild(this._detachedChild); - this._detachedChild = undefined; - } - - const moreInfoType = - stateObj.attributes && "custom_ui_more_info" in stateObj.attributes - ? stateObj.attributes.custom_ui_more_info - : "more-info-" + stateMoreInfoType(stateObj); - - dynamicContentUpdater(this, moreInfoType.toUpperCase(), { - hass, - stateObj, - }); - } -} - -customElements.define("more-info-content", MoreInfoContent); diff --git a/gallery/src/demos/demo-hui-alarm-panel-card.ts b/gallery/src/demos/demo-hui-alarm-panel-card.ts index 12b5f130b4..73d46b53fd 100644 --- a/gallery/src/demos/demo-hui-alarm-panel-card.ts +++ b/gallery/src/demos/demo-hui-alarm-panel-card.ts @@ -15,6 +15,10 @@ const ENTITIES = [ getEntity("alarm_control_panel", "unavailable", "unavailable", { friendly_name: "Alarm", }), + getEntity("alarm_control_panel", "alarm_code", "disarmed", { + friendly_name: "Alarm", + code_format: "number", + }), ]; const CONFIGS = [ @@ -30,7 +34,14 @@ const CONFIGS = [ config: ` - type: alarm-panel entity: alarm_control_panel.alarm_armed - title: My Alarm + name: My Alarm + `, + }, + { + heading: "Code Example", + config: ` +- type: alarm-panel + entity: alarm_control_panel.alarm_code `, }, { @@ -83,8 +94,12 @@ class DemoAlarmPanelEntity extends PolymerElement { public ready() { super.ready(); + this._setupDemo(); + } + + private async _setupDemo() { const hass = provideHass(this.$.demos); - hass.updateTranslations(null, "en"); + await hass.updateTranslations(null, "en"); hass.addEntities(ENTITIES); } } diff --git a/gallery/src/demos/demo-hui-entity-button-card.ts b/gallery/src/demos/demo-hui-entity-button-card.ts index 8c4c77e100..5b89327432 100644 --- a/gallery/src/demos/demo-hui-entity-button-card.ts +++ b/gallery/src/demos/demo-hui-entity-button-card.ts @@ -98,4 +98,4 @@ class DemoButtonEntity extends PolymerElement { } } -customElements.define("demo-hui-button-card", DemoButtonEntity); +customElements.define("demo-hui-entity-button-card", DemoButtonEntity); diff --git a/gallery/src/demos/demo-hui-gauge-card.ts b/gallery/src/demos/demo-hui-gauge-card.ts index 2963cab228..5dd0cf582b 100644 --- a/gallery/src/demos/demo-hui-gauge-card.ts +++ b/gallery/src/demos/demo-hui-gauge-card.ts @@ -8,6 +8,7 @@ import "../components/demo-cards"; const ENTITIES = [ getEntity("sensor", "brightness", "12", {}), getEntity("plant", "bonsai", "ok", {}), + getEntity("sensor", "not_working", "unavailable", {}), getEntity("sensor", "outside_humidity", "54", { unit_of_measurement: "%", }), @@ -74,6 +75,13 @@ const CONFIGS = [ entity: plant.bonsai `, }, + { + heading: "Unavailable entity", + config: ` +- type: gauge + entity: sensor.not_working + `, + }, ]; class DemoGaugeEntity extends PolymerElement { diff --git a/gallery/src/demos/demo-hui-markdown-card.ts b/gallery/src/demos/demo-hui-markdown-card.ts index 07e974316e..1ae6b5f0f7 100644 --- a/gallery/src/demos/demo-hui-markdown-card.ts +++ b/gallery/src/demos/demo-hui-markdown-card.ts @@ -1,6 +1,8 @@ import { html } from "@polymer/polymer/lib/utils/html-tag"; /* eslint-plugin-disable lit */ import { PolymerElement } from "@polymer/polymer/polymer-element"; +import { mockTemplate } from "../../../demo/src/stubs/template"; +import { provideHass } from "../../../src/fake_data/provide_hass"; import "../components/demo-cards"; const CONFIGS = [ @@ -254,7 +256,7 @@ const CONFIGS = [ class DemoMarkdown extends PolymerElement { static get template() { - return html` `; + return html` `; } static get properties() { @@ -265,6 +267,12 @@ class DemoMarkdown extends PolymerElement { }, }; } + + public ready() { + super.ready(); + const hass = provideHass(this.$.demos); + mockTemplate(hass); + } } customElements.define("demo-hui-markdown-card", DemoMarkdown); diff --git a/gallery/src/demos/demo-hui-stack-card.ts b/gallery/src/demos/demo-hui-stack-card.ts index b746888dc1..982eab4f25 100644 --- a/gallery/src/demos/demo-hui-stack-card.ts +++ b/gallery/src/demos/demo-hui-stack-card.ts @@ -1,6 +1,7 @@ import { html } from "@polymer/polymer/lib/utils/html-tag"; /* eslint-plugin-disable lit */ import { PolymerElement } from "@polymer/polymer/polymer-element"; +import { mockHistory } from "../../../demo/src/stubs/history"; import { getEntity } from "../../../src/fake_data/entity"; import { provideHass } from "../../../src/fake_data/provide_hass"; import "../components/demo-cards"; @@ -36,6 +37,10 @@ const ENTITIES = [ battery: 71, friendly_name: "Home Boy", }), + getEntity("sensor", "illumination", "23", { + friendly_name: "Illumination", + unit_of_measurement: "lx", + }), ]; const CONFIGS = [ @@ -89,6 +94,42 @@ const CONFIGS = [ entity: light.bed_light `, }, + { + heading: "Default Grid", + config: ` +- type: grid + cards: + - type: entity + entity: light.kitchen_lights + - type: entity + entity: light.bed_light + - type: entity + entity: device_tracker.demo_paulus + - type: sensor + entity: sensor.illumination + graph: line + - type: entity + entity: device_tracker.demo_anne_therese + `, + }, + { + heading: "Non-square Grid with 2 columns", + config: ` +- type: grid + columns: 2 + square: false + cards: + - type: entity + entity: light.kitchen_lights + - type: entity + entity: light.bed_light + - type: entity + entity: device_tracker.demo_paulus + - type: sensor + entity: sensor.illumination + graph: line + `, + }, ]; class DemoStack extends PolymerElement { @@ -110,6 +151,7 @@ class DemoStack extends PolymerElement { const hass = provideHass(this.$.demos); hass.updateTranslations(null, "en"); hass.addEntities(ENTITIES); + mockHistory(hass); } } diff --git a/gallery/src/demos/demo-more-info-light.ts b/gallery/src/demos/demo-more-info-light.ts index 8c5ac611e9..70b77560d9 100644 --- a/gallery/src/demos/demo-more-info-light.ts +++ b/gallery/src/demos/demo-more-info-light.ts @@ -6,7 +6,7 @@ import { SUPPORT_BRIGHTNESS } from "../../../src/data/light"; import { getEntity } from "../../../src/fake_data/entity"; import { provideHass } from "../../../src/fake_data/provide_hass"; import "../components/demo-more-infos"; -import "../components/more-info-content"; +import "../../../src/dialogs/more-info/more-info-content"; const ENTITIES = [ getEntity("light", "bed_light", "on", { @@ -40,8 +40,12 @@ class DemoMoreInfoLight extends PolymerElement { public ready() { super.ready(); + this._setupDemo(); + } + + private async _setupDemo() { const hass = provideHass(this); - hass.updateTranslations(null, "en"); + await hass.updateTranslations(null, "en"); hass.addEntities(ENTITIES); } } diff --git a/hassio/src/addon-store/hassio-addon-repository.ts b/hassio/src/addon-store/hassio-addon-repository.ts index c188e516b7..67c4c50849 100644 --- a/hassio/src/addon-store/hassio-addon-repository.ts +++ b/hassio/src/addon-store/hassio-addon-repository.ts @@ -23,9 +23,9 @@ import { hassioStyle } from "../resources/hassio-style"; class HassioAddonRepositoryEl extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property() public repo!: HassioAddonRepository; + @property({ attribute: false }) public repo!: HassioAddonRepository; - @property() public addons!: HassioAddonInfo[]; + @property({ attribute: false }) public addons!: HassioAddonInfo[]; @property() public filter!: string; @@ -78,18 +78,18 @@ class HassioAddonRepositoryEl extends LitElement { .title=${addon.name} .description=${addon.description} .available=${addon.available} - .icon=${addon.installed && addon.installed !== addon.version + .icon=${addon.installed && addon.update_available ? mdiArrowUpBoldCircle : mdiPuzzle} .iconTitle=${addon.installed - ? addon.installed !== addon.version + ? addon.update_available ? "New version available" : "Add-on is installed" : addon.available ? "Add-on is not installed" : "Add-on is not available on your system"} .iconClass=${addon.installed - ? addon.installed !== addon.version + ? addon.update_available ? "update" : "installed" : !addon.available @@ -104,7 +104,7 @@ class HassioAddonRepositoryEl extends LitElement { : undefined} .showTopbar=${addon.installed || !addon.available} .topbarClass=${addon.installed - ? addon.installed !== addon.version + ? addon.update_available ? "update" : "installed" : !addon.available diff --git a/hassio/src/addon-store/hassio-addon-store.ts b/hassio/src/addon-store/hassio-addon-store.ts index baf2fef515..fc15fc38dc 100644 --- a/hassio/src/addon-store/hassio-addon-store.ts +++ b/hassio/src/addon-store/hassio-addon-store.ts @@ -11,6 +11,7 @@ import { PropertyValues, } from "lit-element"; import { html, TemplateResult } from "lit-html"; +import { atLeastVersion } from "../../../src/common/config/version"; import "../../../src/common/search/search-input"; import "../../../src/components/ha-button-menu"; import "../../../src/components/ha-svg-icon"; @@ -24,6 +25,7 @@ import { extractApiErrorMessage } from "../../../src/data/hassio/common"; import "../../../src/layouts/hass-loading-screen"; import "../../../src/layouts/hass-tabs-subpage"; import { HomeAssistant, Route } from "../../../src/types"; +import { showRegistriesDialog } from "../dialogs/registries/show-dialog-registries"; import { showRepositoriesDialog } from "../dialogs/repositories/show-dialog-repositories"; import { supervisorTabs } from "../hassio-tabs"; import "./hassio-addon-repository"; @@ -98,14 +100,14 @@ class HassioAddonStore extends LitElement { main-page .tabs=${supervisorTabs} > - Add-on store + Add-on Store - + Repositories @@ -113,6 +115,12 @@ class HassioAddonStore extends LitElement { Reload + ${this.hass.userData?.showAdvanced && + atLeastVersion(this.hass.config.version, 0, 117) + ? html` + Registries + ` + : ""} ${repos.length === 0 ? html`` @@ -157,6 +165,9 @@ class HassioAddonStore extends LitElement { case 1: this.refreshData(); break; + case 2: + this._manageRegistries(); + break; } } @@ -173,6 +184,10 @@ class HassioAddonStore extends LitElement { }); } + private async _manageRegistries() { + showRegistriesDialog(this); + } + private async _loadData() { try { const addonsInfo = await fetchHassioAddonsInfo(this.hass); diff --git a/hassio/src/addon-view/config/hassio-addon-config.ts b/hassio/src/addon-view/config/hassio-addon-config.ts index 415e3e3e91..beb8b6a749 100644 --- a/hassio/src/addon-view/config/hassio-addon-config.ts +++ b/hassio/src/addon-view/config/hassio-addon-config.ts @@ -39,13 +39,11 @@ class HassioAddonConfig extends LitElement { @property({ type: Boolean }) private _configHasChanged = false; - @query("ha-yaml-editor") private _editor!: HaYamlEditor; + @property({ type: Boolean }) private _valid = true; + + @query("ha-yaml-editor", true) private _editor!: HaYamlEditor; protected render(): TemplateResult { - const editor = this._editor; - // If editor not rendered, don't show the error. - const valid = editor ? editor.isValid : true; - return html`

${this.addon.name}

@@ -54,7 +52,7 @@ class HassioAddonConfig extends LitElement { @value-changed=${this._configChanged} > ${this._error ? html`
${this._error}
` : ""} - ${valid ? "" : html`
Invalid YAML
`} + ${this._valid ? "" : html`
Invalid YAML
`}
@@ -62,7 +60,7 @@ class HassioAddonConfig extends LitElement { Save @@ -78,9 +76,9 @@ class HassioAddonConfig extends LitElement { } } - private _configChanged(): void { + private _configChanged(ev): void { this._configHasChanged = true; - this.requestUpdate(); + this._valid = ev.detail.isValid; } private async _resetTapped(ev: CustomEvent): Promise { diff --git a/hassio/src/addon-view/info/hassio-addon-info.ts b/hassio/src/addon-view/info/hassio-addon-info.ts index 1de072ee99..91b3410d67 100644 --- a/hassio/src/addon-view/info/hassio-addon-info.ts +++ b/hassio/src/addon-view/info/hassio-addon-info.ts @@ -69,7 +69,7 @@ const STAGE_ICON = { const PERMIS_DESC = { stage: { title: "Add-on Stage", - description: `Add-ons can have one of three stages:\n\n **Stable**: These are add-ons ready to be used in production.\n\n **Experimental**: These may contain bugs, and may be unfinished.\n\n **Deprecated**: These add-ons will no longer receive any updates.`, + description: `Add-ons can have one of three stages:\n\n **Stable**: These are add-ons ready to be used in production.\n\n **Experimental**: These may contain bugs, and may be unfinished.\n\n **Deprecated**: These add-ons will no longer receive any updates.`, }, rating: { title: "Add-on Security Rating", @@ -135,7 +135,7 @@ class HassioAddonInfo extends LitElement { protected render(): TemplateResult { return html` - ${this._computeUpdateAvailable + ${this.addon.update_available ? html`
@@ -178,7 +178,7 @@ class HassioAddonInfo extends LitElement { ${!this.addon.protected ? html` -
Warning: Protection mode is disabled!
+

Warning: Protection mode is disabled!

Protection mode on this add-on is disabled! This gives the add-on full access to the entire system, which adds security risks, and could damage your system when used incorrectly. Only disable the protection mode if you know, need AND trust the source of this add-on.
@@ -202,14 +202,14 @@ class HassioAddonInfo extends LitElement { ` : html` `} ` @@ -283,7 +283,7 @@ class HassioAddonInfo extends LitElement { label="host" description="" > - + ` : ""} @@ -295,7 +295,7 @@ class HassioAddonInfo extends LitElement { label="hardware" description="" > - + ` : ""} @@ -307,7 +307,7 @@ class HassioAddonInfo extends LitElement { label="hass" description="" > - + ` : ""} @@ -319,7 +319,7 @@ class HassioAddonInfo extends LitElement { label="hassio" .description=${this.addon.hassio_role} > - + ` : ""} @@ -331,7 +331,7 @@ class HassioAddonInfo extends LitElement { label="docker" description="" > - + ` : ""} @@ -343,7 +343,7 @@ class HassioAddonInfo extends LitElement { label="host pid" description="" > - + ` : ""} @@ -356,7 +356,7 @@ class HassioAddonInfo extends LitElement { label="apparmor" description="" > - + ` : ""} @@ -368,7 +368,7 @@ class HassioAddonInfo extends LitElement { label="auth" description="" > - + ` : ""} @@ -381,7 +381,7 @@ class HassioAddonInfo extends LitElement { description="" > ` @@ -609,15 +609,6 @@ class HassioAddonInfo extends LitElement { return this.addon?.state === "started"; } - private get _computeUpdateAvailable(): boolean | "" { - return ( - this.addon && - !this.addon.detached && - this.addon.version && - this.addon.version !== this.addon.version_latest - ); - } - private get _pathWebui(): string | null { return ( this.addon.webui && @@ -798,10 +789,10 @@ class HassioAddonInfo extends LitElement { ); if (!validate.data.valid) { await showConfirmationDialog(this, { - title: "Failed to start addon - configruation validation faled!", + title: "Failed to start addon - configuration validation failed!", text: validate.data.message.split(" Got ")[0], confirm: () => this._openConfiguration(), - confirmText: "Go to configruation", + confirmText: "Go to configuration", dismissText: "Cancel", }); button.progress = false; diff --git a/hassio/src/components/hassio-card-content.ts b/hassio/src/components/hassio-card-content.ts index ee57a98ab8..1123aa9f5a 100644 --- a/hassio/src/components/hassio-card-content.ts +++ b/hassio/src/components/hassio-card-content.ts @@ -50,7 +50,7 @@ class HassioCardContent extends LitElement { ` : html` diff --git a/hassio/src/components/hassio-upload-snapshot.ts b/hassio/src/components/hassio-upload-snapshot.ts index 94c7828ad7..5740e0621e 100644 --- a/hassio/src/components/hassio-upload-snapshot.ts +++ b/hassio/src/components/hassio-upload-snapshot.ts @@ -1,4 +1,3 @@ -import "../../../src/components/ha-file-upload"; import "@material/mwc-icon-button/mwc-icon-button"; import { mdiFolderUpload } from "@mdi/js"; import "@polymer/iron-input/iron-input"; @@ -12,13 +11,15 @@ import { } from "lit-element"; import { fireEvent } from "../../../src/common/dom/fire_event"; import "../../../src/components/ha-circular-progress"; +import "../../../src/components/ha-file-upload"; import "../../../src/components/ha-svg-icon"; +import { extractApiErrorMessage } from "../../../src/data/hassio/common"; import { HassioSnapshot, uploadSnapshot, } from "../../../src/data/hassio/snapshot"; -import { HomeAssistant } from "../../../src/types"; import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box"; +import { HomeAssistant } from "../../../src/types"; declare global { interface HASSDomEvents { @@ -65,7 +66,7 @@ export class HassioUploadSnapshot extends LitElement { } catch (err) { showAlertDialog(this, { title: "Upload failed", - text: err.toString(), + text: extractApiErrorMessage(err), confirmText: "ok", }); } finally { diff --git a/hassio/src/dashboard/hassio-addons.ts b/hassio/src/dashboard/hassio-addons.ts index 8c6963cac9..717a6397a3 100644 --- a/hassio/src/dashboard/hassio-addons.ts +++ b/hassio/src/dashboard/hassio-addons.ts @@ -52,22 +52,21 @@ class HassioAddons extends LitElement { .title=${addon.name} .description=${addon.description} available - .showTopbar=${addon.installed !== addon.version} + .showTopbar=${addon.update_available} topbarClass="update" - .icon=${addon.installed !== addon.version + .icon=${addon.update_available! ? mdiArrowUpBoldCircle : mdiPuzzle} .iconTitle=${addon.state !== "started" ? "Add-on is stopped" - : addon.installed !== addon.version + : addon.update_available! ? "New version available" : "Add-on is running"} - .iconClass=${addon.installed && - addon.installed !== addon.version + .iconClass=${addon.update_available ? addon.state === "started" ? "update" : "update stopped" - : addon.installed && addon.state === "started" + : addon.state === "started" ? "running" : "stopped"} .iconImage=${atLeastVersion( diff --git a/hassio/src/dashboard/hassio-update.ts b/hassio/src/dashboard/hassio-update.ts index a95eafbd76..9f8fa97d37 100644 --- a/hassio/src/dashboard/hassio-update.ts +++ b/hassio/src/dashboard/hassio-update.ts @@ -5,11 +5,11 @@ import { CSSResult, customElement, html, - internalProperty, LitElement, property, TemplateResult, } from "lit-element"; +import memoizeOne from "memoize-one"; import "../../../src/components/buttons/ha-progress-button"; import "../../../src/components/ha-card"; import "../../../src/components/ha-svg-icon"; @@ -35,29 +35,30 @@ import { hassioStyle } from "../resources/hassio-style"; export class HassioUpdate extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property({ attribute: false }) public hassInfo: HassioHomeAssistantInfo; + @property({ attribute: false }) public hassInfo?: HassioHomeAssistantInfo; @property({ attribute: false }) public hassOsInfo?: HassioHassOSInfo; - @property() public supervisorInfo: HassioSupervisorInfo; + @property({ attribute: false }) public supervisorInfo?: HassioSupervisorInfo; - @internalProperty() private _error?: string; + private _pendingUpdates = memoizeOne( + ( + core?: HassioHomeAssistantInfo, + supervisor?: HassioSupervisorInfo, + os?: HassioHassOSInfo + ): number => { + return [core, supervisor, os].filter( + (value) => !!value && value?.update_available + ).length; + } + ); protected render(): TemplateResult { - const updatesAvailable: number = [ + const updatesAvailable = this._pendingUpdates( this.hassInfo, this.supervisorInfo, - this.hassOsInfo, - ].filter((value) => { - return ( - !!value && - (value.version_latest - ? value.version !== value.version_latest - : value.version_latest - ? value.version !== value.version_latest - : false) - ); - }).length; + this.hassOsInfo + ); if (!updatesAvailable) { return html``; @@ -65,9 +66,6 @@ export class HassioUpdate extends LitElement { return html`
- ${this._error - ? html`
Error: ${this._error}
` - : ""}

${updatesAvailable > 1 ? "Updates Available 🎉" @@ -76,26 +74,24 @@ export class HassioUpdate extends LitElement {
${this._renderUpdateCard( "Home Assistant Core", - this.hassInfo.version, - this.hassInfo.version_latest, + this.hassInfo!, "hassio/homeassistant/update", `https://${ - this.hassInfo.version_latest.includes("b") ? "rc" : "www" - }.home-assistant.io/latest-release-notes/`, - mdiHomeAssistant + this.hassInfo?.version_latest.includes("b") ? "rc" : "www" + }.home-assistant.io/latest-release-notes/` )} ${this._renderUpdateCard( "Supervisor", - this.supervisorInfo.version, - this.supervisorInfo.version_latest, + this.supervisorInfo!, "hassio/supervisor/update", - `https://github.com//home-assistant/hassio/releases/tag/${this.supervisorInfo.version_latest}` + `https://github.com//home-assistant/hassio/releases/tag/${ + this.supervisorInfo!.version_latest + }` )} ${this.hassOsInfo ? this._renderUpdateCard( "Operating System", - this.hassOsInfo.version, - this.hassOsInfo.version_latest, + this.hassOsInfo, "hassio/os/update", `https://github.com//home-assistant/hassos/releases/tag/${this.hassOsInfo.version_latest}` ) @@ -107,28 +103,22 @@ export class HassioUpdate extends LitElement { private _renderUpdateCard( name: string, - curVersion: string, - lastVersion: string, + object: HassioHomeAssistantInfo | HassioSupervisorInfo | HassioHassOSInfo, apiPath: string, - releaseNotesUrl: string, - icon?: string + releaseNotesUrl: string ): TemplateResult { - if (!lastVersion || lastVersion === curVersion) { + if (!object.update_available) { return html``; } return html`
- ${icon - ? html` -
- -
- ` - : ""} -
${name} ${lastVersion}
+
+ +
+
${name} ${object.version_latest}
- You are currently running version ${curVersion} + You are currently running version ${object.version}
@@ -138,7 +128,7 @@ export class HassioUpdate extends LitElement { Update diff --git a/hassio/src/dialogs/network/dialog-hassio-network.ts b/hassio/src/dialogs/network/dialog-hassio-network.ts index 1ab958891b..8569b8bc60 100644 --- a/hassio/src/dialogs/network/dialog-hassio-network.ts +++ b/hassio/src/dialogs/network/dialog-hassio-network.ts @@ -39,7 +39,8 @@ import type { HomeAssistant } from "../../../../src/types"; import { HassioNetworkDialogParams } from "./show-dialog-network"; @customElement("dialog-hassio-network") -export class DialogHassioNetwork extends LitElement implements HassDialog { +export class DialogHassioNetwork extends LitElement + implements HassDialog { @property({ attribute: false }) public hass!: HomeAssistant; @internalProperty() private _prosessing = false; diff --git a/hassio/src/dialogs/registries/dialog-hassio-registries.ts b/hassio/src/dialogs/registries/dialog-hassio-registries.ts new file mode 100644 index 0000000000..fef6e3e0a8 --- /dev/null +++ b/hassio/src/dialogs/registries/dialog-hassio-registries.ts @@ -0,0 +1,245 @@ +import "@material/mwc-button/mwc-button"; +import "@material/mwc-icon-button/mwc-icon-button"; +import "@material/mwc-list/mwc-list-item"; +import { mdiDelete } from "@mdi/js"; +import { PaperInputElement } from "@polymer/paper-input/paper-input"; +import { + css, + CSSResult, + customElement, + html, + internalProperty, + LitElement, + property, + TemplateResult, +} from "lit-element"; +import "../../../../src/components/ha-circular-progress"; +import { createCloseHeading } from "../../../../src/components/ha-dialog"; +import "../../../../src/components/ha-svg-icon"; +import { extractApiErrorMessage } from "../../../../src/data/hassio/common"; +import { + addHassioDockerRegistry, + fetchHassioDockerRegistries, + removeHassioDockerRegistry, +} from "../../../../src/data/hassio/docker"; +import { showAlertDialog } from "../../../../src/dialogs/generic/show-dialog-box"; +import { haStyle, haStyleDialog } from "../../../../src/resources/styles"; +import type { HomeAssistant } from "../../../../src/types"; + +@customElement("dialog-hassio-registries") +class HassioRegistriesDialog extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) private _registries?: { + registry: string; + username: string; + }[]; + + @internalProperty() private _registry?: string; + + @internalProperty() private _username?: string; + + @internalProperty() private _password?: string; + + @internalProperty() private _opened = false; + + @internalProperty() private _addingRegistry = false; + + protected render(): TemplateResult { + return html` + +
+ ${this._addingRegistry + ? html` + + + + + + Add registry + + ` + : html`${this._registries?.length + ? this._registries.map((entry) => { + return html` + + ${entry.registry} + Username: ${entry.username} + + + + + `; + }) + : html` + + No registries configured + + `} + + Add new registry + `} +
+
+ `; + } + + private _inputChanged(ev: Event) { + const target = ev.currentTarget as PaperInputElement; + this[`_${target.name}`] = target.value; + } + + public async showDialog(_dialogParams: any): Promise { + this._opened = true; + await this._loadRegistries(); + await this.updateComplete; + } + + public closeDialog(): void { + this._addingRegistry = false; + this._opened = false; + } + + public focus(): void { + this.updateComplete.then(() => + (this.shadowRoot?.querySelector( + "[dialogInitialFocus]" + ) as HTMLElement)?.focus() + ); + } + + private async _loadRegistries(): Promise { + const registries = await fetchHassioDockerRegistries(this.hass); + this._registries = Object.keys(registries!.registries).map((key) => ({ + registry: key, + username: registries.registries[key].username, + })); + } + + private _addRegistry(): void { + this._addingRegistry = true; + } + + private async _addNewRegistry(): Promise { + const data = {}; + data[this._registry!] = { + username: this._username, + password: this._password, + }; + + try { + await addHassioDockerRegistry(this.hass, data); + await this._loadRegistries(); + this._addingRegistry = false; + } catch (err) { + showAlertDialog(this, { + title: "Failed to add registry", + text: extractApiErrorMessage(err), + }); + } + } + + private async _removeRegistry(ev: Event): Promise { + const entry = (ev.currentTarget as any).entry; + + try { + await removeHassioDockerRegistry(this.hass, entry.registry); + await this._loadRegistries(); + } catch (err) { + showAlertDialog(this, { + title: "Failed to remove registry", + text: extractApiErrorMessage(err), + }); + } + } + + static get styles(): CSSResult[] { + return [ + haStyle, + haStyleDialog, + css` + ha-dialog.button-left { + --justify-action-buttons: flex-start; + } + paper-icon-item { + cursor: pointer; + } + .form { + color: var(--primary-text-color); + } + .option { + border: 1px solid var(--divider-color); + border-radius: 4px; + margin-top: 4px; + } + mwc-button { + margin-left: 8px; + } + mwc-icon-button { + color: var(--error-color); + margin: -10px; + } + mwc-list-item { + cursor: default; + } + mwc-list-item span[slot="secondary"] { + color: var(--secondary-text-color); + } + ha-paper-dropdown-menu { + display: block; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-hassio-registries": HassioRegistriesDialog; + } +} diff --git a/hassio/src/dialogs/registries/show-dialog-registries.ts b/hassio/src/dialogs/registries/show-dialog-registries.ts new file mode 100644 index 0000000000..a9e871d17e --- /dev/null +++ b/hassio/src/dialogs/registries/show-dialog-registries.ts @@ -0,0 +1,13 @@ +import { fireEvent } from "../../../../src/common/dom/fire_event"; +import "./dialog-hassio-registries"; + +export const showRegistriesDialog = (element: HTMLElement): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-hassio-registries", + dialogImport: () => + import( + /* webpackChunkName: "dialog-hassio-registries" */ "./dialog-hassio-registries" + ), + dialogParams: {}, + }); +}; diff --git a/hassio/src/dialogs/repositories/dialog-hassio-repositories.ts b/hassio/src/dialogs/repositories/dialog-hassio-repositories.ts index 150fc2b181..2d8d028cdf 100644 --- a/hassio/src/dialogs/repositories/dialog-hassio-repositories.ts +++ b/hassio/src/dialogs/repositories/dialog-hassio-repositories.ts @@ -39,7 +39,7 @@ class HassioRepositoriesDialog extends LitElement { @property({ attribute: false }) private _dialogParams?: HassioRepositoryDialogParams; - @query("#repository_input") private _optionInput?: PaperInputElement; + @query("#repository_input", true) private _optionInput?: PaperInputElement; @internalProperty() private _opened = false; @@ -91,7 +91,7 @@ class HassioRepositoriesDialog extends LitElement { title="Remove" @click=${this._removeRepository} > - + `; diff --git a/hassio/src/dialogs/snapshot/dialog-hassio-snapshot-upload.ts b/hassio/src/dialogs/snapshot/dialog-hassio-snapshot-upload.ts index ed0532d29f..1f657bf0d8 100644 --- a/hassio/src/dialogs/snapshot/dialog-hassio-snapshot-upload.ts +++ b/hassio/src/dialogs/snapshot/dialog-hassio-snapshot-upload.ts @@ -19,7 +19,7 @@ import { HassioSnapshotUploadDialogParams } from "./show-dialog-snapshot-upload" @customElement("dialog-hassio-snapshot-upload") export class DialogHassioSnapshotUpload extends LitElement - implements HassDialog { + implements HassDialog { @property({ attribute: false }) public hass!: HomeAssistant; @internalProperty() private _params?: HassioSnapshotUploadDialogParams; diff --git a/hassio/src/dialogs/snapshot/dialog-hassio-snapshot.ts b/hassio/src/dialogs/snapshot/dialog-hassio-snapshot.ts index 790ebc467c..d3524bc4ed 100755 --- a/hassio/src/dialogs/snapshot/dialog-hassio-snapshot.ts +++ b/hassio/src/dialogs/snapshot/dialog-hassio-snapshot.ts @@ -1,6 +1,7 @@ import "@material/mwc-button"; import { mdiClose, mdiDelete, mdiDownload, mdiHistory } from "@mdi/js"; -import { PaperCheckboxElement } from "@polymer/paper-checkbox/paper-checkbox"; +import "@polymer/paper-checkbox/paper-checkbox"; +import type { PaperCheckboxElement } from "@polymer/paper-checkbox/paper-checkbox"; import "@polymer/paper-input/paper-input"; import { css, @@ -196,7 +197,7 @@ class HassioSnapshotDialog extends LitElement { @click=${this._downloadClicked} slot="primaryAction" > - + Download Snapshot ` : ""} @@ -205,7 +206,7 @@ class HassioSnapshotDialog extends LitElement { @click=${this._partialRestoreClicked} slot="secondaryAction" > - + Restore Selected ${this._snapshot.type === "full" @@ -214,7 +215,7 @@ class HassioSnapshotDialog extends LitElement { @click=${this._fullRestoreClicked} slot="secondaryAction" > - + Wipe & restore ` @@ -224,7 +225,10 @@ class HassioSnapshotDialog extends LitElement { @click=${this._deleteClicked} slot="secondaryAction" > - + Delete Snapshot ` : ""} @@ -440,6 +444,19 @@ class HassioSnapshotDialog extends LitElement { return; } + if (window.location.href.includes("ui.nabu.casa")) { + const confirm = await showConfirmationDialog(this, { + title: "Potential slow download", + text: + "Downloading snapshots over the Nabu Casa URL will take some time, it is recomended to use your local URL instead, do you want to continue?", + confirmText: "continue", + dismissText: "cancel", + }); + if (!confirm) { + return; + } + } + const name = this._computeName.replace(/[^a-z0-9]+/gi, "_"); const a = document.createElement("a"); a.href = signedPath.path; diff --git a/hassio/src/hassio-panel-router.ts b/hassio/src/hassio-panel-router.ts index 00830423b0..6cad299188 100644 --- a/hassio/src/hassio-panel-router.ts +++ b/hassio/src/hassio-panel-router.ts @@ -25,13 +25,13 @@ class HassioPanelRouter extends HassRouterPage { @property({ type: Boolean }) public narrow!: boolean; - @property({ attribute: false }) public supervisorInfo: HassioSupervisorInfo; + @property({ attribute: false }) public supervisorInfo?: HassioSupervisorInfo; @property({ attribute: false }) public hassioInfo!: HassioInfo; - @property({ attribute: false }) public hostInfo: HassioHostInfo; + @property({ attribute: false }) public hostInfo?: HassioHostInfo; - @property({ attribute: false }) public hassInfo: HassioHomeAssistantInfo; + @property({ attribute: false }) public hassInfo?: HassioHomeAssistantInfo; @property({ attribute: false }) public hassOsInfo!: HassioHassOSInfo; diff --git a/hassio/src/hassio-router.ts b/hassio/src/hassio-router.ts index a3ecdaf784..52168d1b3f 100644 --- a/hassio/src/hassio-router.ts +++ b/hassio/src/hassio-router.ts @@ -66,15 +66,15 @@ class HassioRouter extends HassRouterPage { }, }; - @internalProperty() private _supervisorInfo: HassioSupervisorInfo; + @internalProperty() private _supervisorInfo?: HassioSupervisorInfo; - @internalProperty() private _hostInfo: HassioHostInfo; + @internalProperty() private _hostInfo?: HassioHostInfo; @internalProperty() private _hassioInfo?: HassioInfo; @internalProperty() private _hassOsInfo?: HassioHassOSInfo; - @internalProperty() private _hassInfo: HassioHomeAssistantInfo; + @internalProperty() private _hassInfo?: HassioHomeAssistantInfo; protected firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); diff --git a/hassio/src/hassio-tabs.ts b/hassio/src/hassio-tabs.ts index f5dbaf4499..7c7601a6b3 100644 --- a/hassio/src/hassio-tabs.ts +++ b/hassio/src/hassio-tabs.ts @@ -8,7 +8,7 @@ export const supervisorTabs: PageNavigation[] = [ iconPath: mdiViewDashboard, }, { - name: "Add-on store", + name: "Add-on Store", path: `/hassio/store`, iconPath: mdiStore, }, diff --git a/hassio/src/ingress-view/hassio-ingress-view.ts b/hassio/src/ingress-view/hassio-ingress-view.ts index 8780d9617d..ec899c7094 100644 --- a/hassio/src/ingress-view/hassio-ingress-view.ts +++ b/hassio/src/ingress-view/hassio-ingress-view.ts @@ -57,7 +57,7 @@ class HassioIngressView extends LitElement { aria-label=${this.hass.localize("ui.sidebar.sidebar_toggle")} @click=${this._toggleMenu} > - +
${this._addon.name}
diff --git a/hassio/src/snapshots/hassio-snapshots.ts b/hassio/src/snapshots/hassio-snapshots.ts index 8f040c906b..96dd6caecf 100644 --- a/hassio/src/snapshots/hassio-snapshots.ts +++ b/hassio/src/snapshots/hassio-snapshots.ts @@ -117,7 +117,7 @@ class HassioSnapshots extends LitElement { @action=${this._handleAction} > - + Reload @@ -131,7 +131,7 @@ class HassioSnapshots extends LitElement {

- Create snapshot + Create Snapshot

Snapshots allow you to easily backup and restore all data of your @@ -219,7 +219,7 @@ class HassioSnapshots extends LitElement {

-

Available snapshots

+

Available Snapshots

${this._snapshots === undefined ? undefined diff --git a/hassio/src/system/hassio-host-info.ts b/hassio/src/system/hassio-host-info.ts index 94400402ae..5a2c5776bc 100644 --- a/hassio/src/system/hassio-host-info.ts +++ b/hassio/src/system/hassio-host-info.ts @@ -87,7 +87,7 @@ class HassioHostInfo extends LitElement { ${this.hostInfo.features.includes("network") ? html` - IP address + IP Address ${primaryIpAddress} @@ -103,13 +103,13 @@ class HassioHostInfo extends LitElement { - Operating system + Operating System ${this.hostInfo.operating_system} - ${this.hostInfo.version !== this.hostInfo.version_latest && - this.hostInfo.features.includes("hassos") + ${this.hostInfo.features.includes("hassos") && + this.hassOsInfo.update_available ? html` { const curHostname: string = this.hostInfo.hostname; const hostname = await showPromptDialog(this, { - title: "Change hostname", + title: "Change Hostname", inputLabel: "Please enter a new hostname:", inputType: "string", defaultValue: curHostname, diff --git a/hassio/src/system/hassio-supervisor-info.ts b/hassio/src/system/hassio-supervisor-info.ts index fcec51d607..9a1fa07f5f 100644 --- a/hassio/src/system/hassio-supervisor-info.ts +++ b/hassio/src/system/hassio-supervisor-info.ts @@ -7,18 +7,21 @@ import { property, TemplateResult, } from "lit-element"; +import { fireEvent } from "../../../src/common/dom/fire_event"; import "../../../src/components/buttons/ha-progress-button"; import "../../../src/components/ha-card"; import "../../../src/components/ha-settings-row"; import "../../../src/components/ha-switch"; +import { extractApiErrorMessage } from "../../../src/data/hassio/common"; import { HassioHostInfo as HassioHostInfoType } from "../../../src/data/hassio/host"; +import { fetchHassioResolution } from "../../../src/data/hassio/resolution"; import { + fetchHassioSupervisorInfo, HassioSupervisorInfo as HassioSupervisorInfoType, reloadSupervisor, setSupervisorOption, SupervisorOptions, updateSupervisor, - fetchHassioSupervisorInfo, } from "../../../src/data/hassio/supervisor"; import { showAlertDialog, @@ -26,14 +29,42 @@ import { } from "../../../src/dialogs/generic/show-dialog-box"; import { haStyle } from "../../../src/resources/styles"; import { HomeAssistant } from "../../../src/types"; +import { documentationUrl } from "../../../src/util/documentation-url"; import { hassioStyle } from "../resources/hassio-style"; -import { extractApiErrorMessage } from "../../../src/data/hassio/common"; + +const ISSUES = { + container: { + title: "Containers known to cause issues", + url: "/more-info/unsupported/container", + }, + dbus: { title: "DBUS", url: "/more-info/unsupported/dbus" }, + docker_configuration: { + title: "Docker Configuration", + url: "/more-info/unsupported/docker_configuration", + }, + docker_version: { + title: "Docker Version", + url: "/more-info/unsupported/docker_version", + }, + lxc: { title: "LXC", url: "/more-info/unsupported/lxc" }, + network_manager: { + title: "Network Manager", + url: "/more-info/unsupported/network_manager", + }, + os: { title: "Operating System", url: "/more-info/unsupported/os" }, + privileged: { + title: "Supervisor is not privileged", + url: "/more-info/unsupported/privileged", + }, + systemd: { title: "Systemd", url: "/more-info/unsupported/systemd" }, +}; @customElement("hassio-supervisor-info") class HassioSupervisorInfo extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; - @property() public supervisorInfo!: HassioSupervisorInfoType; + @property({ attribute: false }) + public supervisorInfo!: HassioSupervisorInfoType; @property() public hostInfo!: HassioHostInfoType; @@ -51,12 +82,12 @@ class HassioSupervisorInfo extends LitElement { - Newest version + Newest Version ${this.supervisorInfo.version_latest} - ${this.supervisorInfo.version !== this.supervisorInfo.version_latest + ${this.supervisorInfo.update_available ? html` - Share diagnostics + Share Diagnostics
Share crash reports and diagnostic information. @@ -118,24 +149,19 @@ class HassioSupervisorInfo extends LitElement { ` : html`
You are running an unsupported installation. - - Learn More - + Learn more +
`}
Reload @@ -181,7 +207,7 @@ class HassioSupervisorInfo extends LitElement { }; await setSupervisorOption(this.hass, data); await reloadSupervisor(this.hass); - this.supervisorInfo = await fetchHassioSupervisorInfo(this.hass); + fireEvent(this, "hass-api-called", { success: true, response: null }); } catch (err) { showAlertDialog(this, { title: "Failed to set supervisor option", @@ -212,7 +238,7 @@ class HassioSupervisorInfo extends LitElement { button.progress = true; const confirmed = await showConfirmationDialog(this, { - title: "Update supervisor", + title: "Update Supervisor", text: `Are you sure you want to update supervisor to version ${this.supervisorInfo.version_latest}?`, confirmText: "update", dismissText: "cancel", @@ -249,6 +275,32 @@ class HassioSupervisorInfo extends LitElement { }); } + private async _unsupportedDialog(): Promise { + const resolution = await fetchHassioResolution(this.hass); + await showAlertDialog(this, { + title: "You are running an unsupported installation", + text: html`Below is a list of issues found with your installation, click + on the links to learn how you can resolve the issues.

+ `, + }); + } + private async _toggleDiagnostics(): Promise { try { const data: SupervisorOptions = { diff --git a/hassio/src/system/hassio-supervisor-log.ts b/hassio/src/system/hassio-supervisor-log.ts index 88528eb431..2e1e2162aa 100644 --- a/hassio/src/system/hassio-supervisor-log.ts +++ b/hassio/src/system/hassio-supervisor-log.ts @@ -76,7 +76,7 @@ class HassioSupervisorLog extends LitElement { ${this.hass.userData?.showAdvanced ? html` +
${metrics.map((metric) => - this._renderMetric(metric.description, metric.value ?? 0) + this._renderMetric( + metric.description, + metric.value ?? 0, + metric.tooltip + ) )}
@@ -77,13 +88,17 @@ class HassioSystemMetrics extends LitElement { this._loadData(); } - private _renderMetric(description: string, value: number): TemplateResult { + private _renderMetric( + description: string, + value: number, + tooltip?: string + ): TemplateResult { const roundedValue = roundWithOneDecimal(value); return html` ${description} -
+
${roundedValue}% @@ -155,6 +170,7 @@ class HassioSystemMetrics extends LitElement { } .value { width: 42px; + padding-right: 4px; } `, ]; diff --git a/package.json b/package.json index e1e98eeb01..df64438afc 100644 --- a/package.json +++ b/package.json @@ -22,28 +22,29 @@ "author": "Paulus Schoutsen (http://paulusschoutsen.nl)", "license": "Apache-2.0", "dependencies": { - "@formatjs/intl-pluralrules": "^1.5.8", + "@formatjs/intl-getcanonicallocales": "^1.4.6", + "@formatjs/intl-pluralrules": "^3.4.10", "@fullcalendar/common": "5.1.0", "@fullcalendar/core": "5.1.0", "@fullcalendar/daygrid": "5.1.0", "@fullcalendar/interaction": "5.1.0", "@fullcalendar/list": "5.1.0", - "@material/chips": "=8.0.0-canary.096a7a066.0", - "@material/circular-progress": "=8.0.0-canary.a78ceb112.0", - "@material/mwc-button": "^0.18.0", - "@material/mwc-checkbox": "^0.18.0", - "@material/mwc-dialog": "^0.18.0", - "@material/mwc-fab": "^0.18.0", - "@material/mwc-formfield": "^0.18.0", - "@material/mwc-icon-button": "^0.18.0", - "@material/mwc-list": "^0.18.0", - "@material/mwc-menu": "^0.18.0", - "@material/mwc-radio": "^0.18.0", - "@material/mwc-ripple": "^0.18.0", - "@material/mwc-switch": "^0.18.0", - "@material/mwc-tab": "^0.18.0", - "@material/mwc-tab-bar": "^0.18.0", - "@material/top-app-bar": "=8.0.0-canary.096a7a066.0", + "@material/chips": "=8.0.0-canary.774dcfc8e.0", + "@material/mwc-button": "^0.19.0", + "@material/mwc-checkbox": "^0.19.0", + "@material/mwc-circular-progress": "^0.19.0", + "@material/mwc-dialog": "^0.19.0", + "@material/mwc-fab": "^0.19.0", + "@material/mwc-formfield": "^0.19.0", + "@material/mwc-icon-button": "^0.19.0", + "@material/mwc-list": "^0.19.0", + "@material/mwc-menu": "^0.19.0", + "@material/mwc-radio": "^0.19.0", + "@material/mwc-ripple": "^0.19.0", + "@material/mwc-switch": "^0.19.0", + "@material/mwc-tab": "^0.19.0", + "@material/mwc-tab-bar": "^0.19.0", + "@material/top-app-bar": "=8.0.0-canary.774dcfc8e.0", "@mdi/js": "5.6.55", "@mdi/svg": "5.6.55", "@polymer/app-layout": "^3.0.2", @@ -77,7 +78,7 @@ "@polymer/paper-toast": "^3.0.1", "@polymer/paper-tooltip": "^3.0.1", "@polymer/polymer": "3.1.0", - "@thomasloven/round-slider": "0.5.0", + "@thomasloven/round-slider": "0.5.2", "@types/chromecast-caf-sender": "^1.0.3", "@types/sortablejs": "^1.10.6", "@vaadin/vaadin-combo-box": "^5.0.10", @@ -88,11 +89,11 @@ "chartjs-chart-timeline": "^0.3.0", "codemirror": "^5.49.0", "comlink": "^4.3.0", + "core-js": "^3.6.5", "cpx": "^1.5.0", "cropperjs": "^1.5.7", "deep-clone-simple": "^1.1.1", "deep-freeze": "^0.0.1", - "es6-object-assign": "^1.1.0", "fecha": "^4.2.0", "fuse.js": "^6.0.0", "google-timezones-json": "^1.0.2", @@ -103,21 +104,23 @@ "js-yaml": "^3.13.1", "leaflet": "^1.4.0", "leaflet-draw": "^1.0.4", - "lit-element": "^2.3.1", + "lit-element": "^2.4.0", "lit-grid-layout": "^1.1.10", - "lit-html": "^1.2.1", + "lit-html": "^1.3.0", "lit-virtualizer": "^0.4.2", "marked": "^1.1.1", "mdn-polyfills": "^5.16.0", "memoize-one": "^5.0.2", - "node-vibrant": "^3.1.5", + "node-vibrant": "^3.1.6", "proxy-polyfill": "^0.3.1", "punycode": "^2.1.1", + "qrcode": "^1.4.4", "regenerator-runtime": "^0.13.2", "resize-observer-polyfill": "^1.5.1", "roboto-fontface": "^0.10.0", "sortablejs": "^1.10.2", "superstruct": "^0.10.12", + "tinykeys": "^1.1.1", "unfetch": "^4.1.0", "uuid": "^8.3.0", "vue": "^2.6.11", @@ -130,16 +133,17 @@ "xss": "^1.0.6" }, "devDependencies": { - "@babel/core": "^7.9.0", - "@babel/plugin-external-helpers": "^7.8.3", - "@babel/plugin-proposal-class-properties": "^7.8.3", - "@babel/plugin-proposal-decorators": "^7.8.3", - "@babel/plugin-proposal-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-proposal-object-rest-spread": "^7.9.5", - "@babel/plugin-proposal-optional-chaining": "^7.9.0", + "@babel/core": "^7.11.6", + "@babel/plugin-external-helpers": "^7.10.4", + "@babel/plugin-proposal-class-properties": "^7.10.4", + "@babel/plugin-proposal-decorators": "^7.10.5", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.10.4", + "@babel/plugin-proposal-object-rest-spread": "^7.11.0", + "@babel/plugin-proposal-optional-chaining": "^7.11.0", "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/preset-env": "^7.9.5", - "@babel/preset-typescript": "^7.9.0", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/preset-env": "^7.11.5", + "@babel/preset-typescript": "^7.10.4", "@rollup/plugin-commonjs": "^11.1.0", "@rollup/plugin-json": "^4.0.3", "@rollup/plugin-node-resolve": "^7.1.3", @@ -156,8 +160,8 @@ "@types/mocha": "^7.0.2", "@types/resize-observer-browser": "^0.1.3", "@types/webspeechapi": "^0.0.29", - "@typescript-eslint/eslint-plugin": "^2.28.0", - "@typescript-eslint/parser": "^2.28.0", + "@typescript-eslint/eslint-plugin": "^4.4.0", + "@typescript-eslint/parser": "^4.4.0", "babel-loader": "^8.1.0", "chai": "^4.2.0", "del": "^4.0.0", @@ -175,14 +179,13 @@ "gulp": "^4.0.0", "gulp-foreach": "^0.1.0", "gulp-json-transform": "^0.4.6", - "gulp-jsonminify": "^1.1.0", "gulp-merge-json": "^1.3.1", "gulp-rename": "^2.0.0", "gulp-zopfli-green": "^3.0.1", "html-minifier": "^4.0.0", "husky": "^1.3.1", "lint-staged": "^8.1.5", - "lit-analyzer": "^1.2.0", + "lit-analyzer": "^1.2.1", "lodash.template": "^4.5.0", "magic-string": "^0.25.7", "map-stream": "^0.0.7", @@ -202,30 +205,25 @@ "sinon": "^7.3.1", "source-map-url": "^0.4.0", "systemjs": "^6.3.2", - "terser-webpack-plugin": "^3.0.6", - "ts-lit-plugin": "^1.2.0", + "terser-webpack-plugin": "^5.0.0", + "ts-lit-plugin": "^1.2.1", "ts-mocha": "^7.0.0", - "typescript": "^3.8.3", + "typescript": "^4.0.3", "vinyl-buffer": "^1.0.1", "vinyl-source-stream": "^2.0.0", - "webpack": "^4.40.2", - "webpack-cli": "^3.3.9", - "webpack-dev-server": "^3.10.3", - "webpack-manifest-plugin": "^2.0.4", - "workbox-build": "^5.1.3", - "worker-plugin": "^4.0.3" + "webpack": "5.1.3", + "webpack-cli": "4.1.0", + "webpack-dev-server": "^3.11.0", + "webpack-manifest-plugin": "3.0.0-rc.0", + "workbox-build": "^5.1.3" }, "_comment": "Polymer fixed to 3.1 because 3.2 throws on logbook page", "_comment_2": "Fix in https://github.com/Polymer/polymer/pull/5569", "resolutions": { "@webcomponents/webcomponentsjs": "^2.2.10", "@polymer/polymer": "3.1.0", - "lit-html": "1.2.1", - "lit-element": "2.3.1", - "@material/animation": "8.0.0-canary.096a7a066.0", - "@material/base": "8.0.0-canary.096a7a066.0", - "@material/feature-targeting": "8.0.0-canary.096a7a066.0", - "@material/theme": "8.0.0-canary.096a7a066.0" + "lit-html": "1.3.0", + "lit-element": "2.4.0" }, "main": "src/home-assistant.js", "husky": { diff --git a/setup.py b/setup.py index df64322812..af4d870361 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ from setuptools import setup, find_packages setup( name="home-assistant-frontend", - version="20200930.0", + version="20201021.1", description="The Home Assistant frontend", url="https://github.com/home-assistant/home-assistant-polymer", author="The Home Assistant Authors", diff --git a/src/auth/ha-auth-flow.ts b/src/auth/ha-auth-flow.ts index 4ad4b3330b..a4234c56ca 100644 --- a/src/auth/ha-auth-flow.ts +++ b/src/auth/ha-auth-flow.ts @@ -200,7 +200,7 @@ class HaAuthFlow extends litLocalizeLiteMixin(LitElement) { private _redirect(authCode: string) { // OAuth 2: 3.1.2 we need to retain query component of a redirect URI - let url = this.redirectUri!!; + let url = this.redirectUri!; if (!url.includes("?")) { url += "?"; } else if (!url.endsWith("&")) { diff --git a/src/common/color/convert-color.ts b/src/common/color/convert-color.ts index f48e6184a0..29f95b4283 100644 --- a/src/common/color/convert-color.ts +++ b/src/common/color/convert-color.ts @@ -1,10 +1,4 @@ -const expand_hex = (hex: string): string => { - let result = ""; - for (const val of hex) { - result += val + val; - } - return result; -}; +import { expandHex } from "./hex"; const rgb_hex = (component: number): string => { const hex = Math.round(Math.min(Math.max(component, 0), 255)).toString(16); @@ -14,10 +8,7 @@ const rgb_hex = (component: number): string => { // Conversion between HEX and RGB export const hex2rgb = (hex: string): [number, number, number] => { - hex = hex.replace("#", ""); - if (hex.length === 3 || hex.length === 4) { - hex = expand_hex(hex); - } + hex = expandHex(hex); return [ parseInt(hex.substring(0, 2), 16), diff --git a/src/common/color/hex.ts b/src/common/color/hex.ts new file mode 100644 index 0000000000..a25b6d6442 --- /dev/null +++ b/src/common/color/hex.ts @@ -0,0 +1,24 @@ +export const expandHex = (hex: string): string => { + hex = hex.replace("#", ""); + if (hex.length === 6) return hex; + let result = ""; + for (const val of hex) { + result += val + val; + } + return result; +}; + +// Blend 2 hex colors: c1 is placed over c2, blend is c1's opacity. +export const hexBlend = (c1: string, c2: string, blend = 50): string => { + let color = ""; + c1 = expandHex(c1); + c2 = expandHex(c2); + for (let i = 0; i <= 5; i += 2) { + const h1 = parseInt(c1.substr(i, 2), 16); + const h2 = parseInt(c2.substr(i, 2), 16); + let hex = Math.floor(h2 + (h1 - h2) * (blend / 100)).toString(16); + while (hex.length < 2) hex = "0" + hex; + color += hex; + } + return `#${color}`; +}; diff --git a/src/common/datetime/relative_time.ts b/src/common/datetime/relative_time.ts index c2a761394b..ef3b8b50c7 100644 --- a/src/common/datetime/relative_time.ts +++ b/src/common/datetime/relative_time.ts @@ -38,13 +38,11 @@ export default function relativeTime( roundedDelta = Math.round(delta); } - const timeDesc = localize( - `ui.components.relative_time.duration.${unit}`, + return localize( + options.includeTense === false + ? `ui.components.relative_time.duration.${unit}` + : `ui.components.relative_time.${tense}_duration.${unit}`, "count", roundedDelta ); - - return options.includeTense === false - ? timeDesc - : localize(`ui.components.relative_time.${tense}`, "time", timeDesc); } diff --git a/src/common/dom/apply_themes_on_element.ts b/src/common/dom/apply_themes_on_element.ts index 902a1a196a..4e1aeb9ec6 100644 --- a/src/common/dom/apply_themes_on_element.ts +++ b/src/common/dom/apply_themes_on_element.ts @@ -7,6 +7,7 @@ import { rgb2hex, rgb2lab, } from "../color/convert-color"; +import { hexBlend } from "../color/hex"; import { labBrighten, labDarken } from "../color/lab"; import { rgbContrast } from "../color/rgb"; @@ -37,6 +38,13 @@ export const applyThemesOnElement = ( if (themeOptions.dark) { cacheKey = `${cacheKey}__dark`; themeRules = darkStyles; + if (themeOptions.primaryColor) { + themeRules["app-header-background-color"] = hexBlend( + themeOptions.primaryColor, + "#121212", + 8 + ); + } } if (themeOptions.primaryColor) { cacheKey = `${cacheKey}__primary_${themeOptions.primaryColor}`; diff --git a/src/common/dom/dynamic-element-directive.ts b/src/common/dom/dynamic-element-directive.ts index 8f05f437ef..fe863e48b8 100644 --- a/src/common/dom/dynamic-element-directive.ts +++ b/src/common/dom/dynamic-element-directive.ts @@ -10,10 +10,7 @@ export const dynamicElement = directive( let element = part.value as HTMLElement | undefined; - if ( - element !== undefined && - tag.toUpperCase() === (element as HTMLElement).tagName - ) { + if (tag === element?.localName) { if (properties) { Object.entries(properties).forEach(([key, value]) => { element![key] = value; diff --git a/src/common/entity/binary_sensor_icon.ts b/src/common/entity/binary_sensor_icon.ts index 03c3abaaab..1ae025a2df 100644 --- a/src/common/entity/binary_sensor_icon.ts +++ b/src/common/entity/binary_sensor_icon.ts @@ -18,12 +18,12 @@ export const binarySensorIcon = (state?: string, stateObj?: HassEntity) => { case "garage_door": return is_off ? "hass:garage" : "hass:garage-open"; case "power": - return is_off ? "hass:power-off" : "hass:power-on"; + return is_off ? "hass:power-plug-off" : "hass:power-plug"; case "gas": case "problem": case "safety": case "smoke": - return is_off ? "hass:shield-check" : "hass:alert"; + return is_off ? "hass:check-circle" : "hass:alert-circle"; case "heat": return is_off ? "hass:thermometer" : "hass:fire"; case "light": diff --git a/src/common/entity/compute_state_display.ts b/src/common/entity/compute_state_display.ts index 2a437901ea..d588cd1688 100644 --- a/src/common/entity/compute_state_display.ts +++ b/src/common/entity/compute_state_display.ts @@ -5,6 +5,7 @@ import { formatDateTime } from "../datetime/format_date_time"; import { formatTime } from "../datetime/format_time"; import { LocalizeFunc } from "../translations/localize"; import { computeStateDomain } from "./compute_state_domain"; +import { numberFormat } from "../string/number-format"; export const computeStateDisplay = ( localize: LocalizeFunc, @@ -19,7 +20,9 @@ export const computeStateDisplay = ( } if (stateObj.attributes.unit_of_measurement) { - return `${compareState} ${stateObj.attributes.unit_of_measurement}`; + return `${numberFormat(compareState, language)} ${ + stateObj.attributes.unit_of_measurement + }`; } const domain = computeStateDomain(stateObj); diff --git a/src/common/entity/cover_icon.ts b/src/common/entity/cover_icon.ts index 5723ee2d34..7c9bc5ed53 100644 --- a/src/common/entity/cover_icon.ts +++ b/src/common/entity/cover_icon.ts @@ -43,6 +43,7 @@ export const coverIcon = (state?: string, stateObj?: HassEntity): string => { } case "blind": case "curtain": + case "shade": switch (state) { case "opening": return "hass:arrow-up-box"; diff --git a/src/common/search/search-input.ts b/src/common/search/search-input.ts index 1d52dbcde6..97a52b1864 100644 --- a/src/common/search/search-input.ts +++ b/src/common/search/search-input.ts @@ -51,21 +51,17 @@ class SearchInput extends LitElement { @value-changed=${this._filterInputChanged} .noLabelFloat=${this.noLabelFloat} > - + + + ${this.filter && html` - + `} diff --git a/src/common/string/filter/char-code.ts b/src/common/string/filter/char-code.ts new file mode 100644 index 0000000000..faa7210898 --- /dev/null +++ b/src/common/string/filter/char-code.ts @@ -0,0 +1,244 @@ +// MIT License + +// Copyright (c) 2015 - present Microsoft Corporation + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// Names from https://blog.codinghorror.com/ascii-pronunciation-rules-for-programmers/ + +/** + * An inlined enum containing useful character codes (to be used with String.charCodeAt). + * Please leave the const keyword such that it gets inlined when compiled to JavaScript! + */ +export enum CharCode { + Null = 0, + /** + * The `\b` character. + */ + Backspace = 8, + /** + * The `\t` character. + */ + Tab = 9, + /** + * The `\n` character. + */ + LineFeed = 10, + /** + * The `\r` character. + */ + CarriageReturn = 13, + Space = 32, + /** + * The `!` character. + */ + ExclamationMark = 33, + /** + * The `"` character. + */ + DoubleQuote = 34, + /** + * The `#` character. + */ + Hash = 35, + /** + * The `$` character. + */ + DollarSign = 36, + /** + * The `%` character. + */ + PercentSign = 37, + /** + * The `&` character. + */ + Ampersand = 38, + /** + * The `'` character. + */ + SingleQuote = 39, + /** + * The `(` character. + */ + OpenParen = 40, + /** + * The `)` character. + */ + CloseParen = 41, + /** + * The `*` character. + */ + Asterisk = 42, + /** + * The `+` character. + */ + Plus = 43, + /** + * The `,` character. + */ + Comma = 44, + /** + * The `-` character. + */ + Dash = 45, + /** + * The `.` character. + */ + Period = 46, + /** + * The `/` character. + */ + Slash = 47, + + Digit0 = 48, + Digit1 = 49, + Digit2 = 50, + Digit3 = 51, + Digit4 = 52, + Digit5 = 53, + Digit6 = 54, + Digit7 = 55, + Digit8 = 56, + Digit9 = 57, + + /** + * The `:` character. + */ + Colon = 58, + /** + * The `;` character. + */ + Semicolon = 59, + /** + * The `<` character. + */ + LessThan = 60, + /** + * The `=` character. + */ + Equals = 61, + /** + * The `>` character. + */ + GreaterThan = 62, + /** + * The `?` character. + */ + QuestionMark = 63, + /** + * The `@` character. + */ + AtSign = 64, + + A = 65, + B = 66, + C = 67, + D = 68, + E = 69, + F = 70, + G = 71, + H = 72, + I = 73, + J = 74, + K = 75, + L = 76, + M = 77, + N = 78, + O = 79, + P = 80, + Q = 81, + R = 82, + S = 83, + T = 84, + U = 85, + V = 86, + W = 87, + X = 88, + Y = 89, + Z = 90, + + /** + * The `[` character. + */ + OpenSquareBracket = 91, + /** + * The `\` character. + */ + Backslash = 92, + /** + * The `]` character. + */ + CloseSquareBracket = 93, + /** + * The `^` character. + */ + Caret = 94, + /** + * The `_` character. + */ + Underline = 95, + /** + * The ``(`)`` character. + */ + BackTick = 96, + + a = 97, + b = 98, + c = 99, + d = 100, + e = 101, + f = 102, + g = 103, + h = 104, + i = 105, + j = 106, + k = 107, + l = 108, + m = 109, + n = 110, + o = 111, + p = 112, + q = 113, + r = 114, + s = 115, + t = 116, + u = 117, + v = 118, + w = 119, + x = 120, + y = 121, + z = 122, + + /** + * The `{` character. + */ + OpenCurlyBrace = 123, + /** + * The `|` character. + */ + Pipe = 124, + /** + * The `}` character. + */ + CloseCurlyBrace = 125, + /** + * The `~` character. + */ + Tilde = 126, +} diff --git a/src/common/string/filter/filter.ts b/src/common/string/filter/filter.ts new file mode 100644 index 0000000000..d3471433ab --- /dev/null +++ b/src/common/string/filter/filter.ts @@ -0,0 +1,463 @@ +/* eslint-disable no-console */ +// MIT License + +// Copyright (c) 2015 - present Microsoft Corporation + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { CharCode } from "./char-code"; + +const _debug = false; + +export interface Match { + start: number; + end: number; +} + +const _maxLen = 128; + +function initTable() { + const table: number[][] = []; + const row: number[] = [0]; + for (let i = 1; i <= _maxLen; i++) { + row.push(-i); + } + for (let i = 0; i <= _maxLen; i++) { + const thisRow = row.slice(0); + thisRow[0] = -i; + table.push(thisRow); + } + return table; +} + +function isSeparatorAtPos(value: string, index: number): boolean { + if (index < 0 || index >= value.length) { + return false; + } + const code = value.charCodeAt(index); + switch (code) { + case CharCode.Underline: + case CharCode.Dash: + case CharCode.Period: + case CharCode.Space: + case CharCode.Slash: + case CharCode.Backslash: + case CharCode.SingleQuote: + case CharCode.DoubleQuote: + case CharCode.Colon: + case CharCode.DollarSign: + return true; + default: + return false; + } +} + +function isWhitespaceAtPos(value: string, index: number): boolean { + if (index < 0 || index >= value.length) { + return false; + } + const code = value.charCodeAt(index); + switch (code) { + case CharCode.Space: + case CharCode.Tab: + return true; + default: + return false; + } +} + +function isUpperCaseAtPos(pos: number, word: string, wordLow: string): boolean { + return word[pos] !== wordLow[pos]; +} + +function isPatternInWord( + patternLow: string, + patternPos: number, + patternLen: number, + wordLow: string, + wordPos: number, + wordLen: number +): boolean { + while (patternPos < patternLen && wordPos < wordLen) { + if (patternLow[patternPos] === wordLow[wordPos]) { + patternPos += 1; + } + wordPos += 1; + } + return patternPos === patternLen; // pattern must be exhausted +} + +enum Arrow { + Top = 0b1, + Diag = 0b10, + Left = 0b100, +} + +/** + * A tuple of three values. + * 0. the score + * 1. the matches encoded as bitmask (2^53) + * 2. the offset at which matching started + */ +export type FuzzyScore = [number, number, number]; + +interface FilterGlobals { + _matchesCount: number; + _topMatch2: number; + _topScore: number; + _wordStart: number; + _firstMatchCanBeWeak: boolean; + _table: number[][]; + _scores: number[][]; + _arrows: Arrow[][]; +} + +function initGlobals(): FilterGlobals { + return { + _matchesCount: 0, + _topMatch2: 0, + _topScore: 0, + _wordStart: 0, + _firstMatchCanBeWeak: false, + _table: initTable(), + _scores: initTable(), + _arrows: initTable(), + }; +} + +export function fuzzyScore( + pattern: string, + patternLow: string, + patternStart: number, + word: string, + wordLow: string, + wordStart: number, + firstMatchCanBeWeak: boolean +): FuzzyScore | undefined { + const globals = initGlobals(); + const patternLen = pattern.length > _maxLen ? _maxLen : pattern.length; + const wordLen = word.length > _maxLen ? _maxLen : word.length; + + if ( + patternStart >= patternLen || + wordStart >= wordLen || + patternLen - patternStart > wordLen - wordStart + ) { + return undefined; + } + + // Run a simple check if the characters of pattern occur + // (in order) at all in word. If that isn't the case we + // stop because no match will be possible + if ( + !isPatternInWord( + patternLow, + patternStart, + patternLen, + wordLow, + wordStart, + wordLen + ) + ) { + return undefined; + } + + let row = 1; + let column = 1; + let patternPos = patternStart; + let wordPos = wordStart; + + let hasStrongFirstMatch = false; + + // There will be a match, fill in tables + for ( + row = 1, patternPos = patternStart; + patternPos < patternLen; + row++, patternPos++ + ) { + for ( + column = 1, wordPos = wordStart; + wordPos < wordLen; + column++, wordPos++ + ) { + const score = _doScore( + pattern, + patternLow, + patternPos, + patternStart, + word, + wordLow, + wordPos + ); + + if (patternPos === patternStart && score > 1) { + hasStrongFirstMatch = true; + } + + globals._scores[row][column] = score; + + const diag = + globals._table[row - 1][column - 1] + (score > 1 ? 1 : score); + const top = globals._table[row - 1][column] + -1; + const left = globals._table[row][column - 1] + -1; + + if (left >= top) { + // left or diag + if (left > diag) { + globals._table[row][column] = left; + globals._arrows[row][column] = Arrow.Left; + } else if (left === diag) { + globals._table[row][column] = left; + globals._arrows[row][column] = Arrow.Left || Arrow.Diag; + } else { + globals._table[row][column] = diag; + globals._arrows[row][column] = Arrow.Diag; + } + } else if (top > diag) { + globals._table[row][column] = top; + globals._arrows[row][column] = Arrow.Top; + } else if (top === diag) { + globals._table[row][column] = top; + globals._arrows[row][column] = Arrow.Top || Arrow.Diag; + } else { + globals._table[row][column] = diag; + globals._arrows[row][column] = Arrow.Diag; + } + } + } + + if (_debug) { + printTables(pattern, patternStart, word, wordStart, globals); + } + + if (!hasStrongFirstMatch && !firstMatchCanBeWeak) { + return undefined; + } + + globals._matchesCount = 0; + globals._topScore = -100; + globals._wordStart = wordStart; + globals._firstMatchCanBeWeak = firstMatchCanBeWeak; + + _findAllMatches2( + row - 1, + column - 1, + patternLen === wordLen ? 1 : 0, + 0, + false, + globals + ); + if (globals._matchesCount === 0) { + return undefined; + } + + return [globals._topScore, globals._topMatch2, wordStart]; +} + +function _doScore( + pattern: string, + patternLow: string, + patternPos: number, + patternStart: number, + word: string, + wordLow: string, + wordPos: number +) { + if (patternLow[patternPos] !== wordLow[wordPos]) { + return -1; + } + if (wordPos === patternPos - patternStart) { + // common prefix: `foobar <-> foobaz` + // ^^^^^ + if (pattern[patternPos] === word[wordPos]) { + return 7; + } + return 5; + } + + if ( + isUpperCaseAtPos(wordPos, word, wordLow) && + (wordPos === 0 || !isUpperCaseAtPos(wordPos - 1, word, wordLow)) + ) { + // hitting upper-case: `foo <-> forOthers` + // ^^ ^ + if (pattern[patternPos] === word[wordPos]) { + return 7; + } + return 5; + } + + if ( + isSeparatorAtPos(wordLow, wordPos) && + (wordPos === 0 || !isSeparatorAtPos(wordLow, wordPos - 1)) + ) { + // hitting a separator: `. <-> foo.bar` + // ^ + return 5; + } + + if ( + isSeparatorAtPos(wordLow, wordPos - 1) || + isWhitespaceAtPos(wordLow, wordPos - 1) + ) { + // post separator: `foo <-> bar_foo` + // ^^^ + return 5; + } + return 1; +} + +function printTable( + table: number[][], + pattern: string, + patternLen: number, + word: string, + wordLen: number +): string { + function pad(s: string, n: number, _pad = " ") { + while (s.length < n) { + s = _pad + s; + } + return s; + } + let ret = ` | |${word + .split("") + .map((c) => pad(c, 3)) + .join("|")}\n`; + + for (let i = 0; i <= patternLen; i++) { + if (i === 0) { + ret += " |"; + } else { + ret += `${pattern[i - 1]}|`; + } + ret += + table[i] + .slice(0, wordLen + 1) + .map((n) => pad(n.toString(), 3)) + .join("|") + "\n"; + } + return ret; +} + +function printTables( + pattern: string, + patternStart: number, + word: string, + wordStart: number, + globals: FilterGlobals +): void { + pattern = pattern.substr(patternStart); + word = word.substr(wordStart); + console.log( + printTable(globals._table, pattern, pattern.length, word, word.length) + ); + console.log( + printTable(globals._arrows, pattern, pattern.length, word, word.length) + ); + console.log( + printTable(globals._scores, pattern, pattern.length, word, word.length) + ); +} + +function _findAllMatches2( + row: number, + column: number, + total: number, + matches: number, + lastMatched: boolean, + globals: FilterGlobals +): void { + if (globals._matchesCount >= 10 || total < -25) { + // stop when having already 10 results, or + // when a potential alignment as already 5 gaps + return; + } + + let simpleMatchCount = 0; + + while (row > 0 && column > 0) { + const score = globals._scores[row][column]; + const arrow = globals._arrows[row][column]; + + if (arrow === Arrow.Left) { + // left -> no match, skip a word character + column -= 1; + if (lastMatched) { + total -= 5; // new gap penalty + } else if (matches !== 0) { + total -= 1; // gap penalty after first match + } + lastMatched = false; + simpleMatchCount = 0; + } else if (arrow && Arrow.Diag) { + if (arrow && Arrow.Left) { + // left + _findAllMatches2( + row, + column - 1, + matches !== 0 ? total - 1 : total, // gap penalty after first match + matches, + lastMatched, + globals + ); + } + + // diag + total += score; + row -= 1; + column -= 1; + lastMatched = true; + + // match -> set a 1 at the word pos + matches += 2 ** (column + globals._wordStart); + + // count simple matches and boost a row of + // simple matches when they yield in a + // strong match. + if (score === 1) { + simpleMatchCount += 1; + + if (row === 0 && !globals._firstMatchCanBeWeak) { + // when the first match is a weak + // match we discard it + return; + } + } else { + // boost + total += 1 + simpleMatchCount * (score - 1); + simpleMatchCount = 0; + } + } else { + return; + } + } + + total -= column >= 3 ? 9 : column * 3; // late start penalty + + // dynamically keep track of the current top score + // and insert the current best score at head, the rest at tail + globals._matchesCount += 1; + if (total > globals._topScore) { + globals._topScore = total; + globals._topMatch2 = matches; + } +} + +// #endregion diff --git a/src/common/string/filter/sequence-matching.ts b/src/common/string/filter/sequence-matching.ts new file mode 100644 index 0000000000..dac81672a8 --- /dev/null +++ b/src/common/string/filter/sequence-matching.ts @@ -0,0 +1,66 @@ +import { fuzzyScore } from "./filter"; + +/** + * Determine whether a sequence of letters exists in another string, + * in that order, allowing for skipping. Ex: "chdr" exists in "chandelier") + * + * @param {string} filter - Sequence of letters to check for + * @param {string} word - Word to check for sequence + * + * @return {number} Score representing how well the word matches the filter. Return of 0 means no match. + */ + +export const fuzzySequentialMatch = (filter: string, ...words: string[]) => { + let topScore = 0; + + for (const word of words) { + const scores = fuzzyScore( + filter, + filter.toLowerCase(), + 0, + word, + word.toLowerCase(), + 0, + true + ); + + if (!scores) { + continue; + } + + // The VS Code implementation of filter treats a score of "0" as just barely a match + // But we will typically use this matcher in a .filter(), which interprets 0 as a failure. + // By shifting all scores up by 1, we allow "0" matches, while retaining score precedence + const score = scores[0] + 1; + + if (score > topScore) { + topScore = score; + } + } + return topScore; +}; + +export interface ScorableTextItem { + score?: number; + text: string; + altText?: string; +} + +type FuzzyFilterSort = ( + filter: string, + items: T[] +) => T[]; + +export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) => { + return items + .map((item) => { + item.score = item.altText + ? fuzzySequentialMatch(filter, item.text, item.altText) + : fuzzySequentialMatch(filter, item.text); + return item; + }) + .filter((item) => item.score !== undefined && item.score > 0) + .sort(({ score: scoreA = 0 }, { score: scoreB = 0 }) => + scoreA > scoreB ? -1 : scoreA < scoreB ? 1 : 0 + ); +}; diff --git a/src/common/string/number-format.ts b/src/common/string/number-format.ts new file mode 100644 index 0000000000..39d20e47a5 --- /dev/null +++ b/src/common/string/number-format.ts @@ -0,0 +1,22 @@ +/** + * Formats a number based on the specified language with thousands separator(s) and decimal character for better legibility. + * + * @param num The number to format + * @param language The language to use when formatting the number + */ +export const numberFormat = ( + num: string | number, + language: string +): string => { + // Polyfill for Number.isNaN, which is more reliable that the global isNaN() + Number.isNaN = + Number.isNaN || + function isNaN(input) { + return typeof input === "number" && isNaN(input); + }; + + if (!Number.isNaN(Number(num)) && Intl) { + return new Intl.NumberFormat(language).format(Number(num)); + } + return num.toString(); +}; diff --git a/src/common/translations/localize.ts b/src/common/translations/localize.ts index 696a765872..6ebba251b8 100644 --- a/src/common/translations/localize.ts +++ b/src/common/translations/localize.ts @@ -1,4 +1,5 @@ import IntlMessageFormat from "intl-messageformat"; +import { shouldPolyfill } from "@formatjs/intl-pluralrules/should-polyfill"; import { Resources } from "../../types"; export type LocalizeFunc = (key: string, ...args: any[]) => string; @@ -12,9 +13,12 @@ export interface FormatsType { time: FormatType; } -if (!Intl.PluralRules) { - import("@formatjs/intl-pluralrules/polyfill-locales"); -} +let polyfillLoaded = !shouldPolyfill(); +const polyfillProm = polyfillLoaded + ? undefined + : import("@formatjs/intl-pluralrules/polyfill-locales").then(() => { + polyfillLoaded = true; + }); /** * Adapted from Polymer app-localize-behavior. @@ -37,12 +41,16 @@ if (!Intl.PluralRules) { * } */ -export const computeLocalize = ( +export const computeLocalize = async ( cache: any, language: string, resources: Resources, formats?: FormatsType -): LocalizeFunc => { +): Promise => { + if (!polyfillLoaded) { + await polyfillProm; + } + // Everytime any of the parameters change, invalidate the strings cache. cache._localizationCache = {}; diff --git a/src/common/util/debounce.ts b/src/common/util/debounce.ts index fedd679e2e..557d596540 100644 --- a/src/common/util/debounce.ts +++ b/src/common/util/debounce.ts @@ -5,7 +5,7 @@ // N milliseconds. If `immediate` is passed, trigger the function on the // leading edge, instead of the trailing. // eslint-disable-next-line: ban-types -export const debounce = ( +export const debounce = unknown>( func: T, wait, immediate = false diff --git a/src/common/util/subscribe-one.ts b/src/common/util/subscribe-one.ts index f86bfb7388..51bfa1081d 100644 --- a/src/common/util/subscribe-one.ts +++ b/src/common/util/subscribe-one.ts @@ -2,7 +2,10 @@ import { Connection, UnsubscribeFunc } from "home-assistant-js-websocket"; export const subscribeOne = async ( conn: Connection, - subscribe: (conn: Connection, onChange: (items: T) => void) => UnsubscribeFunc + subscribe: ( + conn2: Connection, + onChange: (items: T) => void + ) => UnsubscribeFunc ) => new Promise((resolve) => { const unsub = subscribe(conn, (items) => { diff --git a/src/common/util/throttle.ts b/src/common/util/throttle.ts index 1cd98ea188..4832a2709b 100644 --- a/src/common/util/throttle.ts +++ b/src/common/util/throttle.ts @@ -5,7 +5,7 @@ // as much as it can, without ever going more than once per `wait` duration; // but if you'd like to disable the execution on the leading edge, pass // `false for leading`. To disable execution on the trailing edge, ditto. -export const throttle = ( +export const throttle = unknown>( func: T, wait: number, leading = true, diff --git a/src/components/buttons/ha-progress-button.ts b/src/components/buttons/ha-progress-button.ts index a446d456fc..c6c325fdab 100644 --- a/src/components/buttons/ha-progress-button.ts +++ b/src/components/buttons/ha-progress-button.ts @@ -21,7 +21,7 @@ class HaProgressButton extends LitElement { @property({ type: Boolean }) public raised = false; - @query("mwc-button") private _button?: Button; + @query("mwc-button", true) private _button?: Button; public render(): TemplateResult { return html` diff --git a/src/components/data-table/ha-data-table.ts b/src/components/data-table/ha-data-table.ts index db83db5a6d..1b8530dcec 100644 --- a/src/components/data-table/ha-data-table.ts +++ b/src/components/data-table/ha-data-table.ts @@ -73,13 +73,17 @@ export interface DataTableColumnData extends DataTableSortColumnData { hidden?: boolean; } +type ClonedDataTableColumnData = Omit & { + title?: string; +}; + export interface DataTableRowData { [key: string]: any; selectable?: boolean; } export interface SortableColumnContainer { - [key: string]: DataTableSortColumnData; + [key: string]: ClonedDataTableColumnData; } @customElement("ha-data-table") @@ -90,6 +94,8 @@ export class HaDataTable extends LitElement { @property({ type: Boolean }) public selectable = false; + @property({ type: Boolean }) public clickable = false; + @property({ type: Boolean }) public hasFab = false; @property({ type: Boolean, attribute: "auto-height" }) @@ -101,6 +107,9 @@ export class HaDataTable extends LitElement { @property({ type: String }) public searchLabel?: string; + @property({ type: Boolean, attribute: "no-label-float" }) + public noLabelFloat? = false; + @property({ type: String }) public filter = ""; @internalProperty() private _filterable = false; @@ -113,9 +122,9 @@ export class HaDataTable extends LitElement { @internalProperty() private _filteredData: DataTableRowData[] = []; - @query("slot[name='header']") private _header!: HTMLSlotElement; + @internalProperty() private _headerHeight = 0; - @query(".mdc-data-table__table") private _table!: HTMLDivElement; + @query("slot[name='header']") private _header!: HTMLSlotElement; private _checkableRowsCount?: number; @@ -166,11 +175,13 @@ export class HaDataTable extends LitElement { } const clonedColumns: DataTableColumnContainer = deepClone(this.columns); - Object.values(clonedColumns).forEach((column: DataTableColumnData) => { - delete column.title; - delete column.type; - delete column.template; - }); + Object.values(clonedColumns).forEach( + (column: ClonedDataTableColumnData) => { + delete column.title; + delete column.type; + delete column.template; + } + ); this._sortColumns = clonedColumns; } @@ -206,6 +217,7 @@ export class HaDataTable extends LitElement {
` @@ -220,7 +232,7 @@ export class HaDataTable extends LitElement { style=${styleMap({ height: this.autoHeight ? `${(this._filteredData.length || 1) * 53 + 57}px` - : `calc(100% - ${this._header?.clientHeight}px)`, + : `calc(100% - ${this._headerHeight}px)`, })} >
@@ -317,12 +329,13 @@ export class HaDataTable extends LitElement {
> => { if (!worker) { - worker = wrap(new Worker("./sort_filter_worker", { type: "module" })); + worker = wrap(new Worker(new URL("./sort_filter_worker", import.meta.url))); } return await worker.filterData(data, columns, filter); @@ -29,7 +29,7 @@ export const sortData = async ( sortColumn: SortDataParamTypes[3] ): Promise> => { if (!worker) { - worker = wrap(new Worker("./sort_filter_worker", { type: "module" })); + worker = wrap(new Worker(new URL("./sort_filter_worker", import.meta.url))); } return await worker.sortData(data, columns, direction, sortColumn); diff --git a/src/components/date-range-picker.ts b/src/components/date-range-picker.ts index cc0e7d3f29..70211da366 100644 --- a/src/components/date-range-picker.ts +++ b/src/components/date-range-picker.ts @@ -1,7 +1,7 @@ +// @ts-nocheck import Vue from "vue"; import wrap from "@vue/web-component-wrapper"; import DateRangePicker from "vue2-daterange-picker"; -// @ts-ignore import dateRangePickerStyles from "vue2-daterange-picker/dist/vue2-daterange-picker.css"; import { fireEvent } from "../common/dom/fire_event"; import { Constructor } from "../types"; @@ -35,7 +35,6 @@ const Component = Vue.extend({ }, }, render(createElement) { - // @ts-ignore return createElement(DateRangePicker, { props: { "time-picker": true, @@ -52,7 +51,6 @@ const Component = Vue.extend({ endDate: this.endDate, }, callback: (value) => { - // @ts-ignore fireEvent(this.$el as HTMLElement, "change", value); }, expression: "dateRange", diff --git a/src/components/device/ha-device-action-picker.ts b/src/components/device/ha-device-action-picker.ts index eaca169438..e8e50f5b47 100644 --- a/src/components/device/ha-device-action-picker.ts +++ b/src/components/device/ha-device-action-picker.ts @@ -9,9 +9,17 @@ import { HaDeviceAutomationPicker } from "./ha-device-automation-picker"; @customElement("ha-device-action-picker") class HaDeviceActionPicker extends HaDeviceAutomationPicker { - protected NO_AUTOMATION_TEXT = "No actions"; + protected get NO_AUTOMATION_TEXT() { + return this.hass.localize( + "ui.panel.config.devices.automation.actions.no_actions" + ); + } - protected UNKNOWN_AUTOMATION_TEXT = "Unknown action"; + protected get UNKNOWN_AUTOMATION_TEXT() { + return this.hass.localize( + "ui.panel.config.devices.automation.actions.unknown_action" + ); + } constructor() { super( diff --git a/src/components/device/ha-device-automation-picker.ts b/src/components/device/ha-device-automation-picker.ts index 540cfc5c9d..fe56884a09 100644 --- a/src/components/device/ha-device-automation-picker.ts +++ b/src/components/device/ha-device-automation-picker.ts @@ -33,16 +33,24 @@ export abstract class HaDeviceAutomationPicker< @property() public value?: T; - protected NO_AUTOMATION_TEXT = "No automations"; - - protected UNKNOWN_AUTOMATION_TEXT = "Unknown automation"; - @internalProperty() private _automations: T[] = []; // Trigger an empty render so we start with a clean DOM. // paper-listbox does not like changing things around. @internalProperty() private _renderEmpty = false; + protected get NO_AUTOMATION_TEXT() { + return this.hass.localize( + "ui.panel.config.devices.automation.actions.no_actions" + ); + } + + protected get UNKNOWN_AUTOMATION_TEXT() { + return this.hass.localize( + "ui.panel.config.devices.automation.actions.unknown_action" + ); + } + private _localizeDeviceAutomation: ( hass: HomeAssistant, automation: T diff --git a/src/components/device/ha-device-condition-picker.ts b/src/components/device/ha-device-condition-picker.ts index dbe95c8f6a..89fefbafaf 100644 --- a/src/components/device/ha-device-condition-picker.ts +++ b/src/components/device/ha-device-condition-picker.ts @@ -11,9 +11,17 @@ import { HaDeviceAutomationPicker } from "./ha-device-automation-picker"; class HaDeviceConditionPicker extends HaDeviceAutomationPicker< DeviceCondition > { - protected NO_AUTOMATION_TEXT = "No conditions"; + protected get NO_AUTOMATION_TEXT() { + return this.hass.localize( + "ui.panel.config.devices.automation.conditions.no_conditions" + ); + } - protected UNKNOWN_AUTOMATION_TEXT = "Unknown condition"; + protected get UNKNOWN_AUTOMATION_TEXT() { + return this.hass.localize( + "ui.panel.config.devices.automation.conditions.unknown_condition" + ); + } constructor() { super( diff --git a/src/components/device/ha-device-trigger-picker.ts b/src/components/device/ha-device-trigger-picker.ts index ec140426ae..d1ff66d37e 100644 --- a/src/components/device/ha-device-trigger-picker.ts +++ b/src/components/device/ha-device-trigger-picker.ts @@ -9,9 +9,17 @@ import { HaDeviceAutomationPicker } from "./ha-device-automation-picker"; @customElement("ha-device-trigger-picker") class HaDeviceTriggerPicker extends HaDeviceAutomationPicker { - protected NO_AUTOMATION_TEXT = "No triggers"; + protected get NO_AUTOMATION_TEXT() { + return this.hass.localize( + "ui.panel.config.devices.automation.triggers.no_triggers" + ); + } - protected UNKNOWN_AUTOMATION_TEXT = "Unknown trigger"; + protected get UNKNOWN_AUTOMATION_TEXT() { + return this.hass.localize( + "ui.panel.config.devices.automation.triggers.unknown_trigger" + ); + } constructor() { super( diff --git a/src/components/entity/ha-chart-base.js b/src/components/entity/ha-chart-base.js index 7df570747c..09ebaef2c4 100644 --- a/src/components/entity/ha-chart-base.js +++ b/src/components/entity/ha-chart-base.js @@ -71,14 +71,25 @@ class HaChartBase extends mixinBehaviors( margin: 5px 0 0 0; width: 100%; } + .chartTooltip ul { + margin: 0 3px; + } .chartTooltip li { display: block; white-space: pre-line; } + .chartTooltip li::first-line { + line-height: 0; + } .chartTooltip .title { text-align: center; font-weight: 500; } + .chartTooltip .beforeBody { + text-align: center; + font-weight: 300; + word-break: break-all; + } .chartLegend li { display: inline-block; padding: 0 6px; @@ -133,6 +144,9 @@ class HaChartBase extends mixinBehaviors( style$="opacity:[[tooltip.opacity]]; top:[[tooltip.top]]; left:[[tooltip.left]]; padding:[[tooltip.yPadding]]px [[tooltip.xPadding]]px" >
[[tooltip.title]]
+
    diff --git a/src/components/ha-attributes.ts b/src/components/ha-attributes.ts index a92d4912b6..7f3966bc26 100644 --- a/src/components/ha-attributes.ts +++ b/src/components/ha-attributes.ts @@ -33,7 +33,9 @@ class HaAttributes extends LitElement { ).map( (attribute) => html`
    -
    ${attribute.replace(/_/g, " ")}
    +
    + ${attribute.replace(/_/g, " ").replace(/\bid\b/g, "ID")} +
    ${this.formatAttribute(attribute)}
    @@ -61,10 +63,14 @@ class HaAttributes extends LitElement { .data-entry .value { max-width: 200px; overflow-wrap: break-word; + text-align: right; + } + .key:first-letter { + text-transform: capitalize; } .attribution { color: var(--secondary-text-color); - text-align: right; + text-align: center; } pre { font-family: inherit; diff --git a/src/components/ha-bar.ts b/src/components/ha-bar.ts index b9d160bf13..6d449c3306 100644 --- a/src/components/ha-bar.ts +++ b/src/components/ha-bar.ts @@ -34,8 +34,8 @@ export class HaBar extends LitElement { return svg` - - + + `; @@ -43,6 +43,9 @@ export class HaBar extends LitElement { static get styles(): CSSResult { return css` + rect { + height: 100%; + } rect:first-child { width: 100%; fill: var(--ha-bar-background-color, var(--secondary-background-color)); diff --git a/src/components/ha-button-menu.ts b/src/components/ha-button-menu.ts index 7adc266a56..5aafc86d78 100644 --- a/src/components/ha-button-menu.ts +++ b/src/components/ha-button-menu.ts @@ -23,7 +23,7 @@ export class HaButtonMenu extends LitElement { @property({ type: Boolean }) public disabled = false; - @query("mwc-menu") private _menu?: Menu; + @query("mwc-menu", true) private _menu?: Menu; public get items() { return this._menu?.items; @@ -62,6 +62,9 @@ export class HaButtonMenu extends LitElement { display: inline-block; position: relative; } + ::slotted([disabled]) { + color: var(--disabled-text-color); + } `; } } diff --git a/src/components/ha-card.ts b/src/components/ha-card.ts index cf215968c4..157d663bfe 100644 --- a/src/components/ha-card.ts +++ b/src/components/ha-card.ts @@ -50,9 +50,12 @@ export class HaCard extends LitElement { font-family: var(--ha-card-header-font-family, inherit); font-size: var(--ha-card-header-font-size, 24px); letter-spacing: -0.012em; - line-height: 32px; - padding: 24px 16px 16px; + line-height: 48px; + padding: 12px 16px 16px; display: block; + margin-block-start: 0px; + margin-block-end: 0px; + font-weight: normal; } :host ::slotted(.card-content:not(:first-child)), @@ -75,7 +78,7 @@ export class HaCard extends LitElement { protected render(): TemplateResult { return html` ${this.header - ? html`
    ${this.header}
    ` + ? html`

    ${this.header}

    ` : html``} `; diff --git a/src/components/ha-circular-progress.ts b/src/components/ha-circular-progress.ts index d28837872e..1e70908291 100644 --- a/src/components/ha-circular-progress.ts +++ b/src/components/ha-circular-progress.ts @@ -1,20 +1,9 @@ -// @ts-ignore -import progressStyles from "@material/circular-progress/dist/mdc.circular-progress.min.css"; -import { - css, - customElement, - html, - LitElement, - property, - svg, - SVGTemplateResult, - TemplateResult, - unsafeCSS, -} from "lit-element"; -import { classMap } from "lit-html/directives/class-map"; +import { customElement, property } from "lit-element"; +import { CircularProgress } from "@material/mwc-circular-progress"; @customElement("ha-circular-progress") -export class HaCircularProgress extends LitElement { +// @ts-ignore +export class HaCircularProgress extends CircularProgress { @property({ type: Boolean }) public active = false; @@ -24,65 +13,31 @@ export class HaCircularProgress extends LitElement { @property() public size: "small" | "medium" | "large" = "medium"; - protected render(): TemplateResult { - let indeterminatePart: SVGTemplateResult; - - if (this.size === "small") { - indeterminatePart = svg` - - - `; - } else if (this.size === "large") { - indeterminatePart = svg` - - - `; - } else { - // medium - indeterminatePart = svg` - - - `; - } - - // ignoring prettier as it will introduce unwanted whitespace - // We have not implemented the determinate support of mdc circular progress. - // prettier-ignore - return html` -
    -
    -
    -
    - ${indeterminatePart} -
    - ${indeterminatePart} -
    - ${indeterminatePart} -
    -
    -
    -
    - `; + // @ts-ignore + public set density(_) { + // just a dummy } - static get styles() { - return [ - unsafeCSS(progressStyles), - css` - :host { - text-align: initial; - } - `, - ]; + public get density() { + switch (this.size) { + case "small": + return -5; + case "medium": + return 0; + case "large": + return 5; + default: + return 0; + } + } + + // @ts-ignore + public set indeterminate(_) { + // just a dummy + } + + public get indeterminate() { + return this.active; } } diff --git a/src/components/ha-climate-state.js b/src/components/ha-climate-state.js deleted file mode 100644 index eb961cce4c..0000000000 --- a/src/components/ha-climate-state.js +++ /dev/null @@ -1,131 +0,0 @@ -import { html } from "@polymer/polymer/lib/utils/html-tag"; -/* eslint-plugin-disable lit */ -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import { CLIMATE_PRESET_NONE } from "../data/climate"; -import LocalizeMixin from "../mixins/localize-mixin"; - -/* - * @appliesMixin LocalizeMixin - */ -class HaClimateState extends LocalizeMixin(PolymerElement) { - static get template() { - return html` - - -
    - -
    [[computeTarget(hass, stateObj)]]
    -
    - - - `; - } - - static get properties() { - return { - hass: Object, - stateObj: Object, - currentStatus: { - type: String, - computed: "computeCurrentStatus(hass, stateObj)", - }, - }; - } - - computeCurrentStatus(hass, stateObj) { - if (!hass || !stateObj) return null; - if (stateObj.attributes.current_temperature != null) { - return `${stateObj.attributes.current_temperature} ${hass.config.unit_system.temperature}`; - } - if (stateObj.attributes.current_humidity != null) { - return `${stateObj.attributes.current_humidity} %`; - } - return null; - } - - computeTarget(hass, stateObj) { - if (!hass || !stateObj) return null; - // We're using "!= null" on purpose so that we match both null and undefined. - if ( - stateObj.attributes.target_temp_low != null && - stateObj.attributes.target_temp_high != null - ) { - return `${stateObj.attributes.target_temp_low}-${stateObj.attributes.target_temp_high} ${hass.config.unit_system.temperature}`; - } - if (stateObj.attributes.temperature != null) { - return `${stateObj.attributes.temperature} ${hass.config.unit_system.temperature}`; - } - if ( - stateObj.attributes.target_humidity_low != null && - stateObj.attributes.target_humidity_high != null - ) { - return `${stateObj.attributes.target_humidity_low}-${stateObj.attributes.target_humidity_high}%`; - } - if (stateObj.attributes.humidity != null) { - return `${stateObj.attributes.humidity} %`; - } - - return ""; - } - - _hasKnownState(state) { - return state !== "unknown"; - } - - _localizeState(localize, stateObj) { - const stateString = localize(`component.climate.state._.${stateObj.state}`); - return stateObj.attributes.hvac_action - ? `${localize( - `state_attributes.climate.hvac_action.${stateObj.attributes.hvac_action}` - )} (${stateString})` - : stateString; - } - - _localizePreset(localize, preset) { - return localize(`state_attributes.climate.preset_mode.${preset}`) || preset; - } - - _renderPreset(attributes) { - return ( - attributes.preset_mode && attributes.preset_mode !== CLIMATE_PRESET_NONE - ); - } -} -customElements.define("ha-climate-state", HaClimateState); diff --git a/src/components/ha-climate-state.ts b/src/components/ha-climate-state.ts new file mode 100644 index 0000000000..39ece5ee43 --- /dev/null +++ b/src/components/ha-climate-state.ts @@ -0,0 +1,139 @@ +import { + css, + CSSResult, + customElement, + html, + LitElement, + property, + TemplateResult, +} from "lit-element"; +import { HassEntity } from "home-assistant-js-websocket"; + +import { CLIMATE_PRESET_NONE } from "../data/climate"; +import type { HomeAssistant } from "../types"; + +@customElement("ha-climate-state") +class HaClimateState extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public stateObj!: HassEntity; + + protected render(): TemplateResult { + const currentStatus = this._computeCurrentStatus(); + + return html`
    + ${this.stateObj.state !== "unknown" + ? html` + ${this._localizeState()} + ${this.stateObj.attributes.preset_mode && + this.stateObj.attributes.preset_mode !== CLIMATE_PRESET_NONE + ? html`- + ${this.hass.localize( + `state_attributes.climate.preset_mode.${this.stateObj.attributes.preset_mode}` + ) || this.stateObj.attributes.preset_mode}` + : ""} + ` + : ""} +
    ${this._computeTarget()}
    +
    + + ${currentStatus + ? html`
    + ${this.hass.localize("ui.card.climate.currently")}: +
    ${currentStatus}
    +
    ` + : ""}`; + } + + private _computeCurrentStatus(): string | undefined { + if (!this.hass || !this.stateObj) { + return undefined; + } + + if (this.stateObj.attributes.current_temperature != null) { + return `${this.stateObj.attributes.current_temperature} ${this.hass.config.unit_system.temperature}`; + } + + if (this.stateObj.attributes.current_humidity != null) { + return `${this.stateObj.attributes.current_humidity} %`; + } + + return undefined; + } + + private _computeTarget(): string { + if (!this.hass || !this.stateObj) { + return ""; + } + + if ( + this.stateObj.attributes.target_temp_low != null && + this.stateObj.attributes.target_temp_high != null + ) { + return `${this.stateObj.attributes.target_temp_low}-${this.stateObj.attributes.target_temp_high} ${this.hass.config.unit_system.temperature}`; + } + + if (this.stateObj.attributes.temperature != null) { + return `${this.stateObj.attributes.temperature} ${this.hass.config.unit_system.temperature}`; + } + if ( + this.stateObj.attributes.target_humidity_low != null && + this.stateObj.attributes.target_humidity_high != null + ) { + return `${this.stateObj.attributes.target_humidity_low}-${this.stateObj.attributes.target_humidity_high}%`; + } + + if (this.stateObj.attributes.humidity != null) { + return `${this.stateObj.attributes.humidity} %`; + } + + return ""; + } + + private _localizeState(): string { + const stateString = this.hass.localize( + `component.climate.state._.${this.stateObj.state}` + ); + + return this.stateObj.attributes.hvac_action + ? `${this.hass.localize( + `state_attributes.climate.hvac_action.${this.stateObj.attributes.hvac_action}` + )} (${stateString})` + : stateString; + } + + static get styles(): CSSResult { + return css` + :host { + display: flex; + flex-direction: column; + justify-content: center; + white-space: nowrap; + } + + .target { + color: var(--primary-text-color); + } + + .current { + color: var(--secondary-text-color); + } + + .state-label { + font-weight: bold; + text-transform: capitalize; + } + + .unit { + display: inline-block; + direction: ltr; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-climate-state": HaClimateState; + } +} diff --git a/src/components/ha-code-editor.ts b/src/components/ha-code-editor.ts index b49d57c897..5dc3b7f826 100644 --- a/src/components/ha-code-editor.ts +++ b/src/components/ha-code-editor.ts @@ -81,6 +81,7 @@ export class HaCodeEditor extends UpdatingElement { protected firstUpdated(changedProps: PropertyValues): void { super.firstUpdated(changedProps); + this._blockKeyboardShortcuts(); this._load(); } @@ -232,6 +233,10 @@ export class HaCodeEditor extends UpdatingElement { this.codemirror!.on("changes", () => this._onChange()); } + private _blockKeyboardShortcuts() { + this.addEventListener("keydown", (ev) => ev.stopPropagation()); + } + private _onChange(): void { const newValue = this.value; if (newValue === this._value) { diff --git a/src/components/ha-date-range-picker.ts b/src/components/ha-date-range-picker.ts index 884c893d56..895a33f984 100644 --- a/src/components/ha-date-range-picker.ts +++ b/src/components/ha-date-range-picker.ts @@ -60,7 +60,7 @@ export class HaDateRangePicker extends LitElement { ?ranges=${this.ranges !== undefined} >
    - + html` class="header_button" dir=${computeRTLDirection(hass)} > - + `; diff --git a/src/components/ha-expansion-panel.ts b/src/components/ha-expansion-panel.ts new file mode 100644 index 0000000000..6805ecfa82 --- /dev/null +++ b/src/components/ha-expansion-panel.ts @@ -0,0 +1,119 @@ +import { + css, + CSSResult, + customElement, + html, + LitElement, + property, + query, + TemplateResult, +} from "lit-element"; +import { fireEvent } from "../common/dom/fire_event"; +import "./ha-svg-icon"; +import { mdiChevronDown } from "@mdi/js"; +import { classMap } from "lit-html/directives/class-map"; + +@customElement("ha-expansion-panel") +class HaExpansionPanel extends LitElement { + @property({ type: Boolean, reflect: true }) expanded = false; + + @property({ type: Boolean, reflect: true }) outlined = false; + + @query(".container") private _container!: HTMLDivElement; + + protected render(): TemplateResult { + return html` +
    + + +
    +
    + +
    + `; + } + + private _handleTransitionEnd() { + this._container.style.removeProperty("height"); + } + + private _toggleContainer(): void { + const scrollHeight = this._container.scrollHeight; + this._container.style.height = `${scrollHeight}px`; + + if (this.expanded) { + setTimeout(() => { + this._container.style.height = "0px"; + }, 0); + } + + this.expanded = !this.expanded; + fireEvent(this, "expanded-changed", { expanded: this.expanded }); + } + + static get styles(): CSSResult { + return css` + :host { + display: block; + } + + :host([outlined]) { + box-shadow: none; + border-width: 1px; + border-style: solid; + border-color: var( + --ha-card-border-color, + var(--divider-color, #e0e0e0) + ); + border-radius: var(--ha-card-border-radius, 4px); + } + + .summary { + display: flex; + padding: 0px 16px; + min-height: 48px; + align-items: center; + cursor: pointer; + overflow: hidden; + } + + .summary-icon { + transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1); + margin-left: auto; + } + + .summary-icon.expanded { + transform: rotate(180deg); + } + + .container { + overflow: hidden; + transition: height 300ms cubic-bezier(0.4, 0, 0.2, 1); + height: 0px; + } + + .container.expanded { + height: auto; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-expansion-panel": HaExpansionPanel; + } + + // for fire event + interface HASSDomEvents { + "expanded-changed": { + expanded: boolean; + }; + } +} diff --git a/src/components/ha-form/ha-form-boolean.ts b/src/components/ha-form/ha-form-boolean.ts index c8c956ceca..454a01e718 100644 --- a/src/components/ha-form/ha-form-boolean.ts +++ b/src/components/ha-form/ha-form-boolean.ts @@ -27,7 +27,7 @@ export class HaFormBoolean extends LitElement implements HaFormElement { @property() public suffix!: string; - @query("paper-checkbox") private _input?: HTMLElement; + @query("paper-checkbox", true) private _input?: HTMLElement; public focus() { if (this._input) { diff --git a/src/components/ha-form/ha-form-float.ts b/src/components/ha-form/ha-form-float.ts index d915434a3d..9720f0ffd2 100644 --- a/src/components/ha-form/ha-form-float.ts +++ b/src/components/ha-form/ha-form-float.ts @@ -21,7 +21,7 @@ export class HaFormFloat extends LitElement implements HaFormElement { @property() public suffix!: string; - @query("paper-input") private _input?: HTMLElement; + @query("paper-input", true) private _input?: HTMLElement; public focus() { if (this._input) { diff --git a/src/components/ha-form/ha-form-multi_select.ts b/src/components/ha-form/ha-form-multi_select.ts index b46218ae6f..29f174a160 100644 --- a/src/components/ha-form/ha-form-multi_select.ts +++ b/src/components/ha-form/ha-form-multi_select.ts @@ -35,7 +35,7 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement { @internalProperty() private _init = false; - @query("paper-menu-button") private _input?: HTMLElement; + @query("paper-menu-button", true) private _input?: HTMLElement; public focus(): void { if (this._input) { diff --git a/src/components/ha-form/ha-form-positive_time_period_dict.ts b/src/components/ha-form/ha-form-positive_time_period_dict.ts index b43bbd4ffa..d5a0db5975 100644 --- a/src/components/ha-form/ha-form-positive_time_period_dict.ts +++ b/src/components/ha-form/ha-form-positive_time_period_dict.ts @@ -20,7 +20,7 @@ export class HaFormTimePeriod extends LitElement implements HaFormElement { @property() public suffix!: string; - @query("paper-time-input") private _input?: HTMLElement; + @query("paper-time-input", true) private _input?: HTMLElement; public focus() { if (this._input) { diff --git a/src/components/ha-form/ha-form-select.ts b/src/components/ha-form/ha-form-select.ts index 359a06c5a8..bac4f8d963 100644 --- a/src/components/ha-form/ha-form-select.ts +++ b/src/components/ha-form/ha-form-select.ts @@ -24,7 +24,7 @@ export class HaFormSelect extends LitElement implements HaFormElement { @property() public suffix!: string; - @query("ha-paper-dropdown-menu") private _input?: HTMLElement; + @query("ha-paper-dropdown-menu", true) private _input?: HTMLElement; public focus() { if (this._input) { diff --git a/src/components/ha-form/ha-form-string.ts b/src/components/ha-form/ha-form-string.ts index 46bdbee332..3f62f9de31 100644 --- a/src/components/ha-form/ha-form-string.ts +++ b/src/components/ha-form/ha-form-string.ts @@ -55,6 +55,7 @@ export class HaFormString extends LitElement implements HaFormElement { id="iconButton" title="Click to toggle between masked and clear password" @click=${this._toggleUnmaskedPassword} + tabindex="-1" >
    diff --git a/src/components/ha-hls-player.ts b/src/components/ha-hls-player.ts index 61f189713c..5b84c8d4e2 100644 --- a/src/components/ha-hls-player.ts +++ b/src/components/ha-hls-player.ts @@ -38,6 +38,7 @@ class HaHLSPlayer extends LitElement { @property({ type: Boolean, attribute: "allow-exoplayer" }) public allowExoPlayer = false; + // don't cache this, as we remove it on disconnects @query("video") private _videoEl!: HTMLVideoElement; @internalProperty() private _attached = false; @@ -154,6 +155,9 @@ class HaHLSPlayer extends LitElement { } private _resizeExoPlayer = () => { + if (!this._videoEl) { + return; + } const rect = this._videoEl.getBoundingClientRect(); this.hass!.auth.external!.fireMessage({ type: "exoplayer/resize", diff --git a/src/components/ha-labeled-slider.js b/src/components/ha-labeled-slider.js index a620ec0204..147fd5b1b1 100644 --- a/src/components/ha-labeled-slider.js +++ b/src/components/ha-labeled-slider.js @@ -14,8 +14,8 @@ class HaLabeledSlider extends PolymerElement { } .title { - margin-bottom: 16px; - color: var(--secondary-text-color); + margin-bottom: 8px; + color: var(--primary-text-color); } .slider-container { @@ -43,7 +43,6 @@ class HaLabeledSlider extends PolymerElement { step="[[step]]" pin="[[pin]]" disabled="[[disabled]]" - disabled="[[disabled]]" value="{{value}}" >
    diff --git a/src/components/ha-menu-button.ts b/src/components/ha-menu-button.ts index 7531c989d9..b95d012938 100644 --- a/src/components/ha-menu-button.ts +++ b/src/components/ha-menu-button.ts @@ -6,9 +6,9 @@ import { CSSResult, customElement, html, + internalProperty, LitElement, property, - internalProperty, TemplateResult, } from "lit-element"; import { fireEvent } from "../common/dom/fire_event"; @@ -62,7 +62,7 @@ class HaMenuButton extends LitElement { aria-label=${this.hass.localize("ui.sidebar.sidebar_toggle")} @click=${this._toggleMenu} > - + ${hasNotifications ? html`
    ` : ""} `; @@ -98,8 +98,7 @@ class HaMenuButton extends LitElement { return; } - this.style.visibility = - newNarrow || this._alwaysVisible ? "initial" : "hidden"; + this.style.display = newNarrow || this._alwaysVisible ? "initial" : "none"; if (!newNarrow) { this._hasNotifications = false; diff --git a/src/components/ha-push-notifications-toggle.js b/src/components/ha-push-notifications-toggle.js index 61aec06691..b8bead07fc 100644 --- a/src/components/ha-push-notifications-toggle.js +++ b/src/components/ha-push-notifications-toggle.js @@ -3,6 +3,7 @@ import { html } from "@polymer/polymer/lib/utils/html-tag"; import { PolymerElement } from "@polymer/polymer/polymer-element"; import { getAppKey } from "../data/notify_html5"; import { EventsMixin } from "../mixins/events-mixin"; +import { showPromptDialog } from "../dialogs/generic/show-dialog-box"; import "./ha-switch"; export const pushSupported = @@ -88,7 +89,14 @@ class HaPushNotificationsToggle extends EventsMixin(PolymerElement) { browserName = "chrome"; } - const name = prompt("What should this device be called ?"); + const name = await showPromptDialog(this, { + title: this.hass.localize( + "ui.panel.profile.push_notifications.add_device_prompt.title" + ), + inputLabel: this.hass.localize( + "ui.panel.profile.push_notifications.add_device_prompt.input_label" + ), + }); if (name == null) { this.pushChecked = false; return; diff --git a/src/components/ha-sidebar.ts b/src/components/ha-sidebar.ts index 12ac252e73..717d376544 100644 --- a/src/components/ha-sidebar.ts +++ b/src/components/ha-sidebar.ts @@ -202,195 +202,17 @@ class HaSidebar extends LitElement { private _sortable?; protected render() { - const hass = this.hass; - - if (!hass) { + if (!this.hass) { return html``; } - const [beforeSpacer, afterSpacer] = computePanels( - hass.panels, - hass.defaultPanel, - this._panelOrder, - this._hiddenPanels - ); - - let notificationCount = this._notifications - ? this._notifications.length - : 0; - for (const entityId in hass.states) { - if (computeDomain(entityId) === "configurator") { - notificationCount++; - } - } - + // prettier-ignore return html` - - - ${this.editMode - ? html`
    - ${guard([this._hiddenPanels, this._renderEmptySortable], () => - this._renderEmptySortable - ? "" - : this._renderPanels(beforeSpacer) - )} -
    ` - : this._renderPanels(beforeSpacer)} -
    - ${this.editMode && this._hiddenPanels.length - ? html` - ${this._hiddenPanels.map((url) => { - const panel = this.hass.panels[url]; - if (!panel) { - return ""; - } - return html` - - ${panel.url_path === this.hass.defaultPanel - ? hass.localize("panel.states") - : hass.localize(`panel.${panel.title}`) || - panel.title} - - - - `; - })} -
    - ` - : ""} - ${this._renderPanels(afterSpacer)} - ${this._externalConfig && this._externalConfig.hasSettingsScreen - ? html` - - - - - ${hass.localize("ui.sidebar.external_app_configuration")} - - - - ` - : ""} -
    - -
    - -
    - - - ${!this.expanded && notificationCount > 0 - ? html` - - ${notificationCount} - - ` - : ""} - - ${hass.localize("ui.notification_drawer.title")} - - ${this.expanded && notificationCount > 0 - ? html` - ${notificationCount} - ` - : ""} - -
    - - - - - - - ${hass.user ? hass.user.name : ""} - - - + ${this._renderHeader()} + ${this._renderAllPanels()} + ${this._renderDivider()} + ${this._renderNotifications()} + ${this._renderUserItem()}
    `; @@ -475,6 +297,215 @@ class HaSidebar extends LitElement { } } + private _renderHeader() { + return html``; + } + + private _renderAllPanels() { + const [beforeSpacer, afterSpacer] = computePanels( + this.hass.panels, + this.hass.defaultPanel, + this._panelOrder, + this._hiddenPanels + ); + + // prettier-ignore + return html` + + ${this.editMode + ? this._renderPanelsEdit(beforeSpacer) + : this._renderPanels(beforeSpacer)} + ${this._renderSpacer()} + ${this._renderPanels(afterSpacer)} + ${this._renderExternalConfiguration()} + + `; + } + + private _renderPanelsEdit(beforeSpacer: PanelInfo[]) { + // prettier-ignore + return html`
    + ${guard([this._hiddenPanels, this._renderEmptySortable], () => + this._renderEmptySortable ? "" : this._renderPanels(beforeSpacer) + )} +
    + ${this._renderSpacer()} + ${this._renderHiddenPanels()} `; + } + + private _renderHiddenPanels() { + return html` ${this._hiddenPanels.length + ? html`${this._hiddenPanels.map((url) => { + const panel = this.hass.panels[url]; + if (!panel) { + return ""; + } + return html` + + ${panel.url_path === this.hass.defaultPanel + ? this.hass.localize("panel.states") + : this.hass.localize(`panel.${panel.title}`) || + panel.title} + + + + `; + })} + ${this._renderSpacer()}` + : ""}`; + } + + private _renderDivider() { + return html`
    `; + } + + private _renderSpacer() { + return html`
    `; + } + + private _renderNotifications() { + let notificationCount = this._notifications + ? this._notifications.length + : 0; + for (const entityId in this.hass.states) { + if (computeDomain(entityId) === "configurator") { + notificationCount++; + } + } + + return html`
    + + + ${!this.expanded && notificationCount > 0 + ? html` + + ${notificationCount} + + ` + : ""} + + ${this.hass.localize("ui.notification_drawer.title")} + + ${this.expanded && notificationCount > 0 + ? html` ${notificationCount} ` + : ""} + +
    `; + } + + private _renderUserItem() { + return html` + + + + + ${this.hass.user ? this.hass.user.name : ""} + + + `; + } + + private _renderExternalConfiguration() { + return html`${this._externalConfig && this._externalConfig.hasSettingsScreen + ? html` + + + + + ${this.hass.localize("ui.sidebar.external_app_configuration")} + + + + ` + : ""}`; + } + private get _tooltip() { return this.shadowRoot!.querySelector(".tooltip")! as HTMLDivElement; } @@ -728,6 +759,7 @@ class HaSidebar extends LitElement { width: 64px; } :host([expanded]) { + width: 256px; width: calc(256px + env(safe-area-inset-left)); } :host([rtl]) { @@ -735,8 +767,7 @@ class HaSidebar extends LitElement { border-left: 1px solid var(--divider-color); } .menu { - box-sizing: border-box; - height: 65px; + height: var(--header-height); display: flex; padding: 0 8.5px; border-bottom: 1px solid transparent; @@ -781,7 +812,7 @@ class HaSidebar extends LitElement { display: initial; } .title mwc-button { - width: 100%; + width: 90%; } #sortable, .hidden-panel { @@ -793,7 +824,10 @@ class HaSidebar extends LitElement { display: flex; flex-direction: column; box-sizing: border-box; - height: calc(100% - 196px - env(safe-area-inset-bottom)); + height: calc(100% - var(--header-height) - 132px); + height: calc( + 100% - var(--header-height) - 132px - env(safe-area-inset-bottom) + ); overflow-x: hidden; background: none; margin-left: env(safe-area-inset-left); diff --git a/src/components/ha-slider.js b/src/components/ha-slider.js index 9a2a9a09c9..f520fa20ef 100644 --- a/src/components/ha-slider.js +++ b/src/components/ha-slider.js @@ -21,6 +21,7 @@ class HaSlider extends PaperSliderClass { .pin > .slider-knob > .slider-knob-inner { font-size: var(--ha-slider-pin-font-size, 10px); line-height: normal; + cursor: pointer; } .disabled.ring > .slider-knob > .slider-knob-inner { @@ -69,9 +70,9 @@ class HaSlider extends PaperSliderClass { transform: scale(1) translate(0, -10px); } - .slider-input { - width: 54px; - } + .slider-input { + width: 54px; + } `) ); } diff --git a/src/components/ha-tabs.ts b/src/components/ha-tabs.ts new file mode 100644 index 0000000000..b6dc45dcf1 --- /dev/null +++ b/src/components/ha-tabs.ts @@ -0,0 +1,100 @@ +import "@polymer/paper-tabs/paper-tabs"; +import type { PaperIconButtonElement } from "@polymer/paper-icon-button/paper-icon-button"; +import type { PaperTabElement } from "@polymer/paper-tabs/paper-tab"; +import type { PaperTabsElement } from "@polymer/paper-tabs/paper-tabs"; +import { customElement } from "lit-element"; +import { Constructor } from "../types"; + +const PaperTabs = customElements.get("paper-tabs") as Constructor< + PaperTabsElement +>; + +let subTemplate: HTMLTemplateElement; + +@customElement("ha-tabs") +export class HaTabs extends PaperTabs { + private _firstTabWidth = 0; + + private _lastTabWidth = 0; + + private _lastLeftHiddenState = false; + + static get template(): HTMLTemplateElement { + if (!subTemplate) { + subTemplate = (PaperTabs as any).template.cloneNode(true); + + const superStyle = subTemplate.content.querySelector("style"); + + // Add "noink" attribute for scroll buttons to disable animation. + subTemplate.content + .querySelectorAll("paper-icon-button") + .forEach((arrow: PaperIconButtonElement) => { + arrow.setAttribute("noink", ""); + }); + + superStyle!.appendChild( + document.createTextNode(` + :host { + padding-top: .5px; + } + .not-visible { + display: none; + } + paper-icon-button { + width: 24px; + height: 48px; + padding: 0; + margin: 0; + } + `) + ); + } + return subTemplate; + } + + // Get first and last tab's width for _affectScroll + public _tabChanged(tab: PaperTabElement, old: PaperTabElement): void { + super._tabChanged(tab, old); + const tabs = this.querySelectorAll("paper-tab:not(.hide-tab)"); + if (tabs.length > 0) { + this._firstTabWidth = tabs[0].clientWidth; + this._lastTabWidth = tabs[tabs.length - 1].clientWidth; + } + + // Scroll active tab into view if needed. + const selected = this.querySelector(".iron-selected"); + if (selected) { + selected.scrollIntoView(); + } + } + + /** + * Modify _affectScroll so that when the scroll arrows appear + * while scrolling and the tab container shrinks we can counteract + * the jump in tab position so that the scroll still appears smooth. + */ + public _affectScroll(dx: number): void { + if (this._firstTabWidth === 0 || this._lastTabWidth === 0) { + return; + } + + this.$.tabsContainer.scrollLeft += dx; + + const scrollLeft = this.$.tabsContainer.scrollLeft; + + this._leftHidden = scrollLeft - this._firstTabWidth < 0; + this._rightHidden = + scrollLeft + this._lastTabWidth > this._tabContainerScrollSize; + + if (this._lastLeftHiddenState !== this._leftHidden) { + this._lastLeftHiddenState = this._leftHidden; + this.$.tabsContainer.scrollLeft += this._leftHidden ? -23 : 23; + } + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-tabs": HaTabs; + } +} diff --git a/src/components/ha-yaml-editor.ts b/src/components/ha-yaml-editor.ts index 596e878b51..4d2f4c2c59 100644 --- a/src/components/ha-yaml-editor.ts +++ b/src/components/ha-yaml-editor.ts @@ -20,7 +20,7 @@ declare global { } } -const isEmpty = (obj: object): boolean => { +const isEmpty = (obj: Record): boolean => { if (typeof obj !== "object") { return false; } @@ -44,7 +44,7 @@ export class HaYamlEditor extends LitElement { @internalProperty() private _yaml = ""; - @query("ha-code-editor") private _editor?: HaCodeEditor; + @query("ha-code-editor", true) private _editor?: HaCodeEditor; public setValue(value): void { try { @@ -105,6 +105,10 @@ export class HaYamlEditor extends LitElement { fireEvent(this, "value-changed", { value: parsed, isValid } as any); } + + get yaml() { + return this._editor?.value; + } } declare global { diff --git a/src/components/map/ha-location-editor.ts b/src/components/map/ha-location-editor.ts index 3b42fd38cc..8ba1ec3c2b 100644 --- a/src/components/map/ha-location-editor.ts +++ b/src/components/map/ha-location-editor.ts @@ -61,8 +61,8 @@ class LocationEditor extends LitElement { if (!this._leafletMap || !this.location) { return; } - if ((this._locationMarker as Circle).getBounds) { - this._leafletMap.fitBounds((this._locationMarker as Circle).getBounds()); + if (this._locationMarker && "getBounds" in this._locationMarker) { + this._leafletMap.fitBounds(this._locationMarker.getBounds()); } else { this._leafletMap.setView(this.location, this.fitZoom); } diff --git a/src/components/map/ha-locations-editor.ts b/src/components/map/ha-locations-editor.ts index 06547974fe..a12021c11f 100644 --- a/src/components/map/ha-locations-editor.ts +++ b/src/components/map/ha-locations-editor.ts @@ -90,8 +90,8 @@ export class HaLocationsEditor extends LitElement { if (!marker) { return; } - if ((marker as Circle).getBounds) { - this._leafletMap.fitBounds((marker as Circle).getBounds()); + if ("getBounds" in marker) { + this._leafletMap.fitBounds(marker.getBounds()); (marker as Circle).bringToFront(); } else { const circle = this._circles[id]; @@ -296,8 +296,8 @@ export class HaLocationsEditor extends LitElement { // @ts-ignore (ev: MouseEvent) => this._markerClicked(ev) ) - .addTo(this._leafletMap); - marker.id = location.id; + .addTo(this._leafletMap!); + (marker as any).id = location.id; this._locationMarkers![location.id] = marker; } diff --git a/src/components/media-player/ha-media-player-browse.ts b/src/components/media-player/ha-media-player-browse.ts index 32146ec822..93a5ca25e4 100644 --- a/src/components/media-player/ha-media-player-browse.ts +++ b/src/components/media-player/ha-media-player-browse.ts @@ -378,6 +378,7 @@ export class HaMediaPlayerBrowse extends LitElement { : html`
    ${this.hass.localize("ui.components.media-browser.no_items")} +
    ${currentItem.media_content_id === "media-source://media_source/local/." ? html`
    ${this.hass.localize( @@ -398,7 +399,7 @@ export class HaMediaPlayerBrowse extends LitElement {
    ${this.hass.localize( "ui.components.media-browser.local_media_files" - )}.` + )}` : ""}
    `} @@ -539,17 +540,20 @@ export class HaMediaPlayerBrowse extends LitElement { mediaContentType?: string ): Promise { this._loading = true; - const itemData = - this.entityId !== BROWSER_PLAYER - ? await browseMediaPlayer( - this.hass, - this.entityId, - mediaContentId, - mediaContentType - ) - : await browseLocalMediaPlayer(this.hass, mediaContentId); - - this._loading = false; + 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; } diff --git a/src/components/state-history-chart-timeline.js b/src/components/state-history-chart-timeline.js index 3bbd6054ce..50c234f35f 100644 --- a/src/components/state-history-chart-timeline.js +++ b/src/components/state-history-chart-timeline.js @@ -159,7 +159,7 @@ class StateHistoryChartTimeline extends LocalizeMixin(PolymerElement) { if (prevState !== null) { dataRow.push([prevLastChanged, endTime, locState, prevState]); } - datasets.push({ data: dataRow }); + datasets.push({ data: dataRow, entity_id: stateInfo.entity_id }); labels.push(entityDisplay); }); @@ -173,12 +173,22 @@ class StateHistoryChartTimeline extends LocalizeMixin(PolymerElement) { return [state, start, end]; }; + const formatTooltipBeforeBody = (item, data) => { + if (!this.hass.userData || !this.hass.userData.showAdvanced || !item[0]) { + return ""; + } + // Extract the entity ID from the dataset. + const values = data.datasets[item[0].datasetIndex]; + return values.entity_id || ""; + }; + const chartOptions = { type: "timeline", options: { tooltips: { callbacks: { label: formatTooltipLabel, + beforeBody: formatTooltipBeforeBody, }, }, scales: { diff --git a/src/components/user/ha-user-picker.ts b/src/components/user/ha-user-picker.ts index ecd99f188f..6b3e865c05 100644 --- a/src/components/user/ha-user-picker.ts +++ b/src/components/user/ha-user-picker.ts @@ -24,10 +24,14 @@ class HaUserPicker extends LitElement { @property() public label?: string; - @property() public value?: string; + @property() public noUserLabel?: string; + + @property() public value = ""; @property() public users?: User[]; + @property({ type: Boolean }) public disabled = false; + private _sortedUsers = memoizeOne((users?: User[]) => { if (!users) { return []; @@ -40,15 +44,19 @@ class HaUserPicker extends LitElement { protected render(): TemplateResult { return html` - + - No user + ${this.noUserLabel || + this.hass?.localize("ui.components.user-picker.no_user")} ${this._sortedUsers(this.users).map( (user) => html` @@ -67,10 +75,6 @@ class HaUserPicker extends LitElement { `; } - private get _value() { - return this.value || ""; - } - protected firstUpdated(changedProps) { super.firstUpdated(changedProps); if (this.users === undefined) { @@ -83,7 +87,7 @@ class HaUserPicker extends LitElement { private _userChanged(ev) { const newValue = ev.detail.item.dataset.userId; - if (newValue !== this._value) { + if (newValue !== this.value) { this.value = ev.detail.value; setTimeout(() => { fireEvent(this, "value-changed", { value: newValue }); @@ -111,3 +115,9 @@ class HaUserPicker extends LitElement { } customElements.define("ha-user-picker", HaUserPicker); + +declare global { + interface HTMLElementTagNameMap { + "ha-user-picker": HaUserPicker; + } +} diff --git a/src/components/user/ha-users-picker.ts b/src/components/user/ha-users-picker.ts new file mode 100644 index 0000000000..d4fac81d48 --- /dev/null +++ b/src/components/user/ha-users-picker.ts @@ -0,0 +1,169 @@ +import { + css, + CSSResult, + customElement, + html, + LitElement, + property, + TemplateResult, +} from "lit-element"; +import { fireEvent } from "../../common/dom/fire_event"; +import type { PolymerChangedEvent } from "../../polymer-types"; +import type { HomeAssistant } from "../../types"; +import { fetchUsers, User } from "../../data/user"; +import "./ha-user-picker"; +import { mdiClose } from "@mdi/js"; +import memoizeOne from "memoize-one"; +import { guard } from "lit-html/directives/guard"; + +@customElement("ha-users-picker") +class HaUsersPickerLight extends LitElement { + @property({ attribute: false }) public hass?: HomeAssistant; + + @property() public value?: string[]; + + @property({ attribute: "picked-user-label" }) + public pickedUserLabel?: string; + + @property({ attribute: "pick-user-label" }) + public pickUserLabel?: string; + + @property({ attribute: false }) + public users?: User[]; + + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + if (this.users === undefined) { + fetchUsers(this.hass!).then((users) => { + this.users = users; + }); + } + } + + protected render(): TemplateResult { + if (!this.hass || !this.users) { + return html``; + } + + const notSelectedUsers = this._notSelectedUsers(this.users, this.value); + return html` + ${guard([notSelectedUsers], () => + this.value?.map( + (user_id, idx) => html` +
    + + + + +
    + ` + ) + )} + + `; + } + + private _notSelectedUsers = memoizeOne( + (users?: User[], currentUsers?: string[]) => + currentUsers + ? users?.filter( + (user) => !user.system_generated && !currentUsers.includes(user.id) + ) + : users?.filter((user) => !user.system_generated) + ); + + private _notSelectedUsersAndSelected = ( + userId: string, + users?: User[], + notSelected?: User[] + ) => { + const selectedUser = users?.find((user) => user.id === userId); + if (selectedUser) { + return notSelected ? [...notSelected, selectedUser] : [selectedUser]; + } + return notSelected; + }; + + private get _currentUsers() { + return this.value || []; + } + + private async _updateUsers(users) { + this.value = users; + fireEvent(this, "value-changed", { + value: users, + }); + } + + private _userChanged(event: PolymerChangedEvent) { + event.stopPropagation(); + const index = (event.currentTarget as any).index; + const newValue = event.detail.value; + const newUsers = [...this._currentUsers]; + if (newValue === "") { + newUsers.splice(index, 1); + } else { + newUsers.splice(index, 1, newValue); + } + this._updateUsers(newUsers); + } + + private async _addUser(event: PolymerChangedEvent) { + event.stopPropagation(); + const toAdd = event.detail.value; + (event.currentTarget as any).value = ""; + if (!toAdd) { + return; + } + const currentUsers = this._currentUsers; + if (currentUsers.includes(toAdd)) { + return; + } + + this._updateUsers([...currentUsers, toAdd]); + } + + private _removeUser(event) { + const userId = (event.currentTarget as any).userId; + this._updateUsers(this._currentUsers.filter((user) => user !== userId)); + } + + static get styles(): CSSResult { + return css` + :host { + display: block; + } + div { + display: flex; + align-items: center; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-users-picker": HaUsersPickerLight; + } +} diff --git a/src/data/automation.ts b/src/data/automation.ts index 4b06d4af63..bb35448575 100644 --- a/src/data/automation.ts +++ b/src/data/automation.ts @@ -109,10 +109,17 @@ export interface TemplateTrigger { value_template: string; } +export interface ContextConstraint { + context_id?: string; + parent_id?: string; + user_id?: string | string[]; +} + export interface EventTrigger { platform: "event"; event_type: string; - event_data: any; + event_data?: any; + context?: ContextConstraint; } export type Trigger = @@ -217,12 +224,12 @@ export const subscribeTrigger = ( hass: HomeAssistant, onChange: (result: { variables: { - trigger: {}; + trigger: Record; }; context: Context; }) => void, trigger: Trigger | Trigger[], - variables?: {} + variables?: Record ) => hass.connection.subscribeMessage(onChange, { type: "subscribe_trigger", @@ -233,7 +240,7 @@ export const subscribeTrigger = ( export const testCondition = ( hass: HomeAssistant, condition: Condition | Condition[], - variables?: {} + variables?: Record ) => hass.callWS<{ result: boolean }>({ type: "test_condition", diff --git a/src/data/cached-history.ts b/src/data/cached-history.ts index f1fee4ec98..6c97c20baa 100644 --- a/src/data/cached-history.ts +++ b/src/data/cached-history.ts @@ -10,7 +10,6 @@ import { } from "./history"; export interface CacheConfig { - refresh: number; cacheKey: string; hoursToShow: number; } diff --git a/src/data/collection.ts b/src/data/collection.ts index a565d371f0..43931acd31 100644 --- a/src/data/collection.ts +++ b/src/data/collection.ts @@ -17,12 +17,12 @@ interface OptimisticCollection extends Collection { */ export const getOptimisticCollection = ( - saveCollection: (conn: Connection, data: StateType) => Promise, + saveCollection: (conn2: Connection, data: StateType) => Promise, conn: Connection, key: string, - fetchCollection: (conn: Connection) => Promise, + fetchCollection: (conn2: Connection) => Promise, subscribeUpdates?: ( - conn: Connection, + conn2: Connection, store: Store ) => Promise ): OptimisticCollection => { diff --git a/src/data/config_flow.ts b/src/data/config_flow.ts index 94cce83ba8..367924e015 100644 --- a/src/data/config_flow.ts +++ b/src/data/config_flow.ts @@ -11,6 +11,7 @@ export const DISCOVERY_SOURCES = [ "ssdp", "zeroconf", "discovery", + "mqtt", ]; export const ATTENTION_SOURCES = ["reauth"]; diff --git a/src/data/counter.ts b/src/data/counter.ts new file mode 100644 index 0000000000..b01bf65869 --- /dev/null +++ b/src/data/counter.ts @@ -0,0 +1,51 @@ +import { HomeAssistant } from "../types"; + +export interface Counter { + id: string; + name: string; + icon?: string; + initial?: number; + restore?: boolean; + minimum?: number; + maximum?: number; + step?: number; +} + +export interface CounterMutableParams { + name: string; + icon: string; + initial: number; + restore: boolean; + minimum: number; + maximum: number; + step: number; +} + +export const fetchCounter = (hass: HomeAssistant) => + hass.callWS({ type: "counter/list" }); + +export const createCounter = ( + hass: HomeAssistant, + values: CounterMutableParams +) => + hass.callWS({ + type: "counter/create", + ...values, + }); + +export const updateCounter = ( + hass: HomeAssistant, + id: string, + updates: Partial +) => + hass.callWS({ + type: "counter/update", + counter_id: id, + ...updates, + }); + +export const deleteCounter = (hass: HomeAssistant, id: string) => + hass.callWS({ + type: "counter/delete", + counter_id: id, + }); diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts index a5d897ca8b..4c5271e82d 100644 --- a/src/data/entity_registry.ts +++ b/src/data/entity_registry.ts @@ -15,7 +15,7 @@ export interface EntityRegistryEntry { export interface ExtEntityRegistryEntry extends EntityRegistryEntry { unique_id: string; - capabilities: object; + capabilities: Record; original_name?: string; original_icon?: string; } diff --git a/src/data/hassio/addon.ts b/src/data/hassio/addon.ts index d500ad4311..902548f174 100644 --- a/src/data/hassio/addon.ts +++ b/src/data/hassio/addon.ts @@ -2,78 +2,71 @@ import { HomeAssistant } from "../../types"; import { hassioApiResultExtractor, HassioResponse } from "./common"; export interface HassioAddonInfo { - name: string; - slug: string; - description: string; - repository: "core" | "local" | string; - version: string; - state: "none" | "started" | "stopped"; - installed: string | undefined; - detached: boolean; + advanced: boolean; available: boolean; build: boolean; - advanced: boolean; - url: string | null; + description: string; + detached: boolean; icon: boolean; + installed: boolean; logo: boolean; + name: string; + repository: "core" | "local" | string; + slug: string; + stage: "stable" | "experimental" | "deprecated"; + state: "started" | "stopped" | null; + update_available: boolean; + url: string | null; + version_latest: string; + version: string; } export interface HassioAddonDetails extends HassioAddonInfo { - name: string; - slug: string; - description: string; - long_description: null | string; - auto_update: boolean; - url: null | string; - detached: boolean; - documentation: boolean; - available: boolean; - arch: "armhf" | "aarch64" | "i386" | "amd64"; - machine: any; - homeassistant: string; - version_latest: string; - boot: "auto" | "manual"; - build: boolean; - options: object; - network: null | object; - network_description: null | object; - host_network: boolean; - host_pid: boolean; - host_ipc: boolean; - host_dbus: boolean; - privileged: any; apparmor: "disable" | "default" | "profile"; - devices: string[]; - auto_uart: boolean; - icon: boolean; - logo: boolean; - stage: "stable" | "experimental" | "deprecated"; - changelog: boolean; - hassio_api: boolean; - hassio_role: "default" | "homeassistant" | "manager" | "admin"; - startup: "initialize" | "system" | "services" | "application" | "once"; - homeassistant_api: boolean; - auth_api: boolean; - full_access: boolean; - protected: boolean; - rating: "1-6"; - stdin: boolean; - webui: null | string; - gpio: boolean; - kernel_modules: boolean; - devicetree: boolean; - docker_api: boolean; - audio: boolean; + arch: "armhf" | "aarch64" | "i386" | "amd64"; audio_input: null | string; audio_output: null | string; - services_role: string[]; + audio: boolean; + auth_api: boolean; + auto_uart: boolean; + auto_update: boolean; + boot: "auto" | "manual"; + changelog: boolean; + devices: string[]; + devicetree: boolean; discovery: string[]; - ip_address: string; - ingress: boolean; - ingress_panel: boolean; + docker_api: boolean; + documentation: boolean; + full_access: boolean; + gpio: boolean; + hassio_api: boolean; + hassio_role: "default" | "homeassistant" | "manager" | "admin"; + homeassistant_api: boolean; + homeassistant: string; + host_dbus: boolean; + host_ipc: boolean; + host_network: boolean; + host_pid: boolean; ingress_entry: null | string; + ingress_panel: boolean; ingress_url: null | string; + ingress: boolean; + ip_address: string; + kernel_modules: boolean; + long_description: null | string; + machine: any; + network_description: null | Record; + network: null | Record; + options: Record; + privileged: any; + protected: boolean; + rating: "1-6"; + services_role: string[]; + slug: string; + startup: "initialize" | "system" | "services" | "application" | "once"; + stdin: boolean; watchdog: null | boolean; + webui: null | string; } export interface HassioAddonsInfo { @@ -96,11 +89,11 @@ export interface HassioAddonRepository { export interface HassioAddonSetOptionParams { audio_input?: string | null; audio_output?: string | null; - options?: object | null; + options?: Record | null; boot?: "auto" | "manual"; auto_update?: boolean; ingress_panel?: boolean; - network?: object | null; + network?: Record | null; watchdog?: boolean; } diff --git a/src/data/hassio/common.ts b/src/data/hassio/common.ts index 103f61c35a..57a0afa59a 100644 --- a/src/data/hassio/common.ts +++ b/src/data/hassio/common.ts @@ -22,8 +22,8 @@ export const hassioApiResultExtractor = (response: HassioResponse) => export const extractApiErrorMessage = (error: any): string => { return typeof error === "object" ? typeof error.body === "object" - ? error.body.message || "Unknown error, see logs" - : error.body || "Unknown error, see logs" + ? error.body.message || "Unknown error, see supervisor logs" + : error.body || error.message || "Unknown error, see supervisor logs" : error; }; diff --git a/src/data/hassio/docker.ts b/src/data/hassio/docker.ts new file mode 100644 index 0000000000..4bc9a194c5 --- /dev/null +++ b/src/data/hassio/docker.ts @@ -0,0 +1,36 @@ +import { HomeAssistant } from "../../types"; +import { hassioApiResultExtractor, HassioResponse } from "./common"; + +interface HassioDockerRegistries { + [key: string]: { username: string; password?: string }; +} + +export const fetchHassioDockerRegistries = async (hass: HomeAssistant) => { + return hassioApiResultExtractor( + await hass.callApi>( + "GET", + "hassio/docker/registries" + ) + ); +}; + +export const addHassioDockerRegistry = async ( + hass: HomeAssistant, + data: HassioDockerRegistries +) => { + await hass.callApi>( + "POST", + "hassio/docker/registries", + data + ); +}; + +export const removeHassioDockerRegistry = async ( + hass: HomeAssistant, + registry: string +) => { + await hass.callApi>( + "DELETE", + `hassio/docker/registries/${registry}` + ); +}; diff --git a/src/data/hassio/hardware.ts b/src/data/hassio/hardware.ts index 345bf0d1ba..863a16d70d 100644 --- a/src/data/hassio/hardware.ts +++ b/src/data/hassio/hardware.ts @@ -18,7 +18,7 @@ export interface HassioHardwareInfo { input: string[]; disk: string[]; gpio: string[]; - audio: object; + audio: Record; } export const fetchHassioHardwareAudio = async (hass: HomeAssistant) => { diff --git a/src/data/hassio/host.ts b/src/data/hassio/host.ts index a8db6dbc71..ebdc606832 100644 --- a/src/data/hassio/host.ts +++ b/src/data/hassio/host.ts @@ -1,14 +1,25 @@ import { HomeAssistant } from "../../types"; import { hassioApiResultExtractor, HassioResponse } from "./common"; -export type HassioHostInfo = any; +export type HassioHostInfo = { + chassis: string; + cpe: string; + deployment: string; + disk_free: number; + disk_total: number; + disk_used: number; + features: string[]; + hostname: string; + kernel: string; + operating_system: string; +}; export interface HassioHassOSInfo { - version: string; - version_cli: string; + board: string; + boot: string; + update_available: boolean; version_latest: string; - version_cli_latest: string; - board: "ova" | "rpi"; + version: string; } export const fetchHassioHostInfo = async (hass: HomeAssistant) => { diff --git a/src/data/hassio/resolution.ts b/src/data/hassio/resolution.ts new file mode 100644 index 0000000000..8f85943de3 --- /dev/null +++ b/src/data/hassio/resolution.ts @@ -0,0 +1,15 @@ +import { HomeAssistant } from "../../types"; +import { hassioApiResultExtractor, HassioResponse } from "./common"; + +export interface HassioResolution { + unsupported: string[]; +} + +export const fetchHassioResolution = async (hass: HomeAssistant) => { + return hassioApiResultExtractor( + await hass.callApi>( + "GET", + "hassio/resolution/info" + ) + ); +}; diff --git a/src/data/hassio/supervisor.ts b/src/data/hassio/supervisor.ts index 992425b8bc..9d2845f776 100644 --- a/src/data/hassio/supervisor.ts +++ b/src/data/hassio/supervisor.ts @@ -1,19 +1,56 @@ import { HomeAssistant, PanelInfo } from "../../types"; +import { HassioAddonInfo, HassioAddonRepository } from "./addon"; import { hassioApiResultExtractor, HassioResponse } from "./common"; -export type HassioHomeAssistantInfo = any; -export type HassioSupervisorInfo = any; +export type HassioHomeAssistantInfo = { + arch: string; + audio_input: string | null; + audio_output: string | null; + boot: boolean; + image: string; + ip_address: string; + machine: string; + port: number; + ssl: boolean; + update_available: boolean; + version_latest: string; + version: string; + wait_boot: number; + watchdog: boolean; +}; + +export type HassioSupervisorInfo = { + addons: HassioAddonInfo[]; + addons_repositories: HassioAddonRepository[]; + arch: string; + channel: string; + debug: boolean; + debug_block: boolean; + diagnostics: boolean | null; + healthy: boolean; + ip_address: string; + logging: string; + supported: boolean; + timezone: string; + update_available: boolean; + version: string; + version_latest: string; + wait_boot: number; +}; export type HassioInfo = { arch: string; channel: string; docker: string; - hassos?: string; + features: string[]; + hassos: null; homeassistant: string; hostname: string; logging: string; - maching: string; + machine: string; + operating_system: string; supervisor: string; + supported: boolean; supported_arch: string[]; timezone: string; }; diff --git a/src/data/history.ts b/src/data/history.ts index c5df270404..16bb07c782 100644 --- a/src/data/history.ts +++ b/src/data/history.ts @@ -85,11 +85,14 @@ export const fetchRecent = ( export const fetchDate = ( hass: HomeAssistant, startTime: Date, - endTime: Date + endTime: Date, + entityId ): Promise => { return hass.callApi( "GET", - `history/period/${startTime.toISOString()}?end_time=${endTime.toISOString()}&minimal_response` + `history/period/${startTime.toISOString()}?end_time=${endTime.toISOString()}&minimal_response${ + entityId ? `&filter_entity_id=${entityId}` : `` + }` ); }; diff --git a/src/data/logbook.ts b/src/data/logbook.ts index cb93ee4003..d5a916c400 100644 --- a/src/data/logbook.ts +++ b/src/data/logbook.ts @@ -6,12 +6,14 @@ import { HomeAssistant } from "../types"; import { UNAVAILABLE_STATES } from "./entity"; const LOGBOOK_LOCALIZE_PATH = "ui.components.logbook.messages"; +export const CONTINUOUS_DOMAINS = ["proximity", "sensor"]; export interface LogbookEntry { when: string; name: string; message?: string; entity_id?: string; + icon?: string; domain: string; context_user_id?: string; context_event_type?: string; @@ -43,11 +45,12 @@ export const getLogbookData = async ( ); for (const entry of logbookData) { - if (entry.state) { + const stateObj = hass!.states[entry.entity_id!]; + if (entry.state && stateObj) { entry.message = getLogbookMessage( hass, entry.state, - hass!.states[entry.entity_id!], + stateObj, computeDomain(entry.entity_id!) ); } diff --git a/src/data/lovelace.ts b/src/data/lovelace.ts index d0a19bf20c..090bb0dfc0 100644 --- a/src/data/lovelace.ts +++ b/src/data/lovelace.ts @@ -33,7 +33,7 @@ export interface LovelaceResource { } export interface LovelaceResourcesMutableParams { - res_type: "css" | "js" | "module" | "html"; + res_type: LovelaceResource["type"]; url: string; } @@ -89,6 +89,7 @@ export interface LovelaceViewConfig { export interface LovelaceViewElement extends HTMLElement { hass?: HomeAssistant; lovelace?: Lovelace; + narrow?: boolean; index?: number; cards?: Array; badges?: LovelaceBadge[]; diff --git a/src/data/ozw.ts b/src/data/ozw.ts index 80ad9882b7..0e5d73565a 100644 --- a/src/data/ozw.ts +++ b/src/data/ozw.ts @@ -63,6 +63,16 @@ export interface OZWNetworkStatistics { retries: number; } +export interface OZWDeviceConfig { + label: string; + type: string; + value: string | number; + parameter: number; + min: number; + max: number; + help: string; +} + export const nodeQueryStages = [ "ProtocolInfo", "Probe", @@ -180,6 +190,17 @@ export const fetchOZWNodeMetadata = ( node_id: node_id, }); +export const fetchOZWNodeConfig = ( + hass: HomeAssistant, + ozw_instance: number, + node_id: number +): Promise => + hass.callWS({ + type: "ozw/get_config_parameters", + ozw_instance: ozw_instance, + node_id: node_id, + }); + export const refreshNodeInfo = ( hass: HomeAssistant, ozw_instance: number, diff --git a/src/data/panel_custom.ts b/src/data/panel_custom.ts index ce09a24408..0b6472c6d1 100644 --- a/src/data/panel_custom.ts +++ b/src/data/panel_custom.ts @@ -9,6 +9,6 @@ export interface CustomPanelConfig { html_url?: string; } -export type CustomPanelInfo = PanelInfo< +export type CustomPanelInfo> = PanelInfo< T & { _panel_custom: CustomPanelConfig } >; diff --git a/src/data/script.ts b/src/data/script.ts index 240472f743..3becd56e0a 100644 --- a/src/data/script.ts +++ b/src/data/script.ts @@ -105,7 +105,7 @@ export type Action = export const triggerScript = ( hass: HomeAssistant, entityId: string, - variables?: {} + variables?: Record ) => hass.callService("script", computeObjectId(entityId), variables); export const canExcecute = (state: ScriptEntity) => { diff --git a/src/data/tasmota.ts b/src/data/tasmota.ts new file mode 100644 index 0000000000..df9aad3032 --- /dev/null +++ b/src/data/tasmota.ts @@ -0,0 +1,10 @@ +import { HomeAssistant } from "../types"; + +export const removeTasmotaDeviceEntry = ( + hass: HomeAssistant, + deviceId: string +): Promise => + hass.callWS({ + type: "tasmota/device/remove", + device_id: deviceId, + }); diff --git a/src/data/timer.ts b/src/data/timer.ts index 8b54020a7d..a963d64ea3 100644 --- a/src/data/timer.ts +++ b/src/data/timer.ts @@ -2,6 +2,7 @@ import { HassEntityAttributeBase, HassEntityBase, } from "home-assistant-js-websocket"; +import { HomeAssistant } from "../types"; export type TimerEntity = HassEntityBase & { attributes: HassEntityAttributeBase & { @@ -9,3 +10,48 @@ export type TimerEntity = HassEntityBase & { remaining: string; }; }; + +export interface DurationDict { + hours?: number | string; + minutes?: number | string; + seconds?: number | string; +} + +export interface Timer { + id: string; + name: string; + icon?: string; + duration?: string | number | DurationDict; +} + +export interface TimerMutableParams { + name: string; + icon: string; + duration: string | number | DurationDict; +} + +export const fetchTimer = (hass: HomeAssistant) => + hass.callWS({ type: "timer/list" }); + +export const createTimer = (hass: HomeAssistant, values: TimerMutableParams) => + hass.callWS({ + type: "timer/create", + ...values, + }); + +export const updateTimer = ( + hass: HomeAssistant, + id: string, + updates: Partial +) => + hass.callWS({ + type: "timer/update", + timer_id: id, + ...updates, + }); + +export const deleteTimer = (hass: HomeAssistant, id: string) => + hass.callWS({ + type: "timer/delete", + timer_id: id, + }); diff --git a/src/data/translation.ts b/src/data/translation.ts index a713ccd256..8a11515458 100644 --- a/src/data/translation.ts +++ b/src/data/translation.ts @@ -33,8 +33,8 @@ export const getHassTranslations = async ( category: TranslationCategory, integration?: string, config_flow?: boolean -): Promise<{}> => { - const result = await hass.callWS<{ resources: {} }>({ +): Promise> => { + const result = await hass.callWS<{ resources: Record }>({ type: "frontend/get_translations", language, category, @@ -47,8 +47,8 @@ export const getHassTranslations = async ( export const getHassTranslationsPre109 = async ( hass: HomeAssistant, language: string -): Promise<{}> => { - const result = await hass.callWS<{ resources: {} }>({ +): Promise> => { + const result = await hass.callWS<{ resources: Record }>({ type: "frontend/get_translations", language, }); diff --git a/src/data/weather.ts b/src/data/weather.ts index 8c0f7cb5f7..16333d2d1e 100644 --- a/src/data/weather.ts +++ b/src/data/weather.ts @@ -1,6 +1,14 @@ -import { SVGTemplateResult, svg, html, TemplateResult, css } from "lit-element"; +import { + mdiGauge, + mdiWaterPercent, + mdiWeatherFog, + mdiWeatherRainy, + mdiWeatherWindy, +} from "@mdi/js"; +import { css, html, svg, SVGTemplateResult, TemplateResult } from "lit-element"; import { styleMap } from "lit-html/directives/style-map"; - +import "../components/ha-icon"; +import "../components/ha-svg-icon"; import type { HomeAssistant, WeatherEntity } from "../types"; import { roundWithOneDecimal } from "../util/calculate"; @@ -25,6 +33,15 @@ export const weatherIcons = { exceptional: "hass:alert-circle-outline", }; +export const weatherAttrIcons = { + humidity: mdiWaterPercent, + wind_bearing: mdiWeatherWindy, + wind_speed: mdiWeatherWindy, + pressure: mdiGauge, + visibility: mdiWeatherFog, + precipitation: mdiWeatherRainy, +}; + const cloudyStates = new Set([ "partlycloudy", "cloudy", @@ -48,7 +65,7 @@ const snowyStates = new Set(["snowy", "snowy-rainy"]); const lightningStates = new Set(["lightning", "lightning-rainy"]); -export const cardinalDirections = [ +const cardinalDirections = [ "N", "NNE", "NE", @@ -77,13 +94,29 @@ const getWindBearingText = (degree: string): string => { return degree; }; -export const getWindBearing = (bearing: string): string => { +const getWindBearing = (bearing: string): string => { if (bearing != null) { return getWindBearingText(bearing); } return ""; }; +export const getWind = ( + hass: HomeAssistant, + speed: string, + bearing: string +): string => { + if (bearing !== null) { + const cardinalDirection = getWindBearing(bearing); + return `${speed} ${getWeatherUnit(hass!, "wind_speed")} (${ + hass.localize( + `ui.card.weather.cardinal_direction.${cardinalDirection.toLowerCase()}` + ) || cardinalDirection + })`; + } + return `${speed} ${getWeatherUnit(hass!, "wind_speed")}`; +}; + export const getWeatherUnit = ( hass: HomeAssistant, measure: string @@ -94,6 +127,7 @@ export const getWeatherUnit = ( return lengthUnit === "km" ? "hPa" : "inHg"; case "wind_speed": return `${lengthUnit}/h`; + case "visibility": case "length": return lengthUnit; case "precipitation": @@ -109,7 +143,7 @@ export const getWeatherUnit = ( export const getSecondaryWeatherAttribute = ( hass: HomeAssistant, stateObj: WeatherEntity -): string | undefined => { +): TemplateResult | undefined => { const extrema = getWeatherExtrema(hass, stateObj); if (extrema) { @@ -133,17 +167,22 @@ export const getSecondaryWeatherAttribute = ( return undefined; } - return ` - ${hass!.localize( - `ui.card.weather.attributes.${attribute}` - )} ${roundWithOneDecimal(value)} ${getWeatherUnit(hass!, attribute)} + const weatherAttrIcon = weatherAttrIcons[attribute]; + + return html` + ${weatherAttrIcon + ? html` + + ` + : hass!.localize(`ui.card.weather.attributes.${attribute}`)} + ${roundWithOneDecimal(value)} ${getWeatherUnit(hass!, attribute)} `; }; const getWeatherExtrema = ( hass: HomeAssistant, stateObj: WeatherEntity -): string | undefined => { +): TemplateResult | undefined => { if (!stateObj.attributes.forecast?.length) { return undefined; } @@ -173,22 +212,18 @@ const getWeatherExtrema = ( const unit = getWeatherUnit(hass!, "temperature"); - return ` - ${ - tempHigh - ? ` + return html` + ${tempHigh + ? ` ${tempHigh} ${unit} ` - : "" - } + : ""} ${tempLow && tempHigh ? " / " : ""} - ${ - tempLow - ? ` + ${tempLow + ? ` ${tempLow} ${unit} ` - : "" - } + : ""} `; }; @@ -210,7 +245,7 @@ export const weatherSVGStyles = css` } `; -export const getWeatherStateSVG = ( +const getWeatherStateSVG = ( state: string, nightTime?: boolean ): SVGTemplateResult => { diff --git a/src/data/ws-templates.ts b/src/data/ws-templates.ts index 0cac4d4606..6d65e6ccbb 100644 --- a/src/data/ws-templates.ts +++ b/src/data/ws-templates.ts @@ -9,6 +9,7 @@ interface TemplateListeners { all: boolean; domains: string[]; entities: string[]; + time: boolean; } export const subscribeRenderTemplate = ( @@ -17,7 +18,7 @@ export const subscribeRenderTemplate = ( params: { template: string; entity_ids?: string | string[]; - variables?: object; + variables?: Record; timeout?: number; } ): Promise => { diff --git a/src/dialogs/config-entry-system-options/dialog-config-entry-system-options.ts b/src/dialogs/config-entry-system-options/dialog-config-entry-system-options.ts index d7a2ad6fbc..54ab1c7f41 100644 --- a/src/dialogs/config-entry-system-options/dialog-config-entry-system-options.ts +++ b/src/dialogs/config-entry-system-options/dialog-config-entry-system-options.ts @@ -1,5 +1,4 @@ -import "@polymer/paper-dialog-scrollable/paper-dialog-scrollable"; -import "@polymer/paper-input/paper-input"; +import "@material/mwc-button/mwc-button"; import { css, CSSResult, @@ -10,16 +9,16 @@ import { internalProperty, TemplateResult, } from "lit-element"; -import "../../components/dialog/ha-paper-dialog"; +import "../../components/ha-dialog"; import "../../components/ha-circular-progress"; import "../../components/ha-switch"; import "../../components/ha-formfield"; +import { fireEvent } from "../../common/dom/fire_event"; import type { HaSwitch } from "../../components/ha-switch"; import { getConfigEntrySystemOptions, updateConfigEntrySystemOptions, } from "../../data/config_entries"; -import type { PolymerChangedEvent } from "../../polymer-types"; import { haStyleDialog } from "../../resources/styles"; import type { HomeAssistant } from "../../types"; import { ConfigEntrySystemOptionsDialogParams } from "./show-dialog-config-entry-system-options"; @@ -35,9 +34,9 @@ class DialogConfigEntrySystemOptions extends LitElement { @internalProperty() private _params?: ConfigEntrySystemOptionsDialogParams; - @internalProperty() private _loading?: boolean; + @internalProperty() private _loading = false; - @internalProperty() private _submitting?: boolean; + @internalProperty() private _submitting = false; public async showDialog( params: ConfigEntrySystemOptionsDialogParams @@ -51,7 +50,12 @@ class DialogConfigEntrySystemOptions extends LitElement { ); this._loading = false; this._disableNewEntities = systemOptions.disable_new_entities; - await this.updateComplete; + } + + public closeDialog(): void { + this._error = ""; + this._params = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); } protected render(): TemplateResult { @@ -60,21 +64,17 @@ class DialogConfigEntrySystemOptions extends LitElement { } return html` - -

    - ${this.hass.localize( - "ui.dialogs.config_entry_system_options.title", - "integration", - this.hass.localize( - `component.${this._params.entry.domain}.title` - ) || this._params.entry.domain - )} -

    - +
    ${this._loading ? html`
    @@ -112,22 +112,22 @@ class DialogConfigEntrySystemOptions extends LitElement {
    `} - - ${!this._loading - ? html` -
    - - ${this.hass.localize( - "ui.dialogs.config_entry_system_options.update" - )} - -
    - ` - : ""} - +
    + + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize("ui.dialogs.config_entry_system_options.update")} + + `; } @@ -154,19 +154,10 @@ class DialogConfigEntrySystemOptions extends LitElement { } } - private _openedChanged(ev: PolymerChangedEvent): void { - if (!(ev.detail as any).value) { - this._params = undefined; - } - } - static get styles(): CSSResult[] { return [ haStyleDialog, css` - ha-paper-dialog { - max-width: 500px; - } .init-spinner { padding: 50px 100px; text-align: center; diff --git a/src/dialogs/domain-toggler/dialog-domain-toggler.ts b/src/dialogs/domain-toggler/dialog-domain-toggler.ts index 4fb23c79de..4540edfcc7 100644 --- a/src/dialogs/domain-toggler/dialog-domain-toggler.ts +++ b/src/dialogs/domain-toggler/dialog-domain-toggler.ts @@ -19,7 +19,8 @@ import { HassDialog } from "../make-dialog-manager"; import { HaDomainTogglerDialogParams } from "./show-dialog-domain-toggler"; @customElement("dialog-domain-toggler") -class DomainTogglerDialog extends LitElement implements HassDialog { +class DomainTogglerDialog extends LitElement + implements HassDialog { public hass!: HomeAssistant; @internalProperty() private _params?: HaDomainTogglerDialogParams; diff --git a/src/dialogs/generic/dialog-box.ts b/src/dialogs/generic/dialog-box.ts index 5df7cb4f1e..e905f0c3ff 100644 --- a/src/dialogs/generic/dialog-box.ts +++ b/src/dialogs/generic/dialog-box.ts @@ -70,6 +70,7 @@ class DialogBox extends LitElement {

    ${this._params.text} @@ -180,6 +181,9 @@ class DialogBox extends LitElement { /* Place above other dialogs */ --dialog-z-index: 104; } + .warning { + color: var(--warning-color); + } `, ]; } diff --git a/src/dialogs/generic/show-dialog-box.ts b/src/dialogs/generic/show-dialog-box.ts index 86c5cfee8b..5e965bab2f 100644 --- a/src/dialogs/generic/show-dialog-box.ts +++ b/src/dialogs/generic/show-dialog-box.ts @@ -5,6 +5,7 @@ interface BaseDialogParams { confirmText?: string; text?: string | TemplateResult; title?: string; + warning?: boolean; } export interface AlertDialogParams extends BaseDialogParams { diff --git a/src/dialogs/image-cropper-dialog/image-cropper-dialog.ts b/src/dialogs/image-cropper-dialog/image-cropper-dialog.ts index ed9e3a05aa..c2ab0ca148 100644 --- a/src/dialogs/image-cropper-dialog/image-cropper-dialog.ts +++ b/src/dialogs/image-cropper-dialog/image-cropper-dialog.ts @@ -29,7 +29,7 @@ export class HaImagecropperDialog extends LitElement { @internalProperty() private _open = false; - @query("img") private _image!: HTMLImageElement; + @query("img", true) private _image!: HTMLImageElement; private _cropper?: Cropper; diff --git a/src/dialogs/more-info/controls/more-info-cover.js b/src/dialogs/more-info/controls/more-info-cover.js index d2d0d8770e..4e26d57e86 100644 --- a/src/dialogs/more-info/controls/more-info-cover.js +++ b/src/dialogs/more-info/controls/more-info-cover.js @@ -64,6 +64,10 @@ class MoreInfoCover extends LocalizeMixin(PolymerElement) {

+ `; } diff --git a/src/dialogs/more-info/controls/more-info-group.js b/src/dialogs/more-info/controls/more-info-group.js deleted file mode 100644 index fabab470cc..0000000000 --- a/src/dialogs/more-info/controls/more-info-group.js +++ /dev/null @@ -1,111 +0,0 @@ -import { dom } from "@polymer/polymer/lib/legacy/polymer.dom"; -import { html } from "@polymer/polymer/lib/utils/html-tag"; -/* eslint-plugin-disable lit */ -import { PolymerElement } from "@polymer/polymer/polymer-element"; -import dynamicContentUpdater from "../../../common/dom/dynamic_content_updater"; -import { computeStateDomain } from "../../../common/entity/compute_state_domain"; -import "../../../state-summary/state-card-content"; - -class MoreInfoGroup extends PolymerElement { - static get template() { - return html` - - -
- - `; - } - - static get properties() { - return { - hass: { - type: Object, - }, - - stateObj: { - type: Object, - }, - - states: { - type: Array, - computed: "computeStates(stateObj, hass)", - }, - }; - } - - static get observers() { - return ["statesChanged(stateObj, states)"]; - } - - computeStates(stateObj, hass) { - const states = []; - const entIds = stateObj.attributes.entity_id || []; - - for (let i = 0; i < entIds.length; i++) { - const state = hass.states[entIds[i]]; - - if (state) { - states.push(state); - } - } - - return states; - } - - statesChanged(stateObj, states) { - let groupDomainStateObj = false; - let groupDomain = false; - - if (states && states.length > 0) { - const baseStateObj = states.find((s) => s.state === "on") || states[0]; - groupDomain = computeStateDomain(baseStateObj); - - // Groups need to be filtered out or we'll show content of - // first child above the children of the current group - if (groupDomain !== "group") { - groupDomainStateObj = { - ...baseStateObj, - entity_id: stateObj.entity_id, - attributes: { ...baseStateObj.attributes }, - }; - - for (let i = 0; i < states.length; i++) { - if (groupDomain !== computeStateDomain(states[i])) { - groupDomainStateObj = false; - break; - } - } - } - } - - if (!groupDomainStateObj) { - const el = dom(this.$.groupedControlDetails); - if (el.lastChild) { - el.removeChild(el.lastChild); - } - } else { - dynamicContentUpdater( - this.$.groupedControlDetails, - "MORE-INFO-" + groupDomain.toUpperCase(), - { stateObj: groupDomainStateObj, hass: this.hass } - ); - } - } -} - -customElements.define("more-info-group", MoreInfoGroup); diff --git a/src/dialogs/more-info/controls/more-info-group.ts b/src/dialogs/more-info/controls/more-info-group.ts new file mode 100644 index 0000000000..788d1a2f6b --- /dev/null +++ b/src/dialogs/more-info/controls/more-info-group.ts @@ -0,0 +1,111 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { + LitElement, + property, + CSSResult, + css, + internalProperty, + PropertyValues, +} from "lit-element"; +import { html, TemplateResult } from "lit-html"; +import { dynamicElement } from "../../../common/dom/dynamic-element-directive"; +import { computeStateDomain } from "../../../common/entity/compute_state_domain"; +import "../../../state-summary/state-card-content"; +import { GroupEntity, HomeAssistant } from "../../../types"; +import { + importMoreInfoControl, + domainMoreInfoType, +} from "../state_more_info_control"; + +class MoreInfoGroup extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public stateObj?: GroupEntity; + + @internalProperty() private _groupDomainStateObj?: HassEntity; + + @internalProperty() private _moreInfoType?: string; + + protected updated(changedProperties: PropertyValues) { + if ( + !this.hass || + !this.stateObj || + (!changedProperties.has("hass") && !changedProperties.has("stateObj")) + ) { + return; + } + + const states = this.stateObj.attributes.entity_id + .map((entity_id) => this.hass.states[entity_id]) + .filter((state) => state); + + if (!states.length) { + this._groupDomainStateObj = undefined; + this._moreInfoType = undefined; + return; + } + + const baseStateObj = states.find((s) => s.state === "on") || states[0]; + const groupDomain = computeStateDomain(baseStateObj); + + // Groups need to be filtered out or we'll show content of + // first child above the children of the current group + if ( + groupDomain !== "group" && + states.every((state) => groupDomain === computeStateDomain(state)) + ) { + this._groupDomainStateObj = { + ...baseStateObj, + entity_id: this.stateObj.entity_id, + attributes: { ...baseStateObj.attributes }, + }; + const type = domainMoreInfoType(groupDomain); + importMoreInfoControl(type); + this._moreInfoType = type === "hidden" ? undefined : `more-info-${type}`; + } else { + this._groupDomainStateObj = undefined; + this._moreInfoType = undefined; + } + } + + protected render(): TemplateResult { + if (!this.hass || !this.stateObj) { + return html``; + } + return html`${this._moreInfoType + ? dynamicElement(this._moreInfoType, { + hass: this.hass, + stateObj: this._groupDomainStateObj, + }) + : ""} + ${this.stateObj.attributes.entity_id.map((entity_id) => { + const state = this.hass!.states[entity_id]; + if (!state) { + return ""; + } + return html` + + `; + })}`; + } + + static get styles(): CSSResult { + return css` + state-card-content { + display: block; + margin-top: 8px; + } + `; + } +} + +customElements.define("more-info-group", MoreInfoGroup); + +declare global { + interface HTMLElementTagNameMap { + "more-info-group": MoreInfoGroup; + } +} diff --git a/src/dialogs/more-info/controls/more-info-light.ts b/src/dialogs/more-info/controls/more-info-light.ts index 7ce5896ad6..a0ae76b03b 100644 --- a/src/dialogs/more-info/controls/more-info-light.ts +++ b/src/dialogs/more-info/controls/more-info-light.ts @@ -67,9 +67,10 @@ class MoreInfoLight extends LitElement { caption=${this.hass.localize("ui.card.light.brightness")} icon="hass:brightness-5" min="1" - max="255" + max="100" value=${this._brightnessSliderValue} @change=${this._brightnessSliderChanged} + pin > ` : ""} @@ -87,6 +88,7 @@ class MoreInfoLight extends LitElement { .max=${this.stateObj.attributes.max_mireds} .value=${this._ctSliderValue} @change=${this._ctSliderChanged} + pin > ` : ""} @@ -98,6 +100,7 @@ class MoreInfoLight extends LitElement { max="255" .value=${this._wvSliderValue} @change=${this._wvSliderChanged} + pin > ` : ""} @@ -155,16 +158,22 @@ class MoreInfoLight extends LitElement { protected updated(changedProps: PropertyValues): void { const stateObj = this.stateObj! as LightEntity; - if (changedProps.has("stateObj") && stateObj.state === "on") { - this._brightnessSliderValue = stateObj.attributes.brightness; - this._ctSliderValue = stateObj.attributes.color_temp; - this._wvSliderValue = stateObj.attributes.white_value; + if (changedProps.has("stateObj")) { + if (stateObj.state === "on") { + this._brightnessSliderValue = Math.round( + (stateObj.attributes.brightness * 100) / 255 + ); + this._ctSliderValue = stateObj.attributes.color_temp; + this._wvSliderValue = stateObj.attributes.white_value; - if (stateObj.attributes.hs_color) { - this._colorPickerColor = { - h: stateObj.attributes.hs_color[0], - s: stateObj.attributes.hs_color[1] / 100, - }; + if (stateObj.attributes.hs_color) { + this._colorPickerColor = { + h: stateObj.attributes.hs_color[0], + s: stateObj.attributes.hs_color[1] / 100, + }; + } + } else { + this._brightnessSliderValue = 0; } } } @@ -191,7 +200,7 @@ class MoreInfoLight extends LitElement { this.hass.callService("light", "turn_on", { entity_id: this.stateObj!.entity_id, - brightness: bri, + brightness_pct: bri, }); } @@ -250,15 +259,10 @@ class MoreInfoLight extends LitElement { align-items: center; } - .content.is-on { - margin-top: -16px; - } - .content > * { width: 100%; max-height: 84px; overflow: hidden; - padding-top: 16px; } .color_temp { diff --git a/src/dialogs/more-info/controls/more-info-media_player.ts b/src/dialogs/more-info/controls/more-info-media_player.ts index 85ac9db862..1e47d7f943 100644 --- a/src/dialogs/more-info/controls/more-info-media_player.ts +++ b/src/dialogs/more-info/controls/more-info-media_player.ts @@ -104,18 +104,7 @@ class MoreInfoMediaPlayer extends LitElement { > ` : ""} - ${supportsFeature(stateObj, SUPPORT_VOLUME_SET) - ? html` - - ` - : supportsFeature(stateObj, SUPPORT_VOLUME_BUTTONS) + ${supportsFeature(stateObj, SUPPORT_VOLUME_BUTTONS) ? html` ` : ""} + ${supportsFeature(stateObj, SUPPORT_VOLUME_SET) + ? html` + + ` + : ""}
` : ""} @@ -196,8 +197,8 @@ class MoreInfoMediaPlayer extends LitElement { )} @keydown=${this._ttsCheckForEnter} > - diff --git a/src/dialogs/more-info/controls/more-info-weather.ts b/src/dialogs/more-info/controls/more-info-weather.ts index 8d7a4ca5f3..7e962071a5 100644 --- a/src/dialogs/more-info/controls/more-info-weather.ts +++ b/src/dialogs/more-info/controls/more-info-weather.ts @@ -1,3 +1,4 @@ +import "../../../components/ha-svg-icon"; import { HassEntity } from "home-assistant-js-websocket"; import { css, @@ -9,44 +10,47 @@ import { } from "lit-element"; import { html, TemplateResult } from "lit-html"; import { HomeAssistant } from "../../../types"; -import "../../../components/ha-icon"; -const cardinalDirections = [ - "N", - "NNE", - "NE", - "ENE", - "E", - "ESE", - "SE", - "SSE", - "S", - "SSW", - "SW", - "WSW", - "W", - "WNW", - "NW", - "NNW", - "N", -]; +import { getWind, getWeatherUnit } from "../../../data/weather"; + +import { + mdiAlertCircleOutline, + mdiEye, + mdiGauge, + mdiThermometer, + mdiWaterPercent, + mdiWeatherCloudy, + mdiWeatherFog, + mdiWeatherHail, + mdiWeatherLightning, + mdiWeatherLightningRainy, + mdiWeatherNight, + mdiWeatherPartlyCloudy, + mdiWeatherPouring, + mdiWeatherRainy, + mdiWeatherSnowy, + mdiWeatherSnowyRainy, + mdiWeatherSunny, + mdiWeatherWindy, + mdiWeatherWindyVariant, +} from "@mdi/js"; const weatherIcons = { - "clear-night": "hass:weather-night", - cloudy: "hass:weather-cloudy", - exceptional: "hass:alert-circle-outline", - fog: "hass:weather-fog", - hail: "hass:weather-hail", - lightning: "hass:weather-lightning", - "lightning-rainy": "hass:weather-lightning-rainy", - partlycloudy: "hass:weather-partly-cloudy", - pouring: "hass:weather-pouring", - rainy: "hass:weather-rainy", - snowy: "hass:weather-snowy", - "snowy-rainy": "hass:weather-snowy-rainy", - sunny: "hass:weather-sunny", - windy: "hass:weather-windy", - "windy-variant": "hass:weather-windy-variant", + "clear-night": mdiWeatherNight, + cloudy: mdiWeatherCloudy, + exceptional: mdiAlertCircleOutline, + fog: mdiWeatherFog, + hail: mdiWeatherHail, + lightning: mdiWeatherLightning, + "lightning-rainy": mdiWeatherLightningRainy, + partlycloudy: mdiWeatherPartlyCloudy, + pouring: mdiWeatherPouring, + rainy: mdiWeatherRainy, + snowy: mdiWeatherSnowy, + "snowy-rainy": mdiWeatherSnowyRainy, + sunny: mdiWeatherSunny, + windy: mdiWeatherWindy, + "windy-variant": mdiWeatherWindyVariant, }; @customElement("more-info-weather") @@ -79,24 +83,25 @@ class MoreInfoWeather extends LitElement { return html`
- +
${this.hass.localize("ui.card.weather.attributes.temperature")}
- ${this.stateObj.attributes.temperature} ${this.getUnit("temperature")} + ${this.stateObj.attributes.temperature} + ${getWeatherUnit(this.hass, "temperature")}
${this._showValue(this.stateObj.attributes.pressure) ? html`
- +
${this.hass.localize("ui.card.weather.attributes.air_pressure")}
${this.stateObj.attributes.pressure} - ${this.getUnit("air_pressure")} + ${getWeatherUnit(this.hass, "air_pressure")}
` @@ -104,7 +109,7 @@ class MoreInfoWeather extends LitElement { ${this._showValue(this.stateObj.attributes.humidity) ? html`
- +
${this.hass.localize("ui.card.weather.attributes.humidity")}
@@ -115,12 +120,13 @@ class MoreInfoWeather extends LitElement { ${this._showValue(this.stateObj.attributes.wind_speed) ? html`
- +
${this.hass.localize("ui.card.weather.attributes.wind_speed")}
- ${this.getWind( + ${getWind( + this.hass, this.stateObj.attributes.wind_speed, this.stateObj.attributes.wind_bearing )} @@ -131,12 +137,13 @@ class MoreInfoWeather extends LitElement { ${this._showValue(this.stateObj.attributes.visibility) ? html`
- +
${this.hass.localize("ui.card.weather.attributes.visibility")}
- ${this.stateObj.attributes.visibility} ${this.getUnit("length")} + ${this.stateObj.attributes.visibility} + ${getWeatherUnit(this.hass, "length")}
` @@ -151,9 +158,9 @@ class MoreInfoWeather extends LitElement {
${item.condition ? html` - + ` : ""} ${!this._showValue(item.templow) @@ -169,12 +176,14 @@ class MoreInfoWeather extends LitElement { ${this.computeDate(item.datetime)}
- ${item.templow} ${this.getUnit("temperature")} + ${item.templow} + ${getWeatherUnit(this.hass, "temperature")}
` : ""}
- ${item.temperature} ${this.getUnit("temperature")} + ${item.temperature} + ${getWeatherUnit(this.hass, "temperature")}
`; @@ -193,7 +202,7 @@ class MoreInfoWeather extends LitElement { static get styles(): CSSResult { return css` - ha-icon { + ha-svg-icon { color: var(--paper-item-icon-color); } .section { @@ -247,41 +256,6 @@ class MoreInfoWeather extends LitElement { }); } - private getUnit(measure: string): string { - const lengthUnit = this.hass.config.unit_system.length || ""; - switch (measure) { - case "air_pressure": - return lengthUnit === "km" ? "hPa" : "inHg"; - case "length": - return lengthUnit; - case "precipitation": - return lengthUnit === "km" ? "mm" : "in"; - default: - return this.hass.config.unit_system[measure] || ""; - } - } - - private windBearingToText(degree: string): string { - const degreenum = parseInt(degree, 10); - if (isFinite(degreenum)) { - // eslint-disable-next-line no-bitwise - return cardinalDirections[(((degreenum + 11.25) / 22.5) | 0) % 16]; - } - return degree; - } - - private getWind(speed: string, bearing: string) { - if (bearing != null) { - const cardinalDirection = this.windBearingToText(bearing); - return `${speed} ${this.getUnit("length")}/h (${ - this.hass.localize( - `ui.card.weather.cardinal_direction.${cardinalDirection.toLowerCase()}` - ) || cardinalDirection - })`; - } - return `${speed} ${this.getUnit("length")}/h`; - } - private _showValue(item: string): boolean { return typeof item !== "undefined" && item !== null; } diff --git a/src/dialogs/more-info/ha-more-info-dialog.ts b/src/dialogs/more-info/ha-more-info-dialog.ts index 9851cf2b5b..836273f07f 100644 --- a/src/dialogs/more-info/ha-more-info-dialog.ts +++ b/src/dialogs/more-info/ha-more-info-dialog.ts @@ -17,14 +17,10 @@ import { DOMAINS_MORE_INFO_NO_HISTORY, DOMAINS_WITH_MORE_INFO, } from "../../common/const"; -import { dynamicElement } from "../../common/dom/dynamic-element-directive"; import { fireEvent } from "../../common/dom/fire_event"; import { computeDomain } from "../../common/entity/compute_domain"; import { computeStateName } from "../../common/entity/compute_state_name"; -import { - stateMoreInfoType, - importMoreInfoControl, -} from "./state_more_info_control"; + import { navigate } from "../../common/navigate"; import "../../components/ha-dialog"; import "../../components/ha-header-bar"; @@ -38,9 +34,18 @@ import { showConfirmationDialog } from "../generic/show-dialog-box"; import "./ha-more-info-history"; import "./ha-more-info-logbook"; import "./controls/more-info-default"; +import { CONTINUOUS_DOMAINS } from "../../data/logbook"; +import "./more-info-content"; const DOMAINS_NO_INFO = ["camera", "configurator"]; +/** + * Entity domains that should be editable *if* they have an id present; + * {@see shouldShowEditIcon}. + * */ const EDITABLE_DOMAINS_WITH_ID = ["scene", "automation"]; +/** + * Entity Domains that should always be editable; {@see shouldShowEditIcon}. + * */ const EDITABLE_DOMAINS = ["script"]; export interface MoreInfoDialogParams { @@ -55,14 +60,13 @@ export class MoreInfoDialog extends LitElement { @internalProperty() private _entityId?: string | null; - @internalProperty() private _moreInfoType?: string; - @internalProperty() private _currTabIndex = 0; public showDialog(params: MoreInfoDialogParams) { this._entityId = params.entityId; if (!this._entityId) { this.closeDialog(); + return; } this.large = false; } @@ -73,21 +77,18 @@ export class MoreInfoDialog extends LitElement { fireEvent(this, "dialog-closed", { dialog: this.localName }); } - protected updated(changedProperties) { - if (!this.hass || !this._entityId || !changedProperties.has("_entityId")) { - return; + protected shouldShowEditIcon(domain, stateObj): boolean { + if (EDITABLE_DOMAINS_WITH_ID.includes(domain) && stateObj.attributes.id) { + return true; } - const stateObj = this.hass.states[this._entityId]; - if (!stateObj) { - return; + if (EDITABLE_DOMAINS.includes(domain)) { + return true; } - if (stateObj.attributes && "custom_ui_more_info" in stateObj.attributes) { - this._moreInfoType = stateObj.attributes.custom_ui_more_info; - } else { - const type = stateMoreInfoType(stateObj); - importMoreInfoControl(type); - this._moreInfoType = type === "hidden" ? undefined : `more-info-${type}`; + if (domain === "person" && stateObj.attributes.editable !== "false") { + return true; } + + return false; } protected render() { @@ -137,10 +138,7 @@ export class MoreInfoDialog extends LitElement { ` : ""} - ${this.hass.user!.is_admin && - ((EDITABLE_DOMAINS_WITH_ID.includes(domain) && - stateObj.attributes.id) || - EDITABLE_DOMAINS.includes(domain)) + ${this.shouldShowEditIcon(domain, stateObj) ? html` ${DOMAINS_WITH_MORE_INFO.includes(domain) && - this._computeShowHistoryComponent(entityId) + (this._computeShowHistoryComponent(entityId) || + this._computeShowLogBookComponent(entityId)) ? html` - `} - ${this._moreInfoType - ? dynamicElement(this._moreInfoType, { - hass: this.hass, - stateObj, - }) - : ""} + .hass=${this.hass} + .entityId=${this._entityId} + >`} + ${DOMAINS_WITH_MORE_INFO.includes(domain) || + !this._computeShowLogBookComponent(entityId) + ? "" + : html``} + ${stateObj.attributes.restored ? html`

@@ -250,12 +250,32 @@ export class MoreInfoDialog extends LitElement { private _computeShowHistoryComponent(entityId) { return ( - (isComponentLoaded(this.hass, "history") || - isComponentLoaded(this.hass, "logbook")) && + isComponentLoaded(this.hass, "history") && !DOMAINS_MORE_INFO_NO_HISTORY.includes(computeDomain(entityId)) ); } + private _computeShowLogBookComponent(entityId): boolean { + if (!isComponentLoaded(this.hass, "logbook")) { + return false; + } + + const stateObj = this.hass.states[entityId]; + if (!stateObj || stateObj.attributes.unit_of_measurement) { + return false; + } + + const domain = computeDomain(entityId); + if ( + CONTINUOUS_DOMAINS.includes(domain) || + DOMAINS_MORE_INFO_NO_HISTORY.includes(domain) + ) { + return false; + } + + return true; + } + private _removeEntity() { const entityId = this._entityId!; showConfirmationDialog(this, { @@ -283,14 +303,12 @@ export class MoreInfoDialog extends LitElement { private _gotoEdit() { const stateObj = this.hass.states[this._entityId!]; const domain = computeDomain(this._entityId!); - navigate( - this, - `/config/${domain}/edit/${ - EDITABLE_DOMAINS_WITH_ID.includes(domain) - ? stateObj.attributes.id - : stateObj.entity_id - }` - ); + let idToPassThroughUrl = stateObj.entity_id; + if (EDITABLE_DOMAINS_WITH_ID.includes(domain) || domain === "person") { + idToPassThroughUrl = stateObj.attributes.id; + } + + navigate(this, `/config/${domain}/edit/${idToPassThroughUrl}`); this.closeDialog(); } diff --git a/src/dialogs/more-info/ha-more-info-history.ts b/src/dialogs/more-info/ha-more-info-history.ts index 93970b7b93..eb393e3395 100644 --- a/src/dialogs/more-info/ha-more-info-history.ts +++ b/src/dialogs/more-info/ha-more-info-history.ts @@ -78,7 +78,6 @@ export class MoreInfoHistory extends LitElement { this.hass!, this.entityId, { - refresh: 60, cacheKey: `more_info.${this.entityId}`, hoursToShow: 24, }, diff --git a/src/dialogs/more-info/ha-more-info-logbook.ts b/src/dialogs/more-info/ha-more-info-logbook.ts index 76d05562c5..107636a7c0 100644 --- a/src/dialogs/more-info/ha-more-info-logbook.ts +++ b/src/dialogs/more-info/ha-more-info-logbook.ts @@ -155,9 +155,9 @@ export class MoreInfoLogbook extends LitElement { overflow: auto; } @media all and (max-width: 450px), all and (max-height: 500px) { - ha-logbook { - max-height: unset; - } + ha-logbook { + max-height: unset; + } } ha-circular-progress { display: flex; diff --git a/src/dialogs/more-info/more-info-content.ts b/src/dialogs/more-info/more-info-content.ts new file mode 100644 index 0000000000..47570226fa --- /dev/null +++ b/src/dialogs/more-info/more-info-content.ts @@ -0,0 +1,57 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { property, PropertyValues, UpdatingElement } from "lit-element"; + +import { HomeAssistant } from "../../types"; +import dynamicContentUpdater from "../../common/dom/dynamic_content_updater"; +import { stateMoreInfoType } from "./state_more_info_control"; +import { importMoreInfoControl } from "../../panels/lovelace/custom-card-helpers"; + +class MoreInfoContent extends UpdatingElement { + @property({ attribute: false }) public hass?: HomeAssistant; + + @property() public stateObj?: HassEntity; + + private _detachedChild?: ChildNode; + + // This is not a lit element, but an updating element, so we implement update + protected update(changedProps: PropertyValues): void { + super.update(changedProps); + const stateObj = this.stateObj; + const hass = this.hass; + + if (!stateObj || !hass) { + if (this.lastChild) { + this._detachedChild = this.lastChild; + // Detach child to prevent it from doing work. + this.removeChild(this.lastChild); + } + return; + } + + if (this._detachedChild) { + this.appendChild(this._detachedChild); + this._detachedChild = undefined; + } + + let moreInfoType: string | undefined; + + if (stateObj.attributes && "custom_ui_more_info" in stateObj.attributes) { + moreInfoType = stateObj.attributes.custom_ui_more_info; + } else { + const type = stateMoreInfoType(stateObj); + importMoreInfoControl(type); + moreInfoType = type === "hidden" ? undefined : `more-info-${type}`; + } + + if (!moreInfoType) { + return; + } + + dynamicContentUpdater(this, moreInfoType.toUpperCase(), { + hass, + stateObj, + }); + } +} + +customElements.define("more-info-content", MoreInfoContent); diff --git a/src/dialogs/more-info/state_more_info_control.ts b/src/dialogs/more-info/state_more_info_control.ts index c2b97d9c59..6f8cf904ab 100644 --- a/src/dialogs/more-info/state_more_info_control.ts +++ b/src/dialogs/more-info/state_more_info_control.ts @@ -32,6 +32,10 @@ const LAZY_LOADED_MORE_INFO_CONTROL = { export const stateMoreInfoType = (stateObj: HassEntity): string => { const domain = computeStateDomain(stateObj); + return domainMoreInfoType(domain); +}; + +export const domainMoreInfoType = (domain: string): string => { if (DOMAINS_WITH_MORE_INFO.includes(domain)) { return domain; } diff --git a/src/dialogs/notifications/notification-drawer.js b/src/dialogs/notifications/notification-drawer.js index 927af2f3a9..0ad149e9f0 100644 --- a/src/dialogs/notifications/notification-drawer.js +++ b/src/dialogs/notifications/notification-drawer.js @@ -40,7 +40,7 @@ export class HuiNotificationDrawer extends EventsMixin( padding-left: env(safe-area-inset-left); padding-right: env(safe-area-inset-right); padding-bottom: env(safe-area-inset-bottom); - height: calc(100% - 65px); + height: calc(100% - 1px - var(--header-height)); box-sizing: border-box; background-color: var(--primary-background-color); color: var(--primary-text-color); @@ -50,6 +50,11 @@ export class HuiNotificationDrawer extends EventsMixin( padding: 0 16px 16px; } + .notification-actions { + padding: 0 16px 16px; + text-align: center; + } + .empty { padding: 16px; text-align: center; @@ -69,6 +74,13 @@ export class HuiNotificationDrawer extends EventsMixin(

+