Compare commits

..

125 Commits

Author SHA1 Message Date
Bram Kragten 10c780a847 Add parallel action to trace timeline 2022-05-03 00:07:27 +02:00
Bram Kragten 3a305a44b6 Handle if in repeat (#12544) 2022-05-02 14:48:28 -07:00
Bram Kragten e99143139e Fix script graph parallel (#12545) 2022-05-02 14:47:43 -07:00
Bram Kragten f0c7232704 Add trace timeline for if (#12543) 2022-05-02 14:47:17 -07:00
Zack Barett b2186592df Change name to Settings (#12548) 2022-05-02 23:29:06 +02:00
Bram Kragten e51e3e79d5 Add repeat to trace timeline (#12547) 2022-05-02 17:16:32 +00:00
Bram Kragten 3b6b4d7664 Add descriptions for actions (#12541)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-05-02 15:06:55 +00:00
Zack Barett 239e71b414 Fix some issues and feedback with About and system health (#12537) 2022-05-02 12:54:55 +02:00
Philip Allgaier 080cad0ccd Prevent color temp selector mired exception (#12536) 2022-05-01 22:21:25 +00:00
Allen Porter dd49fd2788 Make the "Aborted: Reauthentication successful" more user friendly (#12530)
Replace the "Aborted" in the title with the integration name to make the user error
messages more user friendly. The message itself ("Reauthentication successful" or "Missing configuraiton, etc) error
message is descriptive enought that we can replace the title with the integration
name and still preserve the meeting. The advance is that this doesn't confuse users
who are surprised by it saying "Aborted" when things were successful

https://github.com/home-assistant/core/issues/47135
2022-05-01 11:02:32 -05:00
Thomas Lovén a571fb5528 Handle condition shorthands in trace graphs (#12533) 2022-05-01 10:59:46 -05:00
Yosi Levy 1369c1ae8c Calendar-card fix (#12532) 2022-05-01 10:59:12 -05:00
Joakim Sørensen f5864181af Add optional repository_url to supervisor_addon my link (#12524) 2022-04-30 16:53:43 -05:00
Joakim Sørensen a4a0d7cf19 Ignore modifier keys when forwarding events to quickbar (#12525) 2022-04-30 16:52:14 -05:00
Bram Kragten 092dfd1e87 Change color of persons for real this time (#12527) 2022-04-30 14:31:43 -05:00
Zack Barett a29ac33810 Bumped version to 20220429.0 (#12521) 2022-04-29 15:37:42 -07:00
Bram Kragten 1421df2a5a Add if, parallel and stop action to trace graph (#12520) 2022-04-29 16:30:40 -05:00
Zack Barett 591b8cc503 Move integrations to System Health (#12504) 2022-04-29 20:53:24 +02:00
Bram Kragten 011467ece0 Add actions to design gallery (#12518)
* Add actions to design gallery

* Update describe-action.ts
2022-04-29 20:51:44 +02:00
Zack Barett f52e8c3392 Restart Home ASsistant button - Make less red and less big (#12515) 2022-04-29 19:15:43 +02:00
Zack Barett c8b87b65bd Fix for external url not logged into cloud (#12516) 2022-04-29 16:19:53 +00:00
Thomas Lovén 98cc82db44 Add condition shorthand to action types (#12514) 2022-04-29 15:40:03 +00:00
Zack Barett f510e2a8e0 Only show Card Content if OS exist (#12513) 2022-04-29 16:49:47 +02:00
Franck Nijhof 3438912ba5 Support shorthand logical operators in script sequences (#12509) 2022-04-29 09:47:44 -05:00
Bruno Maia 671c8e387f Fix continue_on_timeout default on wait_template automation visual editor (#12511) 2022-04-29 14:37:35 +00:00
Yosi Levy 0108ec65cf Media browser RTL fixes (#12506) 2022-04-29 09:27:06 -05:00
Philip Allgaier 39f7034578 Fix incorrect 3-dot menu labels (config hardware & storage) (#12512) 2022-04-29 09:24:37 -05:00
Bram Kragten bf8affaf2b Use media query for config menu mobile (#12510) 2022-04-29 07:41:27 -05:00
Yosi Levy e16a61eb53 form-string password fix (#12507) 2022-04-29 11:50:19 +02:00
Zack Barett cadbe45bab Fix Wrap menu and remove menu title (#12505) 2022-04-28 19:23:23 -07:00
Zack Barett 51f971337d Bumped version to 20220428.0 (#12501) 2022-04-28 13:50:08 -07:00
Zack Barett 1f3c23de29 Change Restart to be a button, update dialogs (#12499) 2022-04-28 13:43:00 -07:00
Zack Barett bdfb17d957 Add Board Names, Move All Hardware (#12484)
Co-authored-by: Joakim Sørensen <ludeeus@ludeeus.dev>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-04-28 20:42:18 +00:00
Franck Nijhof 8c97aee1fe Add parallel automation/script action (#12491) 2022-04-28 15:09:03 -05:00
Bram Kragten 38b4090daa Add support for enabling/disabling trigger/condition/action (#12493)
* Add support for enabling/disabling trigger/condition/action

* Add more visual indication of disabled

* review

* margin

* Dont make overflow transparent

* Change color of bar
2022-04-28 18:37:58 +02:00
Thomas Lovén b8c55f2f65 Evaluate condition shorthands in editors (#12473)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-04-28 08:36:17 -07:00
Bram Kragten 7ca379e0a1 Hide and sort secondary device automations (#12496) 2022-04-28 08:53:56 -05:00
Bram Kragten 1617a9dfed Address minor comments about config menu (#12492) 2022-04-28 08:44:01 -05:00
Franck Nijhof 2c9411c6c3 Add template editor to Markdown card editor (#12490) 2022-04-28 12:40:39 +02:00
Zack Barett 67626d4a06 add my redirects for new config pages (#12481) 2022-04-28 12:39:35 +02:00
Yosi Levy 8135611688 Media panel fix (#12485) 2022-04-28 05:16:18 +00:00
Zack Barett 3ccbf6983e Move General Up in the system menu (#12483) 2022-04-27 22:08:21 -07:00
Zack Barett e4f91195d8 Fix Restarting Home Assistant (#12480)
* Fix Restarting Home ASsistant

* Update src/panels/config/core/ha-config-system-navigation.ts

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

* Update src/panels/developer-tools/yaml_configuration/developer-yaml-config.ts

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

* reviews

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-04-27 15:55:04 -07:00
Philip Allgaier 2751f8f33b Add some bottom padding to YAML conf dev tools page (#12477)
Co-authored-by: Zack Barett <zackbarett@hey.com>
2022-04-27 22:18:25 +02:00
Philip Allgaier 57f2df3b3e Visual tweaks to YAML validation results (#12479) 2022-04-27 19:57:41 +00:00
Zack Barett 6822f0d067 Small config fixes (#12472) 2022-04-27 12:22:57 -07:00
Zack Barett cfba957313 Fix YAML Config Invalid button (#12476) 2022-04-27 13:57:57 -05:00
Yosi Levy 3149ffbf19 RTL fix for log buttons (#12474) 2022-04-27 12:26:19 -05:00
Philip Allgaier 4cd8b76d7e Safeguard against non-existant area in device handling (#12475) 2022-04-27 12:25:13 -05:00
Joakim Sørensen 4b644d8bc5 Add supervisor redirects to m keyboard shortcut (#12466) 2022-04-27 13:36:47 +00:00
Joakim Sørensen 307cd5ad8c Use startsWith for m shortcut for partial match (#12464) 2022-04-27 08:10:38 -05:00
Joakim Sørensen ebc807a6a4 Add hass-quick-bar-trigger event to trigger quickbar from supervisor (#12467) 2022-04-27 08:08:45 -05:00
Philip Allgaier 66adecdfc9 Make helper option button more user friendly (#12468) 2022-04-27 08:07:57 -05:00
Philip Allgaier 2cc6432a0f Use correct label for update config menu (#12465) 2022-04-27 06:37:50 -05:00
Paulus Schoutsen a2c0c0474a Bumped version to 20220427.0 2022-04-26 22:13:16 -07:00
Zack Barett 27884b9a54 Move Restart to Overflow and yaml config advanced (#12446)
* Move Restart to Overflow and yaml config advanced

* Move around YAML Config page

* Move to developer tools

* Make card actions

* Update Translations
2022-04-26 22:12:44 -07:00
Paulus Schoutsen 293df61872 Add a tip for my shortcut (#12462) 2022-04-27 05:01:40 +00:00
Paulus Schoutsen f82dada3e5 Fix icon alignment in nav list (#12463) 2022-04-26 21:58:26 -07:00
Paulus Schoutsen e5824c4794 Fix my link for config dashboard and profile (#12461)
* Fix my link for config dashboard and profile

* add server control redirect

Co-authored-by: Zack <zackbarett@hey.com>
2022-04-27 04:58:18 +00:00
Paulus Schoutsen 186550229c Tweak menu descriptions (#12460) 2022-04-27 04:53:42 +00:00
Zack Barett 7877dd8e6b Move Zones Edit to General config + add general config page (#12452)
* Move Zones Edit to General config + add general

* Update src/translations/en.json

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

* add paper tooltip back for yaml

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2022-04-26 21:53:29 -07:00
Zack Barett b03abc249b Fix Updates Page Toast - Move to overflow (#12453) 2022-04-26 21:52:22 -07:00
Paulus Schoutsen fda03918b9 Move the analytics link (#12459) 2022-04-27 04:40:01 +00:00
Zack Barett 6747375a1b Move Provider Selection to Menu on top header (#12443) 2022-04-26 23:27:15 -05:00
Zack Barett 53b6e31881 Update Configuration badge color to be accent color to match (#12455) 2022-04-26 21:12:09 -07:00
Zack Barett fa004de2d1 Fix more info input number #12396 (#12456) 2022-04-26 21:11:54 -07:00
Zack Barett 3605f7b70f Fix when creating new area in picker #11392 (#12457) 2022-04-26 21:11:38 -07:00
Zack Barett 5348c54c91 Update the hint for key C (#12458) 2022-04-26 21:11:18 -07:00
Zack Barett 684e4421bc Fix for backup overflow (#12454) 2022-04-26 21:10:35 -07:00
Zack Barett 28f5611df5 Small edits on config menu (#12440) 2022-04-26 21:07:53 -07:00
Johann Vanackere 8da73d49d7 Terms based entities search (#10991) 2022-04-26 19:39:58 -05:00
Joakim Sørensen 049ddd5f84 Add "m" keyboard shortcut to get to the create my link page (#12451) 2022-04-27 00:11:09 +02:00
Bram Kragten 8ae2d4e93a Fix integration page on mobile (#12447) 2022-04-26 14:38:59 -05:00
Philip Allgaier 824bb9ba35 Add title to backups config page (#12442) 2022-04-26 21:04:32 +02:00
Philip Allgaier d550b1a18e Fix content display for ha-network after #12438 (#12445)
* Fix content display for `ha-network` after #12438

* Add var default
2022-04-26 20:41:19 +02:00
Bram Kragten dea6c0e761 Add header to supervisor backups page (#12444) 2022-04-26 17:53:32 +00:00
Philip Allgaier 9caee357c0 Fix incorrect text if no backups are found (#12441) 2022-04-26 12:32:04 -05:00
Bram Kragten 35d892c418 Set border radius in config to 8px (#12437) 2022-04-26 11:50:36 -05:00
Bram Kragten 9572a2a46b Dont show tabs when less than 2 (#12439) 2022-04-26 15:39:50 +00:00
Bram Kragten 8996361b26 Fix settings row width (#12438) 2022-04-26 15:17:00 +00:00
Joakim Sørensen 02ee731602 Add join/leave beta to updates panel (#12436) 2022-04-26 16:39:37 +02:00
Joakim Sørensen bb1e6bf35b Fix backup back path (#12435) 2022-04-26 15:29:56 +02:00
Joakim Sørensen c1b65285c1 Redirect hassio system my links to new locations (#12429) 2022-04-26 13:15:29 +02:00
Bram Kragten 8b8d6e5fa3 Resources lovelace should just go back (#12432) 2022-04-26 11:12:14 +00:00
Joakim Sørensen c34fe184e8 Fix log syntax highlight when fetching logs from supervisor (#12430) 2022-04-26 06:09:39 -05:00
Joakim Sørensen 7363838f86 Move unsupported and unhealthy alerts (#12431) 2022-04-26 12:24:55 +02:00
Jaroslav Hanslík 3081425ccd Typo in en.json (#12428) 2022-04-26 12:20:26 +02:00
Joakim Sørensen 95d494a54c Guard against non OS installation (#12427) 2022-04-26 12:18:43 +02:00
J. Nick Koston 145e5d7bc6 Format sensors with state class duration (#12426) 2022-04-26 02:07:11 +00:00
Zack Barett 876fd9e85a Bumped version to 20220425.0 (#12425) 2022-04-25 18:25:07 -05:00
Zack Barett e8c30cabca Show usage stats in System Health (#12424) 2022-04-25 23:24:58 +00:00
Bram Kragten 490f84a7b1 link to updates page (#12423) 2022-04-25 15:56:34 -05:00
Artem Sorokin ca28178b86 Fix title and description for menu step in options flow (#12420) 2022-04-25 20:26:05 +00:00
Zack Barett 2fceb0aeee Allow for checking for updates (#12422) 2022-04-25 22:15:26 +02:00
Bram Kragten 86f39d1d43 Add supervisor, OS version info to about page (#12421)
* Add supervisor, OS version info to about page

* description

* description
2022-04-25 22:14:32 +02:00
Zack Barett 1faf60444d Move Data Disk Moving to Storage (#12416) 2022-04-25 20:49:44 +02:00
Netzwerkfehler e927091d21 Better gauge segment coloring (#11570) 2022-04-25 13:43:53 -05:00
Bram Kragten cff2f856b3 Don't show tabs in supervisor (#12417) 2022-04-25 13:37:48 -05:00
Bram Kragten a743e3bbba Show what updates are skipped (#12418) 2022-04-25 18:37:24 +00:00
Zack Barett f8a52d250e Move System Health to a page (#12412) 2022-04-25 20:26:53 +02:00
Zack Barett b70a523bdf Backup Page - Will load which is available (#12414) 2022-04-25 19:54:11 +02:00
Zack Barett 8f2ed747e6 Configuration Menu Cleanup items (#12413) 2022-04-25 19:53:02 +02:00
Zack Barett 5deccefb15 Allow Showing Skipped Updates on Updates Page (#12415) 2022-04-25 19:50:30 +02:00
Zack Barett 3f04abfa9d Add Supervisor logs to core page (#12410) 2022-04-25 15:35:03 +00:00
Zack Barett 8e55c83996 Add Hardware Page to Configuration System Menu (#12405) 2022-04-25 17:30:53 +02:00
Bram Kragten dee59486ba Add supervisor hostname config (#12407) 2022-04-25 10:27:38 -05:00
Bram Kragten 77ef509aea Fix zones (#12409) 2022-04-25 15:13:31 +00:00
Bram Kragten bfa7bccfa6 Add supervisor network interface settings (#12403) 2022-04-25 16:21:03 +02:00
Thomas Lovén a8c365edc8 Fix broken cards being able to crash entire view (#11440) 2022-04-25 14:37:32 +02:00
Bram Kragten 94953ddf6c Hide supervisor only config, fix backup config page (#12401) 2022-04-25 07:09:23 -05:00
Zack Barett 6b67546daf Virtualize Media Player Grid (#11898) 2022-04-25 12:32:50 +02:00
Paulus Schoutsen 3e188d1f87 Add shorthand condition to the gallery (#12400) 2022-04-25 10:00:28 +02:00
Zack Barett f69eb15a90 Config Menu: Addressing Comments in #12377 (#12399) 2022-04-24 20:25:47 -07:00
Paulus Schoutsen dfe348187f Bumped version to 20220424.0 2022-04-24 15:26:42 -07:00
Zack Barett 9706c56c5c Configuration Menu Updates 3 (#12377) 2022-04-24 15:26:01 -07:00
Raman Gupta 3677c5be2c Update zwavejs controller model (#12390) 2022-04-24 14:55:31 -07:00
Zack Barett bd339fa963 Fix Dashboard URLs (#12394) 2022-04-24 14:55:04 -07:00
Yosi Levy 28f1b6bdf4 Force LTR on time & number inputs (#12393) 2022-04-23 19:48:17 -05:00
Zack Barett c5aac3b81d Add Empty list item for None (#12356) 2022-04-23 10:11:44 -05:00
yangqian 70836597e9 Show vacuum state in more-info dialog for StateVacuumEntity (#12391) 2022-04-23 06:32:59 +00:00
Allen Porter 958a1de2fd Add calendar event end trigger to automation editor (#12389) 2022-04-23 00:19:23 -05:00
Allen Porter 36d30266e3 Add automation editor for calendar trigger (#12343) 2022-04-22 16:53:45 -05:00
Yosi Levy 558ab9761d RTL reading orders and alignments in system log (#12388) 2022-04-22 20:19:38 +00:00
Eric Stern 269ef370e4 Accept new value when hitting ENTER to close a prompt dialog (#12360)
Co-authored-by: Zack Barett <zackbarett@hey.com>
2022-04-22 14:23:34 -05:00
Kuba Wolanin ba2958ecd2 zwave_js: Add title tag to config box heading (#12387) 2022-04-22 13:17:42 -05:00
146 changed files with 6774 additions and 3803 deletions
+4 -2
View File
@@ -3,10 +3,10 @@ const webpack = require("webpack");
const path = require("path");
const TerserPlugin = require("terser-webpack-plugin");
const { WebpackManifestPlugin } = require("webpack-manifest-plugin");
const paths = require("./paths.js");
const bundle = require("./bundle.js");
const log = require("fancy-log");
const WebpackBar = require("webpackbar");
const paths = require("./paths.js");
const bundle = require("./bundle.js");
class LogStartCompilePlugin {
ignoredFirst = false;
@@ -138,6 +138,8 @@ const createWebpackConfig = ({
"lit/directives/cache$": "lit/directives/cache.js",
"lit/directives/repeat$": "lit/directives/repeat.js",
"lit/polyfill-support$": "lit/polyfill-support.js",
"@lit-labs/virtualizer/layouts/grid":
"@lit-labs/virtualizer/layouts/grid.js",
},
},
output: {
@@ -62,6 +62,45 @@ const ACTIONS = [
entity_id: "input_boolean.toggle_4",
},
},
{
parallel: [
{ scene: "scene.kitchen_morning" },
{
service: "media_player.play_media",
target: { entity_id: "media_player.living_room" },
data: { media_content_id: "", media_content_type: "" },
metadata: { title: "Happy Song" },
},
],
},
{
stop: "No one is home!",
},
{ repeat: { count: 3, sequence: [{ delay: "00:00:01" }] } },
{
repeat: {
for_each: ["bread", "butter", "cheese"],
sequence: [{ delay: "00:00:01" }],
},
},
{
if: [{ condition: "state" }],
then: [{ delay: "00:00:01" }],
else: [{ delay: "00:00:05" }],
},
{
choose: [
{
conditions: [{ condition: "state" }],
sequence: [{ delay: "00:00:01" }],
},
{
conditions: [{ condition: "sun" }],
sequence: [{ delay: "00:00:05" }],
},
],
default: [{ delay: "00:00:03" }],
},
];
@customElement("demo-automation-describe-action")
@@ -20,6 +20,10 @@ import { HaWaitForTriggerAction } from "../../../../src/panels/config/automation
import { HaWaitAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-wait_template";
import { Action } from "../../../../src/data/script";
import { HaConditionAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-condition";
import { HaParallelAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-parallel";
import { HaIfAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-if";
import { HaStopAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-stop";
import { HaPlayMediaAction } from "../../../../src/panels/config/automation/action/types/ha-automation-action-play_media";
const SCHEMAS: { name: string; actions: Action[] }[] = [
{ name: "Event", actions: [HaEventAction.defaultConfig] },
@@ -28,11 +32,15 @@ const SCHEMAS: { name: string; actions: Action[] }[] = [
{ name: "Condition", actions: [HaConditionAction.defaultConfig] },
{ name: "Delay", actions: [HaDelayAction.defaultConfig] },
{ name: "Scene", actions: [HaSceneAction.defaultConfig] },
{ name: "Play media", actions: [HaPlayMediaAction.defaultConfig] },
{ name: "Wait", actions: [HaWaitAction.defaultConfig] },
{ name: "WaitForTrigger", actions: [HaWaitForTriggerAction.defaultConfig] },
{ name: "Repeat", actions: [HaRepeatAction.defaultConfig] },
{ name: "If-Then", actions: [HaIfAction.defaultConfig] },
{ name: "Choose", actions: [HaChooseAction.defaultConfig] },
{ name: "Variables", actions: [{ variables: { hello: "1" } }] },
{ name: "Parallel", actions: [HaParallelAction.defaultConfig] },
{ name: "Stop", actions: [HaStopAction.defaultConfig] },
];
@customElement("demo-automation-editor-action")
@@ -86,6 +94,6 @@ class DemoHaAutomationEditorAction extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"demo-ha-automation-editor-action": DemoHaAutomationEditorAction;
"demo-automation-editor-action": DemoHaAutomationEditorAction;
}
}
@@ -8,7 +8,7 @@ import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import type { Condition } from "../../../../src/data/automation";
import type { ConditionWithShorthand } from "../../../../src/data/automation";
import "../../../../src/panels/config/automation/condition/ha-automation-condition";
import { HaDeviceCondition } from "../../../../src/panels/config/automation/condition/types/ha-automation-condition-device";
import { HaLogicalCondition } from "../../../../src/panels/config/automation/condition/types/ha-automation-condition-logical";
@@ -20,7 +20,7 @@ import { HaTimeCondition } from "../../../../src/panels/config/automation/condit
import { HaTriggerCondition } from "../../../../src/panels/config/automation/condition/types/ha-automation-condition-trigger";
import { HaZoneCondition } from "../../../../src/panels/config/automation/condition/types/ha-automation-condition-zone";
const SCHEMAS: { name: string; conditions: Condition[] }[] = [
const SCHEMAS: { name: string; conditions: ConditionWithShorthand[] }[] = [
{
name: "State",
conditions: [{ condition: "state", ...HaStateCondition.defaultConfig }],
@@ -69,6 +69,14 @@ const SCHEMAS: { name: string; conditions: Condition[] }[] = [
name: "Trigger",
conditions: [{ condition: "trigger", ...HaTriggerCondition.defaultConfig }],
},
{
name: "Shorthand",
conditions: [
{ and: HaLogicalCondition.defaultConfig.conditions },
{ or: HaLogicalCondition.defaultConfig.conditions },
{ not: HaLogicalCondition.defaultConfig.conditions },
],
},
];
@customElement("demo-automation-editor-condition")
@@ -17,7 +17,9 @@ import {
HassioAddonDetails,
} from "../../../src/data/hassio/addon";
import { extractApiErrorMessage } from "../../../src/data/hassio/common";
import { setSupervisorOption } from "../../../src/data/hassio/supervisor";
import { Supervisor } from "../../../src/data/supervisor/supervisor";
import { showConfirmationDialog } from "../../../src/dialogs/generic/show-dialog-box";
import "../../../src/layouts/hass-error-screen";
import "../../../src/layouts/hass-loading-screen";
import "../../../src/layouts/hass-tabs-subpage";
@@ -166,6 +168,42 @@ class HassioAddonDashboard extends LitElement {
protected async firstUpdated(): Promise<void> {
if (this.route.path === "") {
const requestedAddon = extractSearchParam("addon");
const requestedAddonRepository = extractSearchParam("repository_url");
if (
requestedAddonRepository &&
!this.supervisor.supervisor.addons_repositories.find(
(repo) => repo === requestedAddonRepository
)
) {
if (
!(await showConfirmationDialog(this, {
title: this.supervisor.localize("my.add_addon_repository_title"),
text: this.supervisor.localize(
"my.add_addon_repository_description",
{ addon: requestedAddon, repository: requestedAddonRepository }
),
confirmText: this.supervisor.localize("common.add"),
dismissText: this.supervisor.localize("common.cancel"),
}))
) {
this._error = this.supervisor.localize(
"my.error_repository_not_found"
);
return;
}
try {
await setSupervisorOption(this.hass, {
addons_repositories: [
...this.supervisor.supervisor.addons_repositories,
requestedAddonRepository,
],
});
} catch (err: any) {
this._error = extractApiErrorMessage(err);
}
}
if (requestedAddon) {
const addonsInfo = await fetchHassioAddonsInfo(this.hass);
const validAddon = addonsInfo.addons.some(
@@ -2,6 +2,7 @@ import "@material/mwc-button";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../src/components/ha-alert";
import "../../../../src/components/ha-ansi-to-html";
import "../../../../src/components/ha-card";
import {
fetchHassioAddonLogs,
@@ -11,7 +12,6 @@ import { extractApiErrorMessage } from "../../../../src/data/hassio/common";
import { Supervisor } from "../../../../src/data/supervisor/supervisor";
import { haStyle } from "../../../../src/resources/styles";
import { HomeAssistant } from "../../../../src/types";
import "../../components/hassio-ansi-to-html";
import { hassioStyle } from "../../resources/hassio-style";
@customElement("hassio-addon-logs")
@@ -40,9 +40,9 @@ class HassioAddonLogs extends LitElement {
: ""}
<div class="card-content">
${this._content
? html`<hassio-ansi-to-html
? html`<ha-ansi-to-html
.content=${this._content}
></hassio-ansi-to-html>`
></ha-ansi-to-html>`
: ""}
</div>
<div class="card-actions">
+13 -3
View File
@@ -1,7 +1,7 @@
import "@material/mwc-button";
import { ActionDetail } from "@material/mwc-list";
import "@material/mwc-list/mwc-list-item";
import { mdiDelete, mdiDotsVertical, mdiPlus } from "@mdi/js";
import { mdiBackupRestore, mdiDelete, mdiDotsVertical, mdiPlus } from "@mdi/js";
import {
css,
CSSResultGroup,
@@ -166,7 +166,15 @@ export class HassioBackups extends LitElement {
}
return html`
<hass-tabs-subpage-data-table
.tabs=${supervisorTabs(this.hass)}
.tabs=${atLeastVersion(this.hass.config.version, 2022, 5)
? [
{
translationKey: "panel.backups",
path: `/hassio/backups`,
iconPath: mdiBackupRestore,
},
]
: supervisorTabs(this.hass)}
.hass=${this.hass}
.localizeFunc=${this.supervisor.localize}
.searchLabel=${this.supervisor.localize("search")}
@@ -182,7 +190,9 @@ export class HassioBackups extends LitElement {
selectable
hasFab
.mainPage=${!atLeastVersion(this.hass.config.version, 2021, 12)}
back-path="/config"
back-path=${atLeastVersion(this.hass.config.version, 2022, 5)
? "/config/system"
: "/config"}
supervisor
>
<ha-button-menu
+32
View File
@@ -10,6 +10,7 @@ import { HomeAssistant, Route } from "../../../src/types";
import { supervisorTabs } from "../hassio-tabs";
import "./hassio-addons";
import "./hassio-update";
import "../../../src/layouts/hass-subpage";
@customElement("hassio-dashboard")
class HassioDashboard extends LitElement {
@@ -22,6 +23,31 @@ class HassioDashboard extends LitElement {
@property({ attribute: false }) public route!: Route;
protected render(): TemplateResult {
if (atLeastVersion(this.hass.config.version, 2022, 5)) {
return html`<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.header=${this.supervisor.localize("panel.addons")}
>
<hassio-addons
.hass=${this.hass}
.supervisor=${this.supervisor}
></hassio-addons>
<a href="/hassio/store">
<ha-fab
.label=${this.supervisor.localize("panel.store")}
extended
class="non-tabs"
>
<ha-svg-icon
slot="icon"
.path=${mdiStorePlus}
></ha-svg-icon> </ha-fab
></a>
</hass-subpage>`;
}
return html`
<hass-tabs-subpage
.hass=${this.hass}
@@ -74,6 +100,12 @@ class HassioDashboard extends LitElement {
.content {
margin: 0 auto;
}
ha-fab.non-tabs {
position: fixed;
right: calc(16px + env(safe-area-inset-right));
bottom: calc(16px + env(safe-area-inset-bottom));
z-index: 1;
}
`,
];
}
+13 -1
View File
@@ -3,8 +3,8 @@ import { customElement, property } from "lit/decorators";
import { atLeastVersion } from "../../src/common/config/version";
import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element";
import { fireEvent } from "../../src/common/dom/fire_event";
import { isNavigationClick } from "../../src/common/dom/is-navigation-click";
import { mainWindow } from "../../src/common/dom/get_main_window";
import { isNavigationClick } from "../../src/common/dom/is-navigation-click";
import { navigate } from "../../src/common/navigate";
import { HassioPanelInfo } from "../../src/data/hassio/supervisor";
import { Supervisor } from "../../src/data/supervisor/supervisor";
@@ -73,6 +73,18 @@ export class HassioMain extends SupervisorBaseElement {
});
});
// Forward keydown events to the main window for quickbar access
document.body.addEventListener("keydown", (ev: KeyboardEvent) => {
if (ev.altKey || ev.ctrlKey || ev.shiftKey || ev.metaKey) {
// Ignore if modifier keys are pressed
return;
}
// @ts-ignore
fireEvent(mainWindow, "hass-quick-bar-trigger", ev, {
bubbles: false,
});
});
makeDialogManager(this, this.shadowRoot!);
}
+12 -1
View File
@@ -15,7 +15,7 @@ import {
} from "../../src/panels/my/ha-panel-my";
import { HomeAssistant, Route } from "../../src/types";
const REDIRECTS: Redirects = {
export const REDIRECTS: Redirects = {
supervisor: {
redirect: "/hassio/dashboard",
},
@@ -42,6 +42,9 @@ const REDIRECTS: Redirects = {
params: {
addon: "string",
},
optional_params: {
repository_url: "url",
},
},
supervisor_ingress: {
redirect: "/hassio/ingress",
@@ -124,6 +127,14 @@ class HassioMyRedirect extends LitElement {
}
resultParams[key] = params[key];
});
Object.entries(redirect.optional_params || {}).forEach(([key, type]) => {
if (params[key]) {
if (!this._checkParamType(type, params[key])) {
throw Error();
}
resultParams[key] = params[key];
}
});
return `?${createSearchParam(resultParams)}`;
}
+24 -21
View File
@@ -8,24 +8,27 @@ import { atLeastVersion } from "../../src/common/config/version";
import type { PageNavigation } from "../../src/layouts/hass-tabs-subpage";
import { HomeAssistant } from "../../src/types";
export const supervisorTabs = (hass: HomeAssistant): PageNavigation[] => [
{
translationKey: atLeastVersion(hass.config.version, 2021, 12)
? "panel.addons"
: "panel.dashboard",
path: `/hassio/dashboard`,
iconPath: atLeastVersion(hass.config.version, 2021, 12)
? mdiPuzzle
: mdiViewDashboard,
},
{
translationKey: "panel.backups",
path: `/hassio/backups`,
iconPath: mdiBackupRestore,
},
{
translationKey: "panel.system",
path: `/hassio/system`,
iconPath: mdiCogs,
},
];
export const supervisorTabs = (hass: HomeAssistant): PageNavigation[] =>
atLeastVersion(hass.config.version, 2022, 5)
? []
: [
{
translationKey: atLeastVersion(hass.config.version, 2021, 12)
? "panel.addons"
: "panel.dashboard",
path: `/hassio/dashboard`,
iconPath: atLeastVersion(hass.config.version, 2021, 12)
? mdiPuzzle
: mdiViewDashboard,
},
{
translationKey: "panel.backups",
path: `/hassio/backups`,
iconPath: mdiBackupRestore,
},
{
translationKey: "panel.system",
path: `/hassio/system`,
iconPath: mdiCogs,
},
];
+4 -5
View File
@@ -23,6 +23,10 @@ import {
showAlertDialog,
showConfirmationDialog,
} from "../../../src/dialogs/generic/show-dialog-box";
import {
UNHEALTHY_REASON_URL,
UNSUPPORTED_REASON_URL,
} from "../../../src/panels/config/system-health/ha-config-system-health";
import { haStyle } from "../../../src/resources/styles";
import { HomeAssistant } from "../../../src/types";
import { bytesToString } from "../../../src/util/bytes-to-string";
@@ -30,11 +34,6 @@ import { documentationUrl } from "../../../src/util/documentation-url";
import "../components/supervisor-metric";
import { hassioStyle } from "../resources/hassio-style";
const UNSUPPORTED_REASON_URL = {};
const UNHEALTHY_REASON_URL = {
privileged: "/more-info/unsupported/privileged",
};
@customElement("hassio-supervisor-info")
class HassioSupervisorInfo extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
+3 -3
View File
@@ -1,3 +1,4 @@
import "../../../src/components/ha-ansi-to-html";
import "@material/mwc-button";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -11,7 +12,6 @@ import { Supervisor } from "../../../src/data/supervisor/supervisor";
import "../../../src/layouts/hass-loading-screen";
import { haStyle } from "../../../src/resources/styles";
import { HomeAssistant } from "../../../src/types";
import "../components/hassio-ansi-to-html";
import { hassioStyle } from "../resources/hassio-style";
interface LogProvider {
@@ -89,8 +89,8 @@ class HassioSupervisorLog extends LitElement {
<div class="card-content" id="content">
${this._content
? html`<hassio-ansi-to-html .content=${this._content}>
</hassio-ansi-to-html>`
? html`<ha-ansi-to-html .content=${this._content}>
</ha-ansi-to-html>`
: html`<hass-loading-screen no-toolbar></hass-loading-screen>`}
</div>
<div class="card-actions">
+1
View File
@@ -106,6 +106,7 @@
"deep-clone-simple": "^1.1.1",
"deep-freeze": "^0.0.1",
"fuse.js": "^6.0.0",
"fuzzysort": "^1.2.1",
"google-timezones-json": "^1.0.2",
"hls.js": "^1.1.5",
"home-assistant-js-websocket": "^7.0.3",
+1 -1
View File
@@ -1,6 +1,6 @@
[metadata]
name = home-assistant-frontend
version = 20220420.0
version = 20220429.0
author = The Home Assistant Authors
author_email = hello@home-assistant.io
license = Apache-2.0
+16
View File
@@ -0,0 +1,16 @@
import secondsToDuration from "./seconds_to_duration";
const DAY_IN_SECONDS = 86400;
const HOUR_IN_SECONDS = 3600;
const MINUTE_IN_SECONDS = 60;
export const UNIT_TO_SECOND_CONVERT = {
s: 1,
min: MINUTE_IN_SECONDS,
h: HOUR_IN_SECONDS,
d: DAY_IN_SECONDS,
};
export const formatDuration = (duration: string, units: string): string =>
secondsToDuration(parseFloat(duration) * UNIT_TO_SECOND_CONVERT[units]) ||
"0";
@@ -13,6 +13,7 @@ import { formatNumber, isNumericState } from "../number/format_number";
import { LocalizeFunc } from "../translations/localize";
import { computeStateDomain } from "./compute_state_domain";
import { supportsFeature } from "./supports-feature";
import { formatDuration, UNIT_TO_SECOND_CONVERT } from "../datetime/duration";
export const computeStateDisplay = (
localize: LocalizeFunc,
@@ -28,6 +29,21 @@ export const computeStateDisplay = (
// Entities with a `unit_of_measurement` or `state_class` are numeric values and should use `formatNumber`
if (isNumericState(stateObj)) {
// state is duration
if (
stateObj.attributes.device_class === "duration" &&
stateObj.attributes.unit_of_measurement &&
UNIT_TO_SECOND_CONVERT[stateObj.attributes.unit_of_measurement]
) {
try {
return formatDuration(
compareState,
stateObj.attributes.unit_of_measurement
);
} catch (_err) {
// fallback to default
}
}
if (stateObj.attributes.device_class === "monetary") {
try {
return formatNumber(compareState, locale, {
-244
View File
@@ -1,244 +0,0 @@
// 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,
}
-551
View File
@@ -1,551 +0,0 @@
/* 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[] = [];
for (let i = 0; i <= _maxLen; i++) {
row[i] = 0;
}
for (let i = 0; i <= _maxLen; i++) {
table.push(row.slice(0));
}
return table;
}
function isSeparatorAtPos(value: string, index: number): boolean {
if (index < 0 || index >= value.length) {
return false;
}
const code = value.codePointAt(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:
case CharCode.LessThan:
case CharCode.OpenParen:
case CharCode.OpenSquareBracket:
return true;
case undefined:
return false;
default:
if (isEmojiImprecise(code)) {
return true;
}
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];
}
export function isPatternInWord(
patternLow: string,
patternPos: number,
patternLen: number,
wordLow: string,
wordPos: number,
wordLen: number,
fillMinWordPosArr = false
): boolean {
while (patternPos < patternLen && wordPos < wordLen) {
if (patternLow[patternPos] === wordLow[wordPos]) {
if (fillMinWordPosArr) {
// Remember the min word position for each pattern position
_minWordMatchPos[patternPos] = wordPos;
}
patternPos += 1;
}
wordPos += 1;
}
return patternPos === patternLen; // pattern must be exhausted
}
enum Arrow {
Diag = 1,
Left = 2,
LeftLeft = 3,
}
/**
* An array representing a fuzzy match.
*
* 0. the score
* 1. the offset at which matching started
* 2. `<match_pos_N>`
* 3. `<match_pos_1>`
* 4. `<match_pos_0>` etc
*/
// export type FuzzyScore = [score: number, wordStart: number, ...matches: number[]];// [number, number, number];
export type FuzzyScore = Array<number>;
export function fuzzyScore(
pattern: string,
patternLow: string,
patternStart: number,
word: string,
wordLow: string,
wordStart: number,
firstMatchCanBeWeak: boolean
): FuzzyScore | undefined {
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,
true
)
) {
return undefined;
}
// Find the max matching word position for each pattern position
// NOTE: the min matching word position was filled in above, in the `isPatternInWord` call
_fillInMaxWordMatchPos(
patternLen,
wordLen,
patternStart,
wordStart,
patternLow,
wordLow
);
let row: number;
let column = 1;
let patternPos: number;
let wordPos: number;
const hasStrongFirstMatch = [false];
// There will be a match, fill in tables
for (
row = 1, patternPos = patternStart;
patternPos < patternLen;
row++, patternPos++
) {
// Reduce search space to possible matching word positions and to possible access from next row
const minWordMatchPos = _minWordMatchPos[patternPos];
const maxWordMatchPos = _maxWordMatchPos[patternPos];
const nextMaxWordMatchPos =
patternPos + 1 < patternLen ? _maxWordMatchPos[patternPos + 1] : wordLen;
for (
column = minWordMatchPos - wordStart + 1, wordPos = minWordMatchPos;
wordPos < nextMaxWordMatchPos;
column++, wordPos++
) {
let score = Number.MIN_SAFE_INTEGER;
let canComeDiag = false;
if (wordPos <= maxWordMatchPos) {
score = _doScore(
pattern,
patternLow,
patternPos,
patternStart,
word,
wordLow,
wordPos,
wordLen,
wordStart,
_diag[row - 1][column - 1] === 0,
hasStrongFirstMatch
);
}
let diagScore = 0;
if (score !== Number.MAX_SAFE_INTEGER) {
canComeDiag = true;
diagScore = score + _table[row - 1][column - 1];
}
const canComeLeft = wordPos > minWordMatchPos;
const leftScore = canComeLeft
? _table[row][column - 1] + (_diag[row][column - 1] > 0 ? -5 : 0)
: 0; // penalty for a gap start
const canComeLeftLeft =
wordPos > minWordMatchPos + 1 && _diag[row][column - 1] > 0;
const leftLeftScore = canComeLeftLeft
? _table[row][column - 2] + (_diag[row][column - 2] > 0 ? -5 : 0)
: 0; // penalty for a gap start
if (
canComeLeftLeft &&
(!canComeLeft || leftLeftScore >= leftScore) &&
(!canComeDiag || leftLeftScore >= diagScore)
) {
// always prefer choosing left left to jump over a diagonal because that means a match is earlier in the word
_table[row][column] = leftLeftScore;
_arrows[row][column] = Arrow.LeftLeft;
_diag[row][column] = 0;
} else if (canComeLeft && (!canComeDiag || leftScore >= diagScore)) {
// always prefer choosing left since that means a match is earlier in the word
_table[row][column] = leftScore;
_arrows[row][column] = Arrow.Left;
_diag[row][column] = 0;
} else if (canComeDiag) {
_table[row][column] = diagScore;
_arrows[row][column] = Arrow.Diag;
_diag[row][column] = _diag[row - 1][column - 1] + 1;
} else {
throw new Error(`not possible`);
}
}
}
if (_debug) {
printTables(pattern, patternStart, word, wordStart);
}
if (!hasStrongFirstMatch[0] && !firstMatchCanBeWeak) {
return undefined;
}
row--;
column--;
const result: FuzzyScore = [_table[row][column], wordStart];
let backwardsDiagLength = 0;
let maxMatchColumn = 0;
while (row >= 1) {
// Find the column where we go diagonally up
let diagColumn = column;
do {
const arrow = _arrows[row][diagColumn];
if (arrow === Arrow.LeftLeft) {
diagColumn -= 2;
} else if (arrow === Arrow.Left) {
diagColumn -= 1;
} else {
// found the diagonal
break;
}
} while (diagColumn >= 1);
// Overturn the "forwards" decision if keeping the "backwards" diagonal would give a better match
if (
backwardsDiagLength > 1 && // only if we would have a contiguous match of 3 characters
patternLow[patternStart + row - 1] === wordLow[wordStart + column - 1] && // only if we can do a contiguous match diagonally
!isUpperCaseAtPos(diagColumn + wordStart - 1, word, wordLow) && // only if the forwards chose diagonal is not an uppercase
backwardsDiagLength + 1 > _diag[row][diagColumn] // only if our contiguous match would be longer than the "forwards" contiguous match
) {
diagColumn = column;
}
if (diagColumn === column) {
// this is a contiguous match
backwardsDiagLength++;
} else {
backwardsDiagLength = 1;
}
if (!maxMatchColumn) {
// remember the last matched column
maxMatchColumn = diagColumn;
}
row--;
column = diagColumn - 1;
result.push(column);
}
if (wordLen === patternLen) {
// the word matches the pattern with all characters!
// giving the score a total match boost (to come up ahead other words)
result[0] += 2;
}
// Add 1 penalty for each skipped character in the word
const skippedCharsCount = maxMatchColumn - patternLen;
result[0] -= skippedCharsCount;
return result;
}
function _doScore(
pattern: string,
patternLow: string,
patternPos: number,
patternStart: number,
word: string,
wordLow: string,
wordPos: number,
wordLen: number,
wordStart: number,
newMatchStart: boolean,
outFirstMatchStrong: boolean[]
): number {
if (patternLow[patternPos] !== wordLow[wordPos]) {
return Number.MIN_SAFE_INTEGER;
}
let score = 1;
let isGapLocation = false;
if (wordPos === patternPos - patternStart) {
// common prefix: `foobar <-> foobaz`
// ^^^^^
score = pattern[patternPos] === word[wordPos] ? 7 : 5;
} else if (
isUpperCaseAtPos(wordPos, word, wordLow) &&
(wordPos === 0 || !isUpperCaseAtPos(wordPos - 1, word, wordLow))
) {
// hitting upper-case: `foo <-> forOthers`
// ^^ ^
score = pattern[patternPos] === word[wordPos] ? 7 : 5;
isGapLocation = true;
} else if (
isSeparatorAtPos(wordLow, wordPos) &&
(wordPos === 0 || !isSeparatorAtPos(wordLow, wordPos - 1))
) {
// hitting a separator: `. <-> foo.bar`
// ^
score = 5;
} else if (
isSeparatorAtPos(wordLow, wordPos - 1) ||
isWhitespaceAtPos(wordLow, wordPos - 1)
) {
// post separator: `foo <-> bar_foo`
// ^^^
score = 5;
isGapLocation = true;
}
if (score > 1 && patternPos === patternStart) {
outFirstMatchStrong[0] = true;
}
if (!isGapLocation) {
isGapLocation =
isUpperCaseAtPos(wordPos, word, wordLow) ||
isSeparatorAtPos(wordLow, wordPos - 1) ||
isWhitespaceAtPos(wordLow, wordPos - 1);
}
//
if (patternPos === patternStart) {
// first character in pattern
if (wordPos > wordStart) {
// the first pattern character would match a word character that is not at the word start
// so introduce a penalty to account for the gap preceding this match
score -= isGapLocation ? 3 : 5;
}
} else if (newMatchStart) {
// this would be the beginning of a new match (i.e. there would be a gap before this location)
score += isGapLocation ? 2 : 0;
} else {
// this is part of a contiguous match, so give it a slight bonus, but do so only if it would not be a prefered gap location
score += isGapLocation ? 0 : 1;
}
if (wordPos + 1 === wordLen) {
// we always penalize gaps, but this gives unfair advantages to a match that would match the last character in the word
// so pretend there is a gap after the last character in the word to normalize things
score -= isGapLocation ? 3 : 5;
}
return score;
}
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
): void {
pattern = pattern.substr(patternStart);
word = word.substr(wordStart);
console.log(printTable(_table, pattern, pattern.length, word, word.length));
console.log(printTable(_arrows, pattern, pattern.length, word, word.length));
console.log(printTable(_diag, pattern, pattern.length, word, word.length));
}
const _minWordMatchPos = initArr(2 * _maxLen); // min word position for a certain pattern position
const _maxWordMatchPos = initArr(2 * _maxLen); // max word position for a certain pattern position
const _diag = initTable(); // the length of a contiguous diagonal match
const _table = initTable();
const _arrows = <Arrow[][]>initTable();
function initArr(maxLen: number) {
const row: number[] = [];
for (let i = 0; i <= maxLen; i++) {
row[i] = 0;
}
return row;
}
function _fillInMaxWordMatchPos(
patternLen: number,
wordLen: number,
patternStart: number,
wordStart: number,
patternLow: string,
wordLow: string
) {
let patternPos = patternLen - 1;
let wordPos = wordLen - 1;
while (patternPos >= patternStart && wordPos >= wordStart) {
if (patternLow[patternPos] === wordLow[wordPos]) {
_maxWordMatchPos[patternPos] = wordPos;
patternPos--;
}
wordPos--;
}
}
export interface FuzzyScorer {
(
pattern: string,
lowPattern: string,
patternPos: number,
word: string,
lowWord: string,
wordPos: number,
firstMatchCanBeWeak: boolean
): FuzzyScore | undefined;
}
export function createMatches(score: undefined | FuzzyScore): Match[] {
if (typeof score === "undefined") {
return [];
}
const res: Match[] = [];
const wordPos = score[1];
for (let i = score.length - 1; i > 1; i--) {
const pos = score[i] + wordPos;
const last = res[res.length - 1];
if (last && last.end === pos) {
last.end = pos + 1;
} else {
res.push({ start: pos, end: pos + 1 });
}
}
return res;
}
/**
* A fast function (therefore imprecise) to check if code points are emojis.
* Generated using https://github.com/alexdima/unicode-utils/blob/master/generate-emoji-test.js
*/
export function isEmojiImprecise(x: number): boolean {
return (
(x >= 0x1f1e6 && x <= 0x1f1ff) ||
x === 8986 ||
x === 8987 ||
x === 9200 ||
x === 9203 ||
(x >= 9728 && x <= 10175) ||
x === 11088 ||
x === 11093 ||
(x >= 127744 && x <= 128591) ||
(x >= 128640 && x <= 128764) ||
(x >= 128992 && x <= 129003) ||
(x >= 129280 && x <= 129535) ||
(x >= 129648 && x <= 129750)
);
}
+36 -54
View File
@@ -1,52 +1,4 @@
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 {ScorableTextItem} item - Item against whose strings will be checked
*
* @return {number} Score representing how well the word matches the filter. Return of 0 means no match.
*/
export const fuzzySequentialMatch = (
filter: string,
item: ScorableTextItem
) => {
let topScore = Number.NEGATIVE_INFINITY;
for (const word of item.strings) {
const scores = fuzzyScore(
filter,
filter.toLowerCase(),
0,
word,
word.toLowerCase(),
0,
true
);
if (!scores) {
continue;
}
// The VS Code implementation of filter returns a 0 for a weak match.
// But if .filter() sees a "0", it considers that a failed match and will remove it.
// So, we set score to 1 in these cases so the match will be included, and mostly respect correct ordering.
const score = scores[0] === 0 ? 1 : scores[0];
if (score > topScore) {
topScore = score;
}
}
if (topScore === Number.NEGATIVE_INFINITY) {
return undefined;
}
return topScore;
};
import fuzzysort from "fuzzysort";
/**
* An interface that objects must extend in order to use the fuzzy sequence matcher
@@ -66,18 +18,48 @@ export interface ScorableTextItem {
strings: string[];
}
type FuzzyFilterSort = <T extends ScorableTextItem>(
export type FuzzyFilterSort = <T extends ScorableTextItem>(
filter: string,
items: T[]
) => T[];
export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) =>
items
export function fuzzyMatcher(search: string | null): (string) => boolean {
const scorer = fuzzyScorer(search);
return (value: string) => scorer([value]) !== Number.NEGATIVE_INFINITY;
}
export function fuzzyScorer(
search: string | null
): (values: string[]) => number {
const searchTerms = (search || "").match(/("[^"]+"|[^"\s]+)/g);
if (!searchTerms) {
return () => 0;
}
return (values) =>
searchTerms
.map((term) => {
const resultsForTerm = fuzzysort.go(term, values, {
allowTypo: true,
});
if (resultsForTerm.length > 0) {
return Math.max(...resultsForTerm.map((result) => result.score));
}
return Number.NEGATIVE_INFINITY;
})
.reduce((partial, current) => partial + current, 0);
}
export const fuzzySortFilterSort: FuzzyFilterSort = (filter, items) => {
const scorer = fuzzyScorer(filter);
return items
.map((item) => {
item.score = fuzzySequentialMatch(filter, item);
item.score = scorer(item.strings);
return item;
})
.filter((item) => item.score !== undefined)
.filter((item) => item.score !== undefined && item.score > -100000)
.sort(({ score: scoreA = 0 }, { score: scoreB = 0 }) =>
scoreA > scoreB ? -1 : scoreA < scoreB ? 1 : 0
);
};
export const defaultFuzzyFilterSort = fuzzySortFilterSort;
+1 -1
View File
@@ -1,4 +1,4 @@
export const promiseTimeout = (ms: number, promise: Promise<any>) => {
export const promiseTimeout = (ms: number, promise: Promise<any> | any) => {
const timeout = new Promise((_resolve, reject) => {
setTimeout(() => {
reject(`Timed out in ${ms} ms.`);
+18
View File
@@ -0,0 +1,18 @@
import { HomeAssistant } from "../../types";
export const subscribePollingCollection = (
hass: HomeAssistant,
updateData: (hass: HomeAssistant) => void,
interval: number
) => {
let timeout;
const fetchData = async () => {
try {
await updateData(hass);
} finally {
timeout = setTimeout(() => fetchData(), interval);
}
};
fetchData();
return () => clearTimeout(timeout);
};
+161 -159
View File
@@ -1,165 +1,167 @@
export const currencies = [
"AED",
"AFN",
"ALL",
"AMD",
"ANG",
"AOA",
"ARS",
"AUD",
"AWG",
"AZN",
"BAM",
"BBD",
"BDT",
"BGN",
"BHD",
"BIF",
"BMD",
"BND",
"BOB",
"BRL",
"BSD",
"BTN",
"BWP",
"BYN",
"BYR",
"BZD",
"CAD",
"CDF",
"CHF",
"CLP",
"CNY",
"COP",
"CRC",
"CUP",
"CVE",
"CZK",
"DJF",
"DKK",
"DOP",
"DZD",
"EGP",
"ERN",
"ETB",
"EUR",
"FJD",
"FKP",
"GBP",
"GEL",
"GHS",
"GIP",
"GMD",
"GNF",
"GTQ",
"GYD",
"HKD",
"HNL",
"HRK",
"HTG",
"HUF",
"IDR",
"ILS",
"INR",
"IQD",
"IRR",
"ISK",
"JMD",
"JOD",
"JPY",
"KES",
"KGS",
"KHR",
"KMF",
"KPW",
"KRW",
"KWD",
"KYD",
"KZT",
"LAK",
"LBP",
"LKR",
"LRD",
"LSL",
"LTL",
"LYD",
"MAD",
"MDL",
"MGA",
"MKD",
"MMK",
"MNT",
"MOP",
"MRO",
"MUR",
"MVR",
"MWK",
"MXN",
"MYR",
"MZN",
"NAD",
"NGN",
"NIO",
"NOK",
"NPR",
"NZD",
"OMR",
"PAB",
"PEN",
"PGK",
"PHP",
"PKR",
"PLN",
"PYG",
"QAR",
"RON",
"RSD",
"RUB",
"RWF",
"SAR",
"SBD",
"SCR",
"SDG",
"SEK",
"SGD",
"SHP",
"SLL",
"SOS",
"SRD",
"SSP",
"STD",
"SYP",
"SZL",
"THB",
"TJS",
"TMT",
"TND",
"TOP",
"TRY",
"TTD",
"TWD",
"TZS",
"UAH",
"UGX",
"USD",
"UYU",
"UZS",
"VEF",
"VND",
"VUV",
"WST",
"XAF",
"XCD",
"XOF",
"XPF",
"YER",
"ZAR",
"ZMK",
"ZWL",
];
export const createCurrencyListEl = () => {
const list = document.createElement("datalist");
list.id = "currencies";
for (const currency of [
"AED",
"AFN",
"ALL",
"AMD",
"ANG",
"AOA",
"ARS",
"AUD",
"AWG",
"AZN",
"BAM",
"BBD",
"BDT",
"BGN",
"BHD",
"BIF",
"BMD",
"BND",
"BOB",
"BRL",
"BSD",
"BTN",
"BWP",
"BYN",
"BYR",
"BZD",
"CAD",
"CDF",
"CHF",
"CLP",
"CNY",
"COP",
"CRC",
"CUP",
"CVE",
"CZK",
"DJF",
"DKK",
"DOP",
"DZD",
"EGP",
"ERN",
"ETB",
"EUR",
"FJD",
"FKP",
"GBP",
"GEL",
"GHS",
"GIP",
"GMD",
"GNF",
"GTQ",
"GYD",
"HKD",
"HNL",
"HRK",
"HTG",
"HUF",
"IDR",
"ILS",
"INR",
"IQD",
"IRR",
"ISK",
"JMD",
"JOD",
"JPY",
"KES",
"KGS",
"KHR",
"KMF",
"KPW",
"KRW",
"KWD",
"KYD",
"KZT",
"LAK",
"LBP",
"LKR",
"LRD",
"LSL",
"LTL",
"LYD",
"MAD",
"MDL",
"MGA",
"MKD",
"MMK",
"MNT",
"MOP",
"MRO",
"MUR",
"MVR",
"MWK",
"MXN",
"MYR",
"MZN",
"NAD",
"NGN",
"NIO",
"NOK",
"NPR",
"NZD",
"OMR",
"PAB",
"PEN",
"PGK",
"PHP",
"PKR",
"PLN",
"PYG",
"QAR",
"RON",
"RSD",
"RUB",
"RWF",
"SAR",
"SBD",
"SCR",
"SDG",
"SEK",
"SGD",
"SHP",
"SLL",
"SOS",
"SRD",
"SSP",
"STD",
"SYP",
"SZL",
"THB",
"TJS",
"TMT",
"TND",
"TOP",
"TRY",
"TTD",
"TWD",
"TZS",
"UAH",
"UGX",
"USD",
"UYU",
"UZS",
"VEF",
"VND",
"VUV",
"WST",
"XAF",
"XCD",
"XOF",
"XPF",
"YER",
"ZAR",
"ZMK",
"ZWL",
]) {
for (const currency of currencies) {
const option = document.createElement("option");
option.value = currency;
option.innerHTML = currency;
@@ -7,25 +7,26 @@ import type {
SortableColumnContainer,
SortingDirection,
} from "./ha-data-table";
import { fuzzyMatcher } from "../../common/string/filter/sequence-matching";
const filterData = (
data: DataTableRowData[],
columns: SortableColumnContainer,
filter: string
) => {
filter = filter.toUpperCase();
const matcher = fuzzyMatcher(filter);
return data.filter((row) =>
Object.entries(columns).some((columnEntry) => {
const [key, column] = columnEntry;
if (column.filterable) {
if (
String(
column.filterKey
? row[column.valueColumn || key][column.filterKey]
: row[column.valueColumn || key]
matcher(
String(
column.filterKey
? row[column.valueColumn || key][column.filterKey]
: row[column.valueColumn || key]
)
)
.toUpperCase()
.includes(filter)
) {
return true;
}
@@ -5,6 +5,7 @@ import { fireEvent } from "../../common/dom/fire_event";
import {
DeviceAutomation,
deviceAutomationsEqual,
sortDeviceAutomations,
} from "../../data/device_automation";
import { HomeAssistant } from "../../types";
import "../ha-select";
@@ -127,7 +128,9 @@ export abstract class HaDeviceAutomationPicker<
private async _updateDeviceInfo() {
this._automations = this.deviceId
? await this._fetchDeviceAutomations(this.hass, this.deviceId)
? (await this._fetchDeviceAutomations(this.hass, this.deviceId)).sort(
sortDeviceAutomations
)
: // No device, clear the list of automations
[];
@@ -161,8 +164,9 @@ export abstract class HaDeviceAutomationPicker<
if (this.value && deviceAutomationsEqual(automation, this.value)) {
return;
}
fireEvent(this, "change");
fireEvent(this, "value-changed", { value: automation });
const value = { ...automation };
delete value.metadata;
fireEvent(this, "value-changed", { value });
}
static get styles(): CSSResultGroup {
+4 -3
View File
@@ -198,9 +198,10 @@ export class HaDevicePicker extends SubscribeMixin(LitElement) {
this.hass,
deviceEntityLookup[device.id]
),
area: device.area_id
? areaLookup[device.area_id].name
: this.hass.localize("ui.components.device-picker.no_area"),
area:
device.area_id && areaLookup[device.area_id]
? areaLookup[device.area_id].name
: this.hass.localize("ui.components.device-picker.no_area"),
}));
if (!outputDevices.length) {
return [
+13 -5
View File
@@ -15,6 +15,7 @@ import type { HaComboBox } from "../ha-combo-box";
import "../ha-icon-button";
import "../ha-svg-icon";
import "./state-badge";
import { defaultFuzzyFilterSort } from "../../common/string/filter/sequence-matching";
interface HassEntityWithCachedName extends HassEntity {
friendly_name: string;
@@ -336,11 +337,18 @@ export class HaEntityPicker extends LitElement {
}
private _filterChanged(ev: CustomEvent): void {
const filterString = ev.detail.value.toLowerCase();
(this.comboBox as any).filteredItems = this._states.filter(
(entityState) =>
entityState.entity_id.toLowerCase().includes(filterString) ||
computeStateName(entityState).toLowerCase().includes(filterString)
const filterString = ev.detail.value;
const sortableEntityStates = this._states.map((entityState) => ({
strings: [entityState.entity_id, computeStateName(entityState)],
entityState: entityState,
}));
const sortedEntityStates = defaultFuzzyFilterSort(
filterString,
sortableEntityStates
);
(this.comboBox as any).filteredItems = sortedEntityStates.map(
(sortableItem) => sortableItem.entityState
);
}
@@ -10,8 +10,8 @@ interface State {
backgroundColor: null | string;
}
@customElement("hassio-ansi-to-html")
class HassioAnsiToHtml extends LitElement {
@customElement("ha-ansi-to-html")
class HaAnsiToHtml extends LitElement {
@property() public content!: string;
protected render(): TemplateResult | void {
@@ -241,6 +241,6 @@ class HassioAnsiToHtml extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"hassio-ansi-to-html": HassioAnsiToHtml;
"ha-ansi-to-html": HaAnsiToHtml;
}
}
+1 -1
View File
@@ -409,7 +409,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) {
name,
});
this._areas = [...this._areas!, area];
(this.comboBox as any).items = this._getAreas(
(this.comboBox as any).filteredItems = this._getAreas(
this._areas!,
this._devices!,
this._entities!,
+1
View File
@@ -310,6 +310,7 @@ export class HaBaseTimeInput extends LitElement {
border-radius: var(--mdc-shape-small, 4px) var(--mdc-shape-small, 4px) 0 0;
overflow: hidden;
position: relative;
direction: ltr;
}
ha-textfield {
width: 40px;
+9 -1
View File
@@ -12,6 +12,8 @@ export class HaClickableListItem extends ListItemBase {
// property used only in css
@property({ type: Boolean, reflect: true }) public rtl = false;
@property({ type: Boolean, reflect: true }) public openNewTab = false;
@query("a") private _anchor!: HTMLAnchorElement;
public render() {
@@ -20,7 +22,12 @@ export class HaClickableListItem extends ListItemBase {
return html`${this.disableHref
? html`<a aria-role="option">${r}</a>`
: html`<a aria-role="option" href=${href}>${r}</a>`}`;
: html`<a
aria-role="option"
target=${this.openNewTab ? "_blank" : ""}
href=${href}
>${r}</a
>`}`;
}
firstUpdated() {
@@ -55,6 +62,7 @@ export class HaClickableListItem extends ListItemBase {
align-items: center;
padding-left: var(--mdc-list-side-padding, 20px);
padding-right: var(--mdc-list-side-padding, 20px);
overflow: hidden;
}
`,
];
+5
View File
@@ -132,6 +132,11 @@ export class HaFormString extends LitElement implements HaFormElement {
--mdc-icon-button-size: 24px;
color: var(--secondary-text-color);
}
:host-context([style*="direction: rtl;"]) ha-icon-button {
right: auto;
left: 12px;
}
`;
}
}
+79
View File
@@ -0,0 +1,79 @@
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { roundWithOneDecimal } from "../util/calculate";
import "./ha-bar";
import "./ha-settings-row";
@customElement("ha-metric")
class HaMetric extends LitElement {
@property({ type: Number }) public value!: number;
@property({ type: String }) public heading!: string;
@property({ type: String }) public tooltip?: string;
protected render(): TemplateResult {
const roundedValue = roundWithOneDecimal(this.value);
return html`
<ha-settings-row>
<span slot="heading"> ${this.heading} </span>
<div slot="description" .title=${this.tooltip ?? ""}>
<span class="value"> ${roundedValue} % </span>
<ha-bar
class=${classMap({
"target-warning": roundedValue > 50,
"target-critical": roundedValue > 85,
})}
.value=${this.value}
></ha-bar>
</div>
</ha-settings-row>
`;
}
static get styles(): CSSResultGroup {
return css`
ha-settings-row {
padding: 0;
height: 54px;
width: 100%;
}
ha-settings-row > div[slot="description"] {
white-space: normal;
color: var(--secondary-text-color);
display: flex;
justify-content: space-between;
}
ha-bar {
--ha-bar-primary-color: var(
--metric-bar-ok-color,
var(--success-color)
);
}
.target-warning {
--ha-bar-primary-color: var(
--metric-bar-warning-color,
var(--warning-color)
);
}
.target-critical {
--ha-bar-primary-color: var(
--metric-bar-critical-color,
var(--error-color)
);
}
.value {
width: 48px;
padding-right: 4px;
flex-shrink: 0;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-metric": HaMetric;
}
}
+6 -8
View File
@@ -4,9 +4,9 @@ import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../types";
import "./ha-clickable-list-item";
import "./ha-icon-next";
import "./ha-svg-icon";
import "./ha-clickable-list-item";
@customElement("ha-navigation-list")
class HaNavigationList extends LitElement {
@@ -56,18 +56,15 @@ class HaNavigationList extends LitElement {
}
static styles: CSSResultGroup = css`
a {
text-decoration: none;
color: var(--primary-text-color);
position: relative;
display: block;
outline: 0;
:host {
--mdc-list-vertical-padding: 0;
}
ha-svg-icon,
ha-icon-next {
color: var(--secondary-text-color);
height: 24px;
width: 24px;
display: block;
}
ha-svg-icon {
padding: 8px;
@@ -78,9 +75,10 @@ class HaNavigationList extends LitElement {
.icon-background ha-svg-icon {
color: #fff;
}
mwc-list-item {
ha-clickable-list-item {
cursor: pointer;
font-size: var(--navigation-list-item-title-font-size);
padding: var(--navigation-list-item-padding) 0;
}
`;
}
+3
View File
@@ -163,6 +163,9 @@ export class HaNetwork extends LitElement {
ha-settings-row {
padding: 0;
--paper-time-input-justify-content: flex-end;
--settings-row-content-display: contents;
--settings-row-prefix-display: contents;
}
span[slot="heading"],
@@ -27,8 +27,8 @@ export class HaColorTempSelector extends LitElement {
pin
icon="hass:thermometer"
.caption=${this.label || ""}
.min=${this.selector.color_temp.min_mireds ?? 153}
.max=${this.selector.color_temp.max_mireds ?? 500}
.min=${this.selector.color_temp?.min_mireds ?? 153}
.max=${this.selector.color_temp?.max_mireds ?? 500}
.value=${this.value}
.disabled=${this.disabled}
.helper=${this.helper}
@@ -1,4 +1,4 @@
import { html, LitElement } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
@@ -76,6 +76,13 @@ export class HaLocationSelector extends LitElement {
const radius = ev.detail.radius;
fireEvent(this, "value-changed", { value: { ...this.value, radius } });
}
static styles = css`
:host {
display: block;
height: 400px;
}
`;
}
declare global {
@@ -107,6 +107,7 @@ export class HaNumberSelector extends LitElement {
display: flex;
justify-content: space-between;
align-items: center;
direction: ltr;
}
ha-slider {
flex: 1;
+1
View File
@@ -472,6 +472,7 @@ export class HaServiceControl extends LitElement {
ha-settings-row {
--paper-time-input-justify-content: flex-end;
--settings-row-content-width: 100%;
--settings-row-prefix-display: contents;
border-top: var(
--service-control-items-border-top,
1px solid var(--divider-color)
+2 -2
View File
@@ -47,7 +47,7 @@ export class HaSettingsRow extends LitElement {
display: contents;
}
:host(:not([narrow])) .content {
display: flex;
display: var(--settings-row-content-display, flex);
justify-content: flex-end;
flex: 1;
padding: 16px 0;
@@ -68,7 +68,7 @@ export class HaSettingsRow extends LitElement {
white-space: normal;
}
.prefix-wrap {
display: contents;
display: var(--settings-row-prefix-display);
}
:host([narrow]) .prefix-wrap {
display: flex;
-3
View File
@@ -1051,9 +1051,6 @@ class HaSidebar extends LitElement {
padding: 0px 6px;
color: var(--text-accent-color, var(--text-primary-color));
}
.configuration-badge {
background-color: var(--primary-color);
}
ha-svg-icon + .notification-badge,
ha-svg-icon + .configuration-badge {
position: absolute;
@@ -302,6 +302,10 @@ class DialogMediaManage extends LitElement {
--mdc-theme-primary: var(--mdc-theme-on-primary);
}
mwc-list {
direction: ltr;
}
.danger {
--mdc-theme-primary: var(--error-color);
}
@@ -310,6 +314,11 @@ class DialogMediaManage extends LitElement {
vertical-align: middle;
}
:host-context([style*="direction: rtl;"]) ha-svg-icon[slot="icon"] {
margin-left: 8px !important;
margin-right: 0px !important;
}
.refresh {
display: flex;
height: 200px;
@@ -151,6 +151,8 @@ class DialogMediaPlayerBrowse extends LitElement {
ha-media-player-browse {
--media-browser-max-height: calc(100vh - 65px);
height: calc(100vh - 65px);
direction: ltr;
}
@media (min-width: 800px) {
@@ -163,6 +165,7 @@ class DialogMediaPlayerBrowse extends LitElement {
ha-media-player-browse {
position: initial;
--media-browser-max-height: 100vh - 137px;
height: 100vh - 137px;
width: 700px;
}
}
@@ -59,6 +59,11 @@ class MediaManageButton extends LitElement {
ha-circular-progress[slot="icon"] {
vertical-align: middle;
}
:host-context([style*="direction: rtl;"]) ha-svg-icon[slot="icon"] {
margin-left: 8px;
margin-right: 0px;
}
`;
}
@@ -3,6 +3,8 @@ import "@material/mwc-list/mwc-list";
import "@material/mwc-list/mwc-list-item";
import { mdiArrowUpRight, mdiPlay, mdiPlus } from "@mdi/js";
import "@polymer/paper-tooltip/paper-tooltip";
import { grid } from "@lit-labs/virtualizer/layouts/grid";
import "@lit-labs/virtualizer";
import {
css,
CSSResultGroup,
@@ -16,16 +18,13 @@ import {
eventOptions,
property,
query,
queryAll,
state,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { until } from "lit/directives/until";
import { fireEvent } from "../../common/dom/fire_event";
import { computeRTLDirection } from "../../common/util/compute_rtl";
import { debounce } from "../../common/util/debounce";
import { getSignedPath } from "../../data/auth";
import type { MediaPlayerItem } from "../../data/media-player";
import {
browseMediaPlayer,
@@ -40,18 +39,18 @@ import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { installResizeObserver } from "../../panels/lovelace/common/install-resize-observer";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { brandsUrl, extractDomainFromBrandUrl } from "../../util/brands-url";
import { documentationUrl } from "../../util/documentation-url";
import "../entity/ha-entity-picker";
import "../ha-button-menu";
import "../ha-card";
import type { HaCard } from "../ha-card";
import "../ha-circular-progress";
import "../ha-fab";
import "../ha-icon-button";
import "../ha-svg-icon";
import "./ha-browse-media-tts";
import type { TtsMediaPickedEvent } from "./ha-browse-media-tts";
import { getSignedPath } from "../../data/auth";
import { brandsUrl, extractDomainFromBrandUrl } from "../../util/brands-url";
declare global {
interface HASSDomEvents {
@@ -101,8 +100,6 @@ export class HaMediaPlayerBrowse extends LitElement {
@query(".content") private _content?: HTMLDivElement;
@queryAll(".lazythumbnail") private _thumbnails?: HaCard[];
private _headerOffsetHeight = 0;
private _resizeObserver?: ResizeObserver;
@@ -148,326 +145,6 @@ export class HaMediaPlayerBrowse extends LitElement {
}
}
protected render(): TemplateResult {
if (this._error) {
return html`
<div class="container">${this._renderError(this._error)}</div>
`;
}
if (!this._currentItem) {
return html`<ha-circular-progress active></ha-circular-progress>`;
}
const currentItem = this._currentItem;
const subtitle = this.hass.localize(
`ui.components.media-browser.class.${currentItem.media_class}`
);
const children = currentItem.children || [];
const mediaClass = MediaClassBrowserSettings[currentItem.media_class];
const childrenMediaClass = currentItem.children_media_class
? MediaClassBrowserSettings[currentItem.children_media_class]
: MediaClassBrowserSettings.directory;
return html`
${
currentItem.can_play
? html` <div
class="header ${classMap({
"no-img": !currentItem.thumbnail,
"no-dialog": !this.dialog,
})}"
@transitionend=${this._setHeaderHeight}
>
<div class="header-content">
${currentItem.thumbnail
? html`
<div
class="img"
style=${styleMap({
backgroundImage: currentItem.thumbnail
? `url(${currentItem.thumbnail})`
: "none",
})}
>
${this._narrow && currentItem?.can_play
? html`
<ha-fab
mini
.item=${currentItem}
@click=${this._actionClicked}
>
<ha-svg-icon
slot="icon"
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${this.action === "play"
? mdiPlay
: mdiPlus}
></ha-svg-icon>
${this.hass.localize(
`ui.components.media-browser.${this.action}`
)}
</ha-fab>
`
: ""}
</div>
`
: html``}
<div class="header-info">
<div class="breadcrumb">
<h1 class="title">${currentItem.title}</h1>
${subtitle
? html` <h2 class="subtitle">${subtitle}</h2> `
: ""}
</div>
${currentItem.can_play &&
(!currentItem.thumbnail || !this._narrow)
? html`
<mwc-button
raised
.item=${currentItem}
@click=${this._actionClicked}
>
<ha-svg-icon
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${this.action === "play"
? mdiPlay
: mdiPlus}
></ha-svg-icon>
${this.hass.localize(
`ui.components.media-browser.${this.action}`
)}
</mwc-button>
`
: ""}
</div>
</div>
</div>`
: ""
}
<div
class="content"
@scroll=${this._scroll}
@touchmove=${this._scroll}
>
${
this._error
? html`
<div class="container">
${this._renderError(this._error)}
</div>
`
: isTTSMediaSource(currentItem.media_content_id)
? html`
<ha-browse-media-tts
.item=${currentItem}
.hass=${this.hass}
.action=${this.action}
@tts-picked=${this._ttsPicked}
></ha-browse-media-tts>
`
: !children.length && !currentItem.not_shown
? html`
<div class="container no-items">
${currentItem.media_content_id ===
"media-source://media_source/local/."
? html`
<div class="highlight-add-button">
<span>
<ha-svg-icon
.path=${mdiArrowUpRight}
></ha-svg-icon>
</span>
<span>
${this.hass.localize(
"ui.components.media-browser.file_management.highlight_button"
)}
</span>
</div>
`
: this.hass.localize(
"ui.components.media-browser.no_items"
)}
</div>
`
: childrenMediaClass.layout === "grid"
? html`
<div
class="children ${classMap({
portrait:
childrenMediaClass.thumbnail_ratio === "portrait",
})}"
>
${children.map(
(child) => html`
<div
class="child"
.item=${child}
@click=${this._childClicked}
>
<ha-card outlined>
<div class="thumbnail">
${child.thumbnail
? html`
<div
class="${["app", "directory"].includes(
child.media_class
)
? "centered-image"
: ""} image lazythumbnail"
data-src=${child.thumbnail}
></div>
`
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${MediaClassBrowserSettings[
child.media_class === "directory"
? child.children_media_class ||
child.media_class
: child.media_class
].icon}
></ha-svg-icon>
</div>
`}
${child.can_play
? html`
<ha-icon-button
class="play ${classMap({
can_expand: child.can_expand,
})}"
.item=${child}
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${this.action === "play"
? mdiPlay
: mdiPlus}
@click=${this._actionClicked}
></ha-icon-button>
`
: ""}
</div>
<div class="title">
${child.title}
<paper-tooltip
fitToVisibleBounds
position="top"
offset="4"
>${child.title}</paper-tooltip
>
</div>
</ha-card>
</div>
`
)}
${currentItem.not_shown
? html`
<div class="grid not-shown">
<div class="title">
${this.hass.localize(
"ui.components.media-browser.not_shown",
{ count: currentItem.not_shown }
)}
</div>
</div>
`
: ""}
</div>
`
: html`
<mwc-list>
${children.map(
(child) => html`
<mwc-list-item
@click=${this._childClicked}
.item=${child}
.graphic=${mediaClass.show_list_images
? "medium"
: "avatar"}
dir=${computeRTLDirection(this.hass)}
>
<div
class=${classMap({
graphic: true,
lazythumbnail:
mediaClass.show_list_images === true,
})}
data-src=${ifDefined(
mediaClass.show_list_images && child.thumbnail
? child.thumbnail
: undefined
)}
slot="graphic"
>
<ha-icon-button
class="play ${classMap({
show:
!mediaClass.show_list_images ||
!child.thumbnail,
})}"
.item=${child}
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${this.action === "play"
? mdiPlay
: mdiPlus}
@click=${this._actionClicked}
></ha-icon-button>
</div>
<span class="title">${child.title}</span>
</mwc-list-item>
<li divider role="separator"></li>
`
)}
${currentItem.not_shown
? html`
<mwc-list-item
noninteractive
class="not-shown"
.graphic=${mediaClass.show_list_images
? "medium"
: "avatar"}
dir=${computeRTLDirection(this.hass)}
>
<span class="title">
${this.hass.localize(
"ui.components.media-browser.not_shown",
{ count: currentItem.not_shown }
)}
</span>
</mwc-list-item>
`
: ""}
</mwc-list>
`
}
</div>
</div>
</div>
`;
}
protected firstUpdated(): void {
this._measureCard();
this._attachResizeObserver();
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (changedProps.size > 1 || !changedProps.has("hass")) {
return true;
}
const oldHass = changedProps.get("hass") as this["hass"];
return oldHass === undefined || oldHass.localize !== this.hass.localize;
}
public willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
@@ -583,6 +260,19 @@ export class HaMediaPlayerBrowse extends LitElement {
}
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (changedProps.size > 1 || !changedProps.has("hass")) {
return true;
}
const oldHass = changedProps.get("hass") as this["hass"];
return oldHass === undefined || oldHass.localize !== this.hass.localize;
}
protected firstUpdated(): void {
this._measureCard();
this._attachResizeObserver();
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
@@ -590,16 +280,368 @@ export class HaMediaPlayerBrowse extends LitElement {
this._animateHeaderHeight();
} else if (changedProps.has("_currentItem")) {
this._setHeaderHeight();
this._attachIntersectionObserver();
}
}
private _actionClicked(ev: MouseEvent): void {
protected render(): TemplateResult {
if (this._error) {
return html`
<div class="container">${this._renderError(this._error)}</div>
`;
}
if (!this._currentItem) {
return html`<ha-circular-progress active></ha-circular-progress>`;
}
const currentItem = this._currentItem;
const subtitle = this.hass.localize(
`ui.components.media-browser.class.${currentItem.media_class}`
);
const children = currentItem.children || [];
const mediaClass = MediaClassBrowserSettings[currentItem.media_class];
const childrenMediaClass = currentItem.children_media_class
? MediaClassBrowserSettings[currentItem.children_media_class]
: MediaClassBrowserSettings.directory;
const backgroundImage = currentItem.thumbnail
? this._getSignedThumbnail(currentItem.thumbnail).then(
(value) => `url(${value})`
)
: "none";
return html`
${
currentItem.can_play
? html`
<div
class="header ${classMap({
"no-img": !currentItem.thumbnail,
"no-dialog": !this.dialog,
})}"
@transitionend=${this._setHeaderHeight}
>
<div class="header-content">
${currentItem.thumbnail
? html`
<div
class="img"
style="background-image: ${until(
backgroundImage,
""
)}"
>
${this._narrow && currentItem?.can_play
? html`
<ha-fab
mini
.item=${currentItem}
@click=${this._actionClicked}
>
<ha-svg-icon
slot="icon"
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${this.action === "play"
? mdiPlay
: mdiPlus}
></ha-svg-icon>
${this.hass.localize(
`ui.components.media-browser.${this.action}`
)}
</ha-fab>
`
: ""}
</div>
`
: html``}
<div class="header-info">
<div class="breadcrumb">
<h1 class="title">${currentItem.title}</h1>
${subtitle
? html` <h2 class="subtitle">${subtitle}</h2> `
: ""}
</div>
${currentItem.can_play &&
(!currentItem.thumbnail || !this._narrow)
? html`
<mwc-button
raised
.item=${currentItem}
@click=${this._actionClicked}
>
<ha-svg-icon
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${this.action === "play"
? mdiPlay
: mdiPlus}
></ha-svg-icon>
${this.hass.localize(
`ui.components.media-browser.${this.action}`
)}
</mwc-button>
`
: ""}
</div>
</div>
</div>
`
: ""
}
<div
class="content"
@scroll=${this._scroll}
@touchmove=${this._scroll}
>
${
this._error
? html`
<div class="container">
${this._renderError(this._error)}
</div>
`
: isTTSMediaSource(currentItem.media_content_id)
? html`
<ha-browse-media-tts
.item=${currentItem}
.hass=${this.hass}
.action=${this.action}
@tts-picked=${this._ttsPicked}
></ha-browse-media-tts>
`
: !children.length && !currentItem.not_shown
? html`
<div class="container no-items">
${currentItem.media_content_id ===
"media-source://media_source/local/."
? html`
<div class="highlight-add-button">
<span>
<ha-svg-icon
.path=${mdiArrowUpRight}
></ha-svg-icon>
</span>
<span>
${this.hass.localize(
"ui.components.media-browser.file_management.highlight_button"
)}
</span>
</div>
`
: this.hass.localize(
"ui.components.media-browser.no_items"
)}
</div>
`
: childrenMediaClass.layout === "grid"
? html`
<lit-virtualizer
scroller
.layout=${grid({
itemSize: {
width: "175px",
height: "225px",
},
gap: "16px",
flex: { preserve: "aspect-ratio" },
justify: "space-evenly",
direction: "vertical",
})}
.items=${children}
.renderItem=${this._renderGridItem}
class="children ${classMap({
portrait:
childrenMediaClass.thumbnail_ratio === "portrait",
not_shown: !!currentItem.not_shown,
})}"
></lit-virtualizer>
${currentItem.not_shown
? html`
<div class="grid not-shown">
<div class="title">
${this.hass.localize(
"ui.components.media-browser.not_shown",
{ count: currentItem.not_shown }
)}
</div>
</div>
`
: ""}
`
: html`
<mwc-list>
<lit-virtualizer
scroller
.items=${children}
.renderItem=${this._renderListItem}
></lit-virtualizer>
${currentItem.not_shown
? html`
<mwc-list-item
noninteractive
class="not-shown"
.graphic=${mediaClass.show_list_images
? "medium"
: "avatar"}
dir=${computeRTLDirection(this.hass)}
>
<span class="title">
${this.hass.localize(
"ui.components.media-browser.not_shown",
{ count: currentItem.not_shown }
)}
</span>
</mwc-list-item>
`
: ""}
</mwc-list>
`
}
</div>
</div>
</div>
`;
}
private _renderGridItem = (child: MediaPlayerItem): TemplateResult => {
const backgroundImage = child.thumbnail
? this._getSignedThumbnail(child.thumbnail).then(
(value) => `url(${value})`
)
: "none";
return html`
<div class="child" .item=${child} @click=${this._childClicked}>
<ha-card outlined>
<div class="thumbnail">
${child.thumbnail
? html`
<div
class="${["app", "directory"].includes(child.media_class)
? "centered-image"
: ""} image"
style="background-image: ${until(backgroundImage, "")}"
></div>
`
: html`
<div class="icon-holder image">
<ha-svg-icon
class="folder"
.path=${MediaClassBrowserSettings[
child.media_class === "directory"
? child.children_media_class || child.media_class
: child.media_class
].icon}
></ha-svg-icon>
</div>
`}
${child.can_play
? html`
<ha-icon-button
class="play ${classMap({
can_expand: child.can_expand,
})}"
.item=${child}
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${this.action === "play" ? mdiPlay : mdiPlus}
@click=${this._actionClicked}
></ha-icon-button>
`
: ""}
</div>
<div class="title">
${child.title}
<paper-tooltip fitToVisibleBounds position="top" offset="4"
>${child.title}</paper-tooltip
>
</div>
</ha-card>
</div>
`;
};
private _renderListItem = (child: MediaPlayerItem): TemplateResult => {
const currentItem = this._currentItem;
const mediaClass = MediaClassBrowserSettings[currentItem!.media_class];
const backgroundImage =
mediaClass.show_list_images && child.thumbnail
? this._getSignedThumbnail(child.thumbnail).then(
(value) => `url(${value})`
)
: "none";
return html`
<mwc-list-item
@click=${this._childClicked}
.item=${child}
.graphic=${mediaClass.show_list_images ? "medium" : "avatar"}
dir=${computeRTLDirection(this.hass)}
>
<div
class=${classMap({
graphic: true,
thumbnail: mediaClass.show_list_images === true,
})}
style="background-image: ${until(backgroundImage, "")}"
slot="graphic"
>
<ha-icon-button
class="play ${classMap({
show: !mediaClass.show_list_images || !child.thumbnail,
})}"
.item=${child}
.label=${this.hass.localize(
`ui.components.media-browser.${this.action}-media`
)}
.path=${this.action === "play" ? mdiPlay : mdiPlus}
@click=${this._actionClicked}
></ha-icon-button>
</div>
<span class="title">${child.title}</span>
</mwc-list-item>
<li divider role="separator"></li>
`;
};
private async _getSignedThumbnail(
thumbnailUrl: string | undefined
): Promise<string> {
if (!thumbnailUrl) {
return "";
}
if (thumbnailUrl.startsWith("/")) {
// Thumbnails served by local API require authentication
return (await getSignedPath(this.hass, thumbnailUrl)).path;
}
if (thumbnailUrl.startsWith("https://brands.home-assistant.io")) {
// The backend is not aware of the theme used by the users,
// so we rewrite the URL to show a proper icon
thumbnailUrl = brandsUrl({
domain: extractDomainFromBrandUrl(thumbnailUrl),
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
});
}
return thumbnailUrl;
}
private _actionClicked = (ev: MouseEvent): void => {
ev.stopPropagation();
const item = (ev.currentTarget as any).item;
this._runAction(item);
}
};
private _runAction(item: MediaPlayerItem): void {
fireEvent(this, "media-picked", { item, navigateIds: this.navigateIds });
@@ -615,7 +657,7 @@ export class HaMediaPlayerBrowse extends LitElement {
});
}
private async _childClicked(ev: MouseEvent): Promise<void> {
private _childClicked = async (ev: MouseEvent): Promise<void> => {
const target = ev.currentTarget as any;
const item: MediaPlayerItem = target.item;
@@ -631,7 +673,7 @@ export class HaMediaPlayerBrowse extends LitElement {
fireEvent(this, "media-browsed", {
ids: [...this.navigateIds, item],
});
}
};
private async _fetchData(
entityId: string,
@@ -658,55 +700,6 @@ export class HaMediaPlayerBrowse extends LitElement {
this._resizeObserver.observe(this);
}
/**
* Load thumbnails for images on demand as they become visible.
*/
private async _attachIntersectionObserver(): Promise<void> {
if (!("IntersectionObserver" in window) || !this._thumbnails) {
return;
}
if (!this._intersectionObserver) {
this._intersectionObserver = new IntersectionObserver(
async (entries, observer) => {
await Promise.all(
entries.map(async (entry) => {
if (!entry.isIntersecting) {
return;
}
const thumbnailCard = entry.target as HTMLElement;
let thumbnailUrl = thumbnailCard.dataset.src;
if (!thumbnailUrl) {
return;
}
if (thumbnailUrl.startsWith("/")) {
// Thumbnails served by local API require authentication
const signedPath = await getSignedPath(this.hass, thumbnailUrl);
thumbnailUrl = signedPath.path;
} else if (
thumbnailUrl.startsWith("https://brands.home-assistant.io")
) {
// The backend is not aware of the theme used by the users,
// so we rewrite the URL to show a proper icon
thumbnailUrl = brandsUrl({
domain: extractDomainFromBrandUrl(thumbnailUrl),
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
});
}
thumbnailCard.style.backgroundImage = `url(${thumbnailUrl})`;
observer.unobserve(thumbnailCard); // loaded, so no need to observe anymore
})
);
}
);
}
const observer = this._intersectionObserver!;
for (const thumbnailCard of this._thumbnails) {
observer.observe(thumbnailCard);
}
}
private _closeDialogAction(): void {
fireEvent(this, "close-dialog");
}
@@ -841,6 +834,7 @@ export class HaMediaPlayerBrowse extends LitElement {
.content {
overflow-y: auto;
box-sizing: border-box;
height: 100%;
}
/* HEADER */
@@ -926,6 +920,7 @@ export class HaMediaPlayerBrowse extends LitElement {
.not-shown {
font-style: italic;
color: var(--secondary-text-color);
padding: 8px 16px 8px;
}
.grid.not-shown {
@@ -951,7 +946,11 @@ export class HaMediaPlayerBrowse extends LitElement {
border-bottom-color: var(--divider-color);
}
.children {
mwc-list-item {
width: 100%;
}
div.children {
display: grid;
grid-template-columns: repeat(
auto-fit,
@@ -988,7 +987,7 @@ export class HaMediaPlayerBrowse extends LitElement {
padding-bottom: 100%;
}
.portrait.children ha-card .thumbnail {
.portrait ha-card .thumbnail {
padding-bottom: 150%;
}
@@ -1062,10 +1061,6 @@ export class HaMediaPlayerBrowse extends LitElement {
color: var(--primary-color);
}
ha-card:hover .lazythumbnail {
opacity: 0.5;
}
.child .title {
font-size: 16px;
padding-top: 16px;
@@ -1127,7 +1122,7 @@ export class HaMediaPlayerBrowse extends LitElement {
padding: 0 24px;
}
:host([narrow]) .children {
:host([narrow]) div.children {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) !important;
}
@@ -1232,6 +1227,16 @@ export class HaMediaPlayerBrowse extends LitElement {
--mdc-fab-box-shadow: none;
--mdc-theme-secondary: rgba(var(--rgb-primary-color), 0.5);
}
lit-virtualizer {
height: 100%;
overflow: overlay !important;
contain: size layout !important;
}
lit-virtualizer.not_shown {
height: calc(100% - 36px);
}
`,
];
}
@@ -119,6 +119,11 @@ class MediaUploadButton extends LitElement {
ha-circular-progress[slot="icon"] {
vertical-align: middle;
}
:host-context([style*="direction: rtl;"]) ha-svg-icon[slot="icon"] {
margin-left: 8px;
margin-right: 0px;
}
`;
}
+132 -1
View File
@@ -1,7 +1,11 @@
import {
mdiAbTesting,
mdiAlertOctagon,
mdiArrowDecision,
mdiArrowUp,
mdiAsterisk,
mdiCallMissed,
mdiCallReceived,
mdiCallSplit,
mdiCheckboxBlankOutline,
mdiCheckboxMarkedOutline,
@@ -9,10 +13,12 @@ import {
mdiChevronRight,
mdiChevronUp,
mdiClose,
mdiCloseOctagon,
mdiCodeBrackets,
mdiDevices,
mdiExclamation,
mdiRefresh,
mdiShuffleDisabled,
mdiTimerOutline,
mdiTrafficLight,
} from "@mdi/js";
@@ -27,6 +33,9 @@ import {
DelayAction,
DeviceAction,
EventAction,
IfAction,
ManualScriptConfig,
ParallelAction,
RepeatAction,
SceneAction,
ServiceAction,
@@ -36,6 +45,8 @@ import {
import {
ChooseActionTraceStep,
ConditionTraceStep,
IfActionTraceStep,
StopActionTraceStep,
TraceExtended,
} from "../../data/trace";
import "../ha-icon-button";
@@ -101,6 +112,9 @@ export class HatScriptGraph extends LitElement {
private typeRenderers = {
condition: this.render_condition_node,
and: this.render_condition_node,
or: this.render_condition_node,
not: this.render_condition_node,
delay: this.render_delay_node,
event: this.render_event_node,
scene: this.render_scene_node,
@@ -110,6 +124,9 @@ export class HatScriptGraph extends LitElement {
repeat: this.render_repeat_node,
choose: this.render_choose_node,
device_id: this.render_device_node,
if: this.render_if_node,
stop: this.render_stop_node,
parallel: this.render_parallel_node,
other: this.render_other_node,
};
@@ -146,7 +163,7 @@ export class HatScriptGraph extends LitElement {
>
<hat-graph-node
.graphStart=${graphStart}
.iconPath=${mdiCallSplit}
.iconPath=${mdiArrowDecision}
?track=${trace !== undefined}
?active=${this.selected === path}
slot="head"
@@ -196,6 +213,64 @@ export class HatScriptGraph extends LitElement {
`;
}
private render_if_node(config: IfAction, path: string, graphStart = false) {
const trace = this.trace.trace[path] as IfActionTraceStep[] | undefined;
let trackThen = false;
let trackElse = false;
for (const trc of trace || []) {
if (!trackThen && trc.result?.choice === "then") {
trackThen = true;
}
if ((!trackElse && trc.result?.choice === "else") || !trc.result) {
trackElse = true;
}
if (trackElse && trackThen) {
break;
}
}
return html`
<hat-graph-branch
tabindex=${trace === undefined ? "-1" : "0"}
@focus=${this.selectNode(config, path)}
?track=${trace !== undefined}
?active=${this.selected === path}
>
<hat-graph-node
.graphStart=${graphStart}
.iconPath=${mdiCallSplit}
?track=${trace !== undefined}
?active=${this.selected === path}
slot="head"
nofocus
></hat-graph-node>
${config.else
? html`<div class="graph-container" ?track=${trackElse}>
<hat-graph-node
.iconPath=${mdiCallMissed}
?track=${trackElse}
?active=${this.selected === path}
nofocus
></hat-graph-node
>${ensureArray(config.else).map((action, j) =>
this.render_action_node(action, `${path}/else/${j}`)
)}
</div>`
: html`<hat-graph-spacer ?track=${trackElse}></hat-graph-spacer>`}
<div class="graph-container" ?track=${trackThen}>
<hat-graph-node
.iconPath=${mdiCallReceived}
?track=${trackThen}
?active=${this.selected === path}
nofocus
></hat-graph-node>
${ensureArray(config.then).map((action, j) =>
this.render_action_node(action, `${path}/then/${j}`)
)}
</div>
</hat-graph-branch>
`;
}
private render_condition_node(
node: Condition,
path: string,
@@ -392,6 +467,62 @@ export class HatScriptGraph extends LitElement {
`;
}
private render_parallel_node(
node: ParallelAction,
path: string,
graphStart = false
) {
const trace: any = this.trace.trace[path];
return html`
<hat-graph-branch
tabindex=${trace === undefined ? "-1" : "0"}
@focus=${this.selectNode(node, path)}
?track=${path in this.trace.trace}
?active=${this.selected === path}
>
<hat-graph-node
.graphStart=${graphStart}
.iconPath=${mdiShuffleDisabled}
?track=${path in this.trace.trace}
?active=${this.selected === path}
slot="head"
nofocus
></hat-graph-node>
${ensureArray(node.parallel).map((action, i) =>
"sequence" in action
? html`<div ?track=${path in this.trace.trace}>
${ensureArray((action as ManualScriptConfig).sequence).map(
(sAction, j) =>
this.render_action_node(
sAction,
`${path}/parallel/${i}/sequence/${j}`
)
)}
</div>`
: this.render_action_node(
action,
`${path}/parallel/${i}/sequence/0`
)
)}
</hat-graph-branch>
`;
}
private render_stop_node(node: Action, path: string, graphStart = false) {
const trace = this.trace.trace[path] as StopActionTraceStep[] | undefined;
return html`
<hat-graph-node
.graphStart=${graphStart}
.iconPath=${trace?.[0].result?.error
? mdiAlertOctagon
: mdiCloseOctagon}
@focus=${this.selectNode(node, path)}
?track=${path in this.trace.trace}
?active=${this.selected === path}
></hat-graph-node>
`;
}
private render_other_node(node: Action, path: string, graphStart = false) {
return html`
<hat-graph-node
+143 -7
View File
@@ -25,12 +25,17 @@ import {
ChooseAction,
ChooseActionChoice,
getActionType,
IfAction,
ParallelAction,
RepeatAction,
} from "../../data/script";
import { describeAction } from "../../data/script_i18n";
import {
ActionTraceStep,
AutomationTraceExtended,
ChooseActionTraceStep,
getDataFromPath,
IfActionTraceStep,
isTriggerPath,
TriggerTraceStep,
} from "../../data/trace";
@@ -105,7 +110,7 @@ class LogbookRenderer {
}
get hasNext() {
return this.curIndex !== this.logbookEntries.length;
return this.curIndex < this.logbookEntries.length;
}
maybeRenderItem() {
@@ -201,7 +206,7 @@ class ActionRenderer {
}
get hasNext() {
return this.curIndex !== this.keys.length;
return this.curIndex < this.keys.length;
}
renderItem() {
@@ -214,15 +219,31 @@ class ActionRenderer {
private _renderItem(
index: number,
actionType?: ReturnType<typeof getActionType>
actionType?: ReturnType<typeof getActionType>,
renderAllIterations?: boolean
): number {
const value = this._getItem(index);
if (isTriggerPath(value[0].path)) {
return this._handleTrigger(index, value[0] as TriggerTraceStep);
if (renderAllIterations) {
let i;
value.forEach((item) => {
i = this._renderIteration(index, item, actionType);
});
return i;
}
return this._renderIteration(index, value[0], actionType);
}
private _renderIteration(
index: number,
value: ActionTraceStep,
actionType?: ReturnType<typeof getActionType>
) {
if (isTriggerPath(value.path)) {
return this._handleTrigger(index, value as TriggerTraceStep);
}
const timestamp = new Date(value[0].timestamp);
const timestamp = new Date(value.timestamp);
// Render all logbook items that are in front of this item.
while (
@@ -235,7 +256,7 @@ class ActionRenderer {
this.logbookRenderer.flush();
this.timeTracker.maybeRenderTime(timestamp);
const path = value[0].path;
const path = value.path;
let data;
try {
data = getDataFromPath(this.trace.config, path);
@@ -263,6 +284,18 @@ class ActionRenderer {
return this._handleChoose(index);
}
if (actionType === "repeat") {
return this._handleRepeat(index);
}
if (actionType === "if") {
return this._handleIf(index);
}
if (actionType === "parallel") {
return this._handleParallel(index);
}
this._renderEntry(path, describeAction(this.hass, data, actionType));
let i = index + 1;
@@ -374,6 +407,109 @@ class ActionRenderer {
return i;
}
private _handleRepeat(index: number): number {
const repeatPath = this.keys[index];
const startLevel = repeatPath.split("/").length;
const repeatConfig = this._getDataFromPath(
this.keys[index]
) as RepeatAction;
const name = repeatConfig.alias || describeAction(this.hass, repeatConfig);
this._renderEntry(repeatPath, name);
let i;
for (i = index + 1; i < this.keys.length; i++) {
const path = this.keys[i];
const parts = path.split("/");
// We're done if no more sequence in current level
if (parts.length <= startLevel) {
return i;
}
i = this._renderItem(i, getActionType(this._getDataFromPath(path)), true);
}
return i;
}
private _handleIf(index: number): number {
const ifPath = this.keys[index];
const startLevel = ifPath.split("/").length;
const ifTrace = this._getItem(index)[0] as IfActionTraceStep;
const ifConfig = this._getDataFromPath(this.keys[index]) as IfAction;
const name = ifConfig.alias || "If";
if (ifTrace.result) {
const choiceConfig = this._getDataFromPath(
`${this.keys[index]}/${ifTrace.result.choice}/`
) as any;
const choiceName = choiceConfig
? `${choiceConfig.alias || `${ifTrace.result.choice} action executed`}`
: `Error: ${ifTrace.error}`;
this._renderEntry(ifPath, `${name}: ${choiceName}`);
} else {
this._renderEntry(ifPath, `${name}: No action taken`);
}
let i;
// Skip over conditions
for (i = index + 1; i < this.keys.length; i++) {
const path = this.keys[i];
const parts = this.keys[i].split("/");
// We're done if no more sequence in current level
if (parts.length <= startLevel) {
return i;
}
// We're going to skip all conditions
if (
parts[startLevel + 1] === "condition" ||
parts.length < startLevel + 2
) {
continue;
}
i = this._renderItem(i, getActionType(this._getDataFromPath(path)));
}
return i;
}
private _handleParallel(index: number): number {
const parallelPath = this.keys[index];
const startLevel = parallelPath.split("/").length;
const parallelConfig = this._getDataFromPath(
this.keys[index]
) as ParallelAction;
const name = parallelConfig.alias || "Execute in parallel";
this._renderEntry(parallelPath, name);
let i;
for (i = index + 1; i < this.keys.length; i++) {
const path = this.keys[i];
const parts = path.split("/");
// We're done if no more sequence in current level
if (parts.length <= startLevel) {
return i;
}
i = this._renderItem(i, getActionType(this._getDataFromPath(path)));
}
return i;
}
private _renderEntry(
path: string,
description: string,
+57 -1
View File
@@ -65,6 +65,7 @@ export interface BaseTrigger {
platform: string;
id?: string;
variables?: Record<string, unknown>;
enabled?: boolean;
}
export interface StateTrigger extends BaseTrigger {
@@ -152,6 +153,12 @@ export interface EventTrigger extends BaseTrigger {
context?: ContextConstraint;
}
export interface CalendarTrigger extends BaseTrigger {
platform: "calendar";
event: "start" | "end";
entity_id: string;
}
export type Trigger =
| StateTrigger
| MqttTrigger
@@ -166,11 +173,13 @@ export type Trigger =
| TimeTrigger
| TemplateTrigger
| EventTrigger
| DeviceTrigger;
| DeviceTrigger
| CalendarTrigger;
interface BaseCondition {
condition: string;
alias?: string;
enabled?: boolean;
}
export interface LogicalCondition extends BaseCondition {
@@ -226,6 +235,24 @@ export interface TriggerCondition extends BaseCondition {
id: string;
}
type ShorthandBaseCondition = Omit<BaseCondition, "condition">;
export interface ShorthandAndConditionList extends ShorthandBaseCondition {
condition: Condition[];
}
export interface ShorthandAndCondition extends ShorthandBaseCondition {
and: Condition[];
}
export interface ShorthandOrCondition extends ShorthandBaseCondition {
or: Condition[];
}
export interface ShorthandNotCondition extends ShorthandBaseCondition {
not: Condition[];
}
export type Condition =
| StateCondition
| NumericStateCondition
@@ -237,6 +264,35 @@ export type Condition =
| LogicalCondition
| TriggerCondition;
export type ConditionWithShorthand =
| Condition
| ShorthandAndConditionList
| ShorthandAndCondition
| ShorthandOrCondition
| ShorthandNotCondition;
export const expandConditionWithShorthand = (
cond: ConditionWithShorthand
): Condition => {
if ("condition" in cond && Array.isArray(cond.condition)) {
return {
condition: "and",
conditions: cond.condition,
};
}
for (const condition of ["and", "or", "not"]) {
if (condition in cond) {
return {
condition,
conditions: cond[condition],
} as Condition;
}
}
return cond as Condition;
};
export const triggerAutomationActions = (
hass: HomeAssistant,
entityId: string
+15
View File
@@ -11,6 +11,8 @@ export interface DeviceAutomation {
type?: string;
subtype?: string;
event?: string;
enabled?: boolean;
metadata?: { secondary: boolean };
}
export interface DeviceAction extends DeviceAutomation {
@@ -179,3 +181,16 @@ export const localizeDeviceAutomationTrigger = (
(trigger.subtype ? `"${trigger.subtype}" ${trigger.type}` : trigger.type!)
);
};
export const sortDeviceAutomations = (
automationA: DeviceAutomation,
automationB: DeviceAutomation
) => {
if (automationA.metadata?.secondary && !automationB.metadata?.secondary) {
return 1;
}
if (!automationA.metadata?.secondary && automationB.metadata?.secondary) {
return -1;
}
return 0;
};
+5
View File
@@ -1,4 +1,9 @@
import { HomeAssistant } from "../types";
export interface LogProvider {
key: string;
name: string;
}
export const fetchErrorLog = (hass: HomeAssistant) =>
hass.callApi<string>("GET", "error_log");
+22
View File
@@ -0,0 +1,22 @@
// Keep in sync with https://github.com/home-assistant/analytics.home-assistant.io/blob/dev/site/src/analytics-os-boards.ts#L6-L24
export const BOARD_NAMES: Record<string, string> = {
"odroid-n2": "Home Assistant Blue / ODROID-N2",
"odroid-xu4": "ODROID-XU4",
"odroid-c2": "ODROID-C2",
"odroid-c4": "ODROID-C4",
rpi: "Raspberry Pi",
rpi0: "Raspberry Pi Zero",
"rpi0-w": "Raspberry Pi Zero W",
rpi2: "Raspberry Pi 2",
rpi3: "Raspberry Pi 3 (32-bit)",
"rpi3-64": "Raspberry Pi 3",
rpi4: "Raspberry Pi 4 (32-bit)",
"rpi4-64": "Raspberry Pi 4",
tinker: "ASUS Tinker Board",
"khadas-vim3": "Khadas VIM3",
"generic-aarch64": "Generic AArch64",
ova: "Virtual Machine",
"generic-x86-64": "Generic x86-64",
"intel-nuc": "Intel NUC",
yellow: "Home Assistant Yellow",
};
+2 -2
View File
@@ -1,7 +1,7 @@
import { atLeastVersion } from "../../common/config/version";
import { HomeAssistant, PanelInfo } from "../../types";
import { SupervisorArch } from "../supervisor/supervisor";
import { HassioAddonInfo, HassioAddonRepository } from "./addon";
import { HassioAddonInfo } from "./addon";
import { hassioApiResultExtractor, HassioResponse } from "./common";
export type HassioHomeAssistantInfo = {
@@ -23,7 +23,7 @@ export type HassioHomeAssistantInfo = {
export type HassioSupervisorInfo = {
addons: HassioAddonInfo[];
addons_repositories: HassioAddonRepository[];
addons_repositories: string[];
arch: SupervisorArch;
channel: string;
debug: boolean;
+49 -37
View File
@@ -13,11 +13,18 @@ import {
literal,
is,
Describe,
boolean,
} from "superstruct";
import { computeObjectId } from "../common/entity/compute_object_id";
import { navigate } from "../common/navigate";
import { HomeAssistant } from "../types";
import { Condition, Trigger } from "./automation";
import {
Condition,
ShorthandAndCondition,
ShorthandNotCondition,
ShorthandOrCondition,
Trigger,
} from "./automation";
import { BlueprintInput } from "./blueprint";
export const MODES = ["single", "restart", "queued", "parallel"] as const;
@@ -25,6 +32,7 @@ export const MODES_MAX = ["queued", "parallel"];
export const baseActionStruct = object({
alias: optional(string()),
enabled: optional(boolean()),
});
const targetStruct = object({
@@ -88,15 +96,18 @@ export interface BlueprintScriptConfig extends ManualScriptConfig {
use_blueprint: { path: string; input?: BlueprintInput };
}
export interface EventAction {
interface BaseAction {
alias?: string;
enabled?: boolean;
}
export interface EventAction extends BaseAction {
event: string;
event_data?: Record<string, any>;
event_data_template?: Record<string, any>;
}
export interface ServiceAction {
alias?: string;
export interface ServiceAction extends BaseAction {
service?: string;
service_template?: string;
entity_id?: string;
@@ -104,55 +115,48 @@ export interface ServiceAction {
data?: Record<string, unknown>;
}
export interface DeviceAction {
alias?: string;
export interface DeviceAction extends BaseAction {
type: string;
device_id: string;
domain: string;
entity_id: string;
}
export interface DelayActionParts {
export interface DelayActionParts extends BaseAction {
milliseconds?: number;
seconds?: number;
minutes?: number;
hours?: number;
days?: number;
}
export interface DelayAction {
alias?: string;
export interface DelayAction extends BaseAction {
delay: number | Partial<DelayActionParts> | string;
}
export interface ServiceSceneAction {
alias?: string;
export interface ServiceSceneAction extends BaseAction {
service: "scene.turn_on";
target?: { entity_id?: string };
entity_id?: string;
metadata: Record<string, unknown>;
}
export interface LegacySceneAction {
alias?: string;
export interface LegacySceneAction extends BaseAction {
scene: string;
}
export type SceneAction = ServiceSceneAction | LegacySceneAction;
export interface WaitAction {
alias?: string;
export interface WaitAction extends BaseAction {
wait_template: string;
timeout?: number;
continue_on_timeout?: boolean;
}
export interface WaitForTriggerAction {
alias?: string;
export interface WaitForTriggerAction extends BaseAction {
wait_for_trigger: Trigger | Trigger[];
timeout?: number;
continue_on_timeout?: boolean;
}
export interface PlayMediaAction {
alias?: string;
export interface PlayMediaAction extends BaseAction {
service: "media_player.play_media";
target?: { entity_id?: string };
entity_id?: string;
@@ -160,13 +164,11 @@ export interface PlayMediaAction {
metadata: Record<string, unknown>;
}
export interface RepeatAction {
alias?: string;
repeat: CountRepeat | WhileRepeat | UntilRepeat;
export interface RepeatAction extends BaseAction {
repeat: CountRepeat | WhileRepeat | UntilRepeat | ForEachRepeat;
}
interface BaseRepeat {
alias?: string;
interface BaseRepeat extends BaseAction {
sequence: Action | Action[];
}
@@ -182,38 +184,40 @@ export interface UntilRepeat extends BaseRepeat {
until: Condition[];
}
export interface ChooseActionChoice {
alias?: string;
export interface ForEachRepeat extends BaseRepeat {
for_each: string | any[];
}
export interface ChooseActionChoice extends BaseAction {
conditions: string | Condition[];
sequence: Action | Action[];
}
export interface ChooseAction {
alias?: string;
export interface ChooseAction extends BaseAction {
choose: ChooseActionChoice | ChooseActionChoice[] | null;
default?: Action | Action[];
}
export interface IfAction {
alias?: string;
export interface IfAction extends BaseAction {
if: string | Condition[];
then: Action | Action[];
else?: Action | Action[];
}
export interface VariablesAction {
alias?: string;
export interface VariablesAction extends BaseAction {
variables: Record<string, unknown>;
}
export interface StopAction {
alias?: string;
export interface StopAction extends BaseAction {
stop: string;
error?: boolean;
}
interface UnknownAction {
alias?: string;
export interface ParallelAction extends BaseAction {
parallel: ManualScriptConfig | Action | (ManualScriptConfig | Action)[];
}
interface UnknownAction extends BaseAction {
[key: string]: unknown;
}
@@ -222,6 +226,9 @@ export type Action =
| DeviceAction
| ServiceAction
| Condition
| ShorthandAndCondition
| ShorthandOrCondition
| ShorthandNotCondition
| DelayAction
| SceneAction
| WaitAction
@@ -232,6 +239,7 @@ export type Action =
| VariablesAction
| PlayMediaAction
| StopAction
| ParallelAction
| UnknownAction;
export interface ActionTypes {
@@ -249,6 +257,7 @@ export interface ActionTypes {
service: ServiceAction;
play_media: PlayMediaAction;
stop: StopAction;
parallel: ParallelAction;
unknown: UnknownAction;
}
@@ -298,7 +307,7 @@ export const getActionType = (action: Action): ActionType => {
if ("wait_template" in action) {
return "wait_template";
}
if ("condition" in action) {
if (["condition", "and", "or", "not"].some((key) => key in action)) {
return "check_condition";
}
if ("event" in action) {
@@ -328,6 +337,9 @@ export const getActionType = (action: Action): ActionType => {
if ("stop" in action) {
return "stop";
}
if ("parallel" in action) {
return "parallel";
}
if ("service" in action) {
if ("metadata" in action) {
if (is(action, activateSceneActionStruct)) {
+87
View File
@@ -8,12 +8,17 @@ import { describeCondition, describeTrigger } from "./automation_i18n";
import {
ActionType,
ActionTypes,
ChooseAction,
DelayAction,
DeviceAction,
EventAction,
getActionType,
IfAction,
ParallelAction,
PlayMediaAction,
RepeatAction,
SceneAction,
StopAction,
VariablesAction,
WaitForTriggerAction,
} from "./script";
@@ -161,6 +166,81 @@ export const describeAction = <T extends ActionType>(
return `Test ${describeCondition(action as Condition)}`;
}
if (actionType === "stop") {
const config = action as StopAction;
return `Stopped${config.stop ? ` because: ${config.stop}` : ""}`;
}
if (actionType === "if") {
const config = action as IfAction;
return `If ${
typeof config.if === "string"
? config.if
: ensureArray(config.if)
.map((condition) => describeCondition(condition))
.join(", ")
} then ${ensureArray(config.then).map((thenAction) =>
describeAction(hass, thenAction)
)}${
config.else
? ` else ${ensureArray(config.else).map((elseAction) =>
describeAction(hass, elseAction)
)}`
: ""
}`;
}
if (actionType === "choose") {
const config = action as ChooseAction;
return config.choose
? `If ${ensureArray(config.choose)
.map(
(chooseAction) =>
`${
typeof chooseAction.conditions === "string"
? chooseAction.conditions
: ensureArray(chooseAction.conditions)
.map((condition) => describeCondition(condition))
.join(", ")
} then ${ensureArray(chooseAction.sequence)
.map((chooseSeq) => describeAction(hass, chooseSeq))
.join(", ")}`
)
.join(", else if ")}${
config.default
? `. If none match: ${ensureArray(config.default)
.map((dAction) => describeAction(hass, dAction))
.join(", ")}`
: ""
}`
: "Choose";
}
if (actionType === "repeat") {
const config = action as RepeatAction;
return `Repeat ${ensureArray(config.repeat.sequence).map((repeatAction) =>
describeAction(hass, repeatAction)
)} ${"count" in config.repeat ? `${config.repeat.count} times` : ""}${
"while" in config.repeat
? `while ${ensureArray(config.repeat.while)
.map((condition) => describeCondition(condition))
.join(", ")} is true`
: "until" in config.repeat
? `until ${ensureArray(config.repeat.until)
.map((condition) => describeCondition(condition))
.join(", ")} is true`
: "for_each" in config.repeat
? `for every item: ${ensureArray(config.repeat.for_each)
.map((item) => JSON.stringify(item))
.join(", ")}`
: ""
}`;
}
if (actionType === "check_condition") {
return `Test ${describeCondition(action as Condition)}`;
}
if (actionType === "device_action") {
const config = action as DeviceAction;
const stateObj = hass.states[config.entity_id as string];
@@ -169,5 +249,12 @@ export const describeAction = <T extends ActionType>(
}`;
}
if (actionType === "parallel") {
const config = action as ParallelAction;
return `Run in parallel: ${ensureArray(config.parallel)
.map((pAction) => describeAction(hass, pAction))
.join(", ")}`;
}
return actionType;
};
+13 -1
View File
@@ -44,6 +44,14 @@ export interface ChooseActionTraceStep extends BaseTraceStep {
result?: { choice: number | "default" };
}
export interface IfActionTraceStep extends BaseTraceStep {
result?: { choice: "then" | "else" };
}
export interface StopActionTraceStep extends BaseTraceStep {
result?: { stop: string; error: boolean };
}
export interface ChooseChoiceActionTraceStep extends BaseTraceStep {
result?: { result: boolean };
}
@@ -177,7 +185,11 @@ export const getDataFromPath = (
const asNumber = Number(raw);
if (isNaN(asNumber)) {
result = result[raw];
const tempResult = result[raw];
if (!tempResult && raw === "sequence") {
continue;
}
result = tempResult;
continue;
}
+83 -2
View File
@@ -1,10 +1,15 @@
import type {
HassEntities,
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import { BINARY_STATE_ON } from "../common/const";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { supportsFeature } from "../common/entity/supports-feature";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
export const UPDATE_SUPPORT_INSTALL = 1;
export const UPDATE_SUPPORT_SPECIFIC_VERSION = 2;
@@ -31,8 +36,12 @@ export const updateUsesProgress = (entity: UpdateEntity): boolean =>
supportsFeature(entity, UPDATE_SUPPORT_PROGRESS) &&
typeof entity.attributes.in_progress === "number";
export const updateCanInstall = (entity: UpdateEntity): boolean =>
entity.state === BINARY_STATE_ON &&
export const updateCanInstall = (
entity: UpdateEntity,
showSkipped = false
): boolean =>
(entity.state === BINARY_STATE_ON ||
(showSkipped && Boolean(entity.attributes.skipped_version))) &&
supportsFeature(entity, UPDATE_SUPPORT_INSTALL);
export const updateIsInstalling = (entity: UpdateEntity): boolean =>
@@ -43,3 +52,75 @@ export const updateReleaseNotes = (hass: HomeAssistant, entityId: string) =>
type: "update/release_notes",
entity_id: entityId,
});
export const filterUpdateEntities = (entities: HassEntities) =>
(
Object.values(entities).filter(
(entity) => computeStateDomain(entity) === "update"
) as UpdateEntity[]
).sort((a, b) => {
if (a.attributes.title === "Home Assistant Core") {
return -3;
}
if (b.attributes.title === "Home Assistant Core") {
return 3;
}
if (a.attributes.title === "Home Assistant Operating System") {
return -2;
}
if (b.attributes.title === "Home Assistant Operating System") {
return 2;
}
if (a.attributes.title === "Home Assistant Supervisor") {
return -1;
}
if (b.attributes.title === "Home Assistant Supervisor") {
return 1;
}
return caseInsensitiveStringCompare(
a.attributes.title || a.attributes.friendly_name || "",
b.attributes.title || b.attributes.friendly_name || ""
);
});
export const filterUpdateEntitiesWithInstall = (
entities: HassEntities,
showSkipped = false
) =>
filterUpdateEntities(entities).filter((entity) =>
updateCanInstall(entity, showSkipped)
);
export const checkForEntityUpdates = async (
element: HTMLElement,
hass: HomeAssistant
) => {
const entities = filterUpdateEntities(hass.states).map(
(entity) => entity.entity_id
);
if (!entities.length) {
showAlertDialog(element, {
title: hass.localize("ui.panel.config.updates.no_update_entities.title"),
text: hass.localize(
"ui.panel.config.updates.no_update_entities.description"
),
warning: true,
});
return;
}
await hass.callService("homeassistant", "update_entity", {
entity_id: entities,
});
if (filterUpdateEntitiesWithInstall(hass.states).length) {
showToast(element, {
message: hass.localize("ui.panel.config.updates.updates_refreshed"),
});
} else {
showToast(element, {
message: hass.localize("ui.panel.config.updates.no_new_updates"),
});
}
};
+2 -2
View File
@@ -127,7 +127,7 @@ export interface ZWaveJSClient {
export interface ZWaveJSController {
home_id: number;
library_version: string;
sdk_version: string;
type: number;
own_node_id: number;
is_secondary: boolean;
@@ -136,7 +136,7 @@ export interface ZWaveJSController {
was_real_primary: boolean;
is_static_update_controller: boolean;
is_slave: boolean;
serial_api_version: string;
firmware_version: string;
manufacturer_id: number;
product_id: number;
product_type: number;
@@ -312,6 +312,7 @@ class DataEntryFlowDialog extends LitElement {
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.domain=${this._step.handler}
></step-flow-abort>
`
: this._step.type === "progress"
@@ -146,14 +146,14 @@ export const showOptionsFlowDialog = (
renderMenuHeader(hass, step) {
return (
hass.localize(
`component.${step.handler}.option.step.${step.step_id}.title`
) || hass.localize(`component.${step.handler}.title`)
`component.${configEntry.domain}.options.step.${step.step_id}.title`
) || hass.localize(`component.${configEntry.domain}.title`)
);
},
renderMenuDescription(hass, step) {
const description = hass.localize(
`component.${step.handler}.option.step.${step.step_id}.description`,
`component.${configEntry.domain}.options.step.${step.step_id}.description`,
step.description_placeholders
);
return description
@@ -169,7 +169,7 @@ export const showOptionsFlowDialog = (
renderMenuOption(hass, step, option) {
return hass.localize(
`component.${step.handler}.options.step.${step.step_id}.menu_options.${option}`,
`component.${configEntry.domain}.options.step.${step.step_id}.menu_options.${option}`,
step.description_placeholders
);
},
+3 -5
View File
@@ -15,13 +15,11 @@ class StepFlowAbort extends LitElement {
@property({ attribute: false }) public step!: DataEntryFlowStepAbort;
@property({ attribute: false }) public domain!: string;
protected render(): TemplateResult {
return html`
<h2>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.aborted"
)}
</h2>
<h2>${this.hass.localize(`component.${this.domain}.title`)}</h2>
<div class="content">
${this.flowConfig.renderAbortDescription(this.hass, this.step)}
</div>
+1 -1
View File
@@ -77,7 +77,7 @@ class DialogBox extends LitElement {
dialogInitialFocus
.value=${this._value || ""}
@keyup=${this._handleKeyUp}
@change=${this._valueChanged}
@input=${this._valueChanged}
.label=${this._params.inputLabel
? this._params.inputLabel
: ""}
@@ -119,7 +119,15 @@ class MoreInfoVacuum extends LitElement {
"ui.dialogs.more_info_control.vacuum.status"
)}:
</span>
<span><strong>${stateObj.attributes.status}</strong></span>
<span>
<strong>
${stateObj.attributes.status ||
this.hass.localize(
`component.vacuum.state._.${stateObj.state}`
) ||
stateObj.state}
</strong>
</span>
</div>
`
: ""}
+2 -2
View File
@@ -24,7 +24,7 @@ import { domainIcon } from "../../common/entity/domain_icon";
import { navigate } from "../../common/navigate";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import {
fuzzyFilterSort,
defaultFuzzyFilterSort,
ScorableTextItem,
} from "../../common/string/filter/sequence-matching";
import { debounce } from "../../common/util/debounce";
@@ -694,7 +694,7 @@ export class QuickBar extends LitElement {
private _filterItems = memoizeOne(
(items: QuickBarItem[], filter: string): QuickBarItem[] =>
fuzzyFilterSort<QuickBarItem>(filter.trimLeft(), items)
defaultFuzzyFilterSort<QuickBarItem>(filter.trimLeft(), items)
);
static get styles() {
+1
View File
@@ -99,6 +99,7 @@ class HassSubpage extends LitElement {
ha-icon-button-arrow-prev,
::slotted([slot="toolbar-icon"]) {
pointer-events: auto;
color: var(--sidebar-icon-color);
}
.main-title {
+4 -1
View File
@@ -130,7 +130,7 @@ export class HaTabsSubpageDataTable extends LitElement {
* Array of tabs to show on the page.
* @type {Array}
*/
@property() public tabs!: PageNavigation[];
@property() public tabs: PageNavigation[] = [];
/**
* Force hides the filter menu.
@@ -283,6 +283,9 @@ export class HaTabsSubpageDataTable extends LitElement {
height: calc(100vh - 1px - var(--header-height));
display: block;
}
:host([narrow]) hass-tabs-subpage {
--main-title-margin: 0;
}
.table-header {
display: flex;
align-items: center;
+16 -3
View File
@@ -82,6 +82,16 @@ class HassTabsSubpage extends LitElement {
(!page.advancedOnly || showAdvanced)
);
if (shownTabs.length < 2) {
if (shownTabs.length === 1) {
const page = shownTabs[0];
return [
page.translationKey ? localizeFunc(page.translationKey) : page.name,
];
}
return [""];
}
return shownTabs.map(
(page) =>
html`
@@ -134,7 +144,7 @@ class HassTabsSubpage extends LitElement {
this.narrow,
this.localizeFunc || this.hass.localize
);
const showTabs = tabs.length > 1 || !this.narrow;
const showTabs = tabs.length > 1;
return html`
<div class="toolbar">
${this.mainPage || (!this.backPath && history.state?.root)
@@ -159,8 +169,10 @@ class HassTabsSubpage extends LitElement {
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`}
${this.narrow
? html`<div class="main-title"><slot name="header"></slot></div>`
${this.narrow || !showTabs
? html`<div class="main-title">
<slot name="header">${!showTabs ? tabs[0] : ""}</slot>
</div>`
: ""}
${showTabs
? html`
@@ -283,6 +295,7 @@ class HassTabsSubpage extends LitElement {
max-height: var(--header-height);
line-height: 20px;
color: var(--sidebar-text-color);
margin: var(--main-title-margin, 0 0 0 24px);
}
.content {
+2 -2
View File
@@ -1,11 +1,11 @@
import "@material/mwc-button/mwc-button";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import "../../hassio/src/components/hassio-ansi-to-html";
import { showBackupUploadDialog } from "../../hassio/src/dialogs/backup/show-dialog-backup-upload";
import { showHassioBackupDialog } from "../../hassio/src/dialogs/backup/show-dialog-hassio-backup";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-card";
import "../components/ha-ansi-to-html";
import { fetchInstallationType } from "../data/onboarding";
import { makeDialogManager } from "../dialogs/make-dialog-manager";
import { ProvideHassLitMixin } from "../mixins/provide-hass-lit-mixin";
@@ -86,7 +86,7 @@ class OnboardingRestoreBackup extends ProvideHassLitMixin(LitElement) {
padding: 4px;
margin-top: 8px;
}
hassio-ansi-to-html {
ha-ansi-to-html {
display: block;
line-height: 22px;
padding: 0 8px;
+5 -15
View File
@@ -11,14 +11,7 @@ import listPlugin from "@fullcalendar/list";
// @ts-ignore
import listStyle from "@fullcalendar/list/main.css";
import "@material/mwc-button";
import {
mdiChevronLeft,
mdiChevronRight,
mdiViewAgenda,
mdiViewDay,
mdiViewModule,
mdiViewWeek,
} from "@mdi/js";
import { mdiViewAgenda, mdiViewDay, mdiViewModule, mdiViewWeek } from "@mdi/js";
import {
css,
CSSResultGroup,
@@ -33,7 +26,6 @@ import memoize from "memoize-one";
import { useAmPm } from "../../common/datetime/use_am_pm";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button-toggle-group";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-prev";
import "../../components/ha-icon-button-next";
import { haStyle } from "../../resources/styles";
@@ -152,20 +144,18 @@ export class HAFullCalendar extends LitElement {
<div class="controls">
<h1>${this.calendar.view.title}</h1>
<div>
<ha-icon-button
<ha-icon-button-prev
.label=${this.hass.localize("ui.common.previous")}
.path=${mdiChevronLeft}
class="prev"
@click=${this._handlePrev}
>
</ha-icon-button>
<ha-icon-button
</ha-icon-button-prev>
<ha-icon-button-next
.label=${this.hass.localize("ui.common.next")}
.path=${mdiChevronRight}
class="next"
@click=${this._handleNext}
>
</ha-icon-button>
</ha-icon-button-next>
</div>
</div>
<div class="controls">
@@ -33,6 +33,7 @@ import "./types/ha-automation-action-delay";
import "./types/ha-automation-action-device_id";
import "./types/ha-automation-action-event";
import "./types/ha-automation-action-if";
import "./types/ha-automation-action-parallel";
import "./types/ha-automation-action-play_media";
import "./types/ha-automation-action-repeat";
import "./types/ha-automation-action-service";
@@ -54,6 +55,7 @@ const OPTIONS = [
"if",
"device_id",
"stop",
"parallel",
];
const getType = (action: Action | undefined) => {
@@ -63,6 +65,9 @@ const getType = (action: Action | undefined) => {
if ("service" in action || "scene" in action) {
return getActionType(action);
}
if (["and", "or", "not"].some((key) => key in action)) {
return "condition";
}
return OPTIONS.find((option) => option in action);
};
@@ -160,62 +165,82 @@ export default class HaAutomationActionRow extends LitElement {
return html`
<ha-card>
<div class="card-content">
<div class="card-menu">
${this.index !== 0
? html`
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.automation.editor.move_up"
)}
.path=${mdiArrowUp}
@click=${this._moveUp}
></ha-icon-button>
`
: ""}
${this.index !== this.totalActions - 1
? html`
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
.path=${mdiArrowDown}
@click=${this._moveDown}
></ha-icon-button>
`
: ""}
<ha-button-menu corner="BOTTOM_START" @action=${this._handleAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<mwc-list-item>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.run_action"
)}
</mwc-list-item>
<mwc-list-item .disabled=${!this._uiModeAvailable}>
${yamlMode
? this.hass.localize(
"ui.panel.config.automation.editor.edit_ui"
)
: this.hass.localize(
"ui.panel.config.automation.editor.edit_yaml"
)}
</mwc-list-item>
<mwc-list-item>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
</mwc-list-item>
<mwc-list-item class="warning">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
)}
</mwc-list-item>
</ha-button-menu>
</div>
${this.action.enabled === false
? html`<div class="disabled-bar">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.disabled"
)}
</div>`
: ""}
<div class="card-menu">
${this.index !== 0
? html`
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.automation.editor.move_up"
)}
.path=${mdiArrowUp}
@click=${this._moveUp}
></ha-icon-button>
`
: ""}
${this.index !== this.totalActions - 1
? html`
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
.path=${mdiArrowDown}
@click=${this._moveDown}
></ha-icon-button>
`
: ""}
<ha-button-menu corner="BOTTOM_START" @action=${this._handleAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<mwc-list-item>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.run_action"
)}
</mwc-list-item>
<mwc-list-item .disabled=${!this._uiModeAvailable}>
${yamlMode
? this.hass.localize(
"ui.panel.config.automation.editor.edit_ui"
)
: this.hass.localize(
"ui.panel.config.automation.editor.edit_yaml"
)}
</mwc-list-item>
<mwc-list-item>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
</mwc-list-item>
<mwc-list-item>
${this.action.enabled === false
? this.hass.localize(
"ui.panel.config.automation.editor.actions.enable"
)
: this.hass.localize(
"ui.panel.config.automation.editor.actions.disable"
)}
</mwc-list-item>
<mwc-list-item class="warning">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
)}
</mwc-list-item>
</ha-button-menu>
</div>
<div
class="card-content ${this.action.enabled === false
? "disabled"
: ""}"
>
${this._warnings
? html`<ha-alert
alert-type="warning"
@@ -314,11 +339,23 @@ export default class HaAutomationActionRow extends LitElement {
fireEvent(this, "duplicate");
break;
case 3:
this._onDisable();
break;
case 4:
this._onDelete();
break;
}
}
private _onDisable() {
const enabled = !(this.action.enabled ?? true);
const value = { ...this.action, enabled };
fireEvent(this, "value-changed", { value });
if (this._yamlMode) {
this._yamlEditor?.setValue(value);
}
}
private async _runAction() {
const validated = await validateConfig(this.hass, {
action: this.action,
@@ -408,11 +445,27 @@ export default class HaAutomationActionRow extends LitElement {
return [
haStyle,
css`
.disabled {
opacity: 0.5;
pointer-events: none;
}
.card-content {
padding-top: 16px;
margin-top: 0;
}
.disabled-bar {
background: var(--divider-color, #e0e0e0);
text-align: center;
border-top-right-radius: var(--ha-card-border-radius);
border-top-left-radius: var(--ha-card-border-radius);
}
.card-menu {
position: absolute;
right: 16px;
float: right;
z-index: 3;
margin: 4px;
--mdc-theme-text-primary-on-background: var(--primary-text-color);
display: flex;
align-items: center;
}
:host-context([style*="direction: rtl;"]) .card-menu {
right: initial;
@@ -0,0 +1,56 @@
import { CSSResultGroup, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { Action, ParallelAction } from "../../../../../data/script";
import { HaDeviceAction } from "./ha-automation-action-device_id";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import "../ha-automation-action";
import "../../../../../components/ha-textfield";
import type { ActionElement } from "../ha-automation-action-row";
@customElement("ha-automation-action-parallel")
export class HaParallelAction extends LitElement implements ActionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public action!: ParallelAction;
public static get defaultConfig() {
return {
parallel: [HaDeviceAction.defaultConfig],
};
}
protected render() {
const action = this.action;
return html`
<ha-automation-action
.actions=${action.parallel}
@value-changed=${this._actionsChanged}
.hass=${this.hass}
></ha-automation-action>
`;
}
private _actionsChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = ev.detail.value as Action[];
fireEvent(this, "value-changed", {
value: {
...this.action,
parallel: value,
},
});
}
static get styles(): CSSResultGroup {
return haStyle;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-action-parallel": HaParallelAction;
}
}
@@ -33,7 +33,7 @@ export class HaWaitAction extends LitElement implements ActionElement {
@property({ attribute: false }) public action!: WaitAction;
public static get defaultConfig() {
return { wait_template: "" };
return { wait_template: "", continue_on_timeout: true };
}
protected render() {
@@ -332,6 +332,7 @@ export class HaBlueprintAutomationEditor extends LitElement {
ha-settings-row {
--paper-time-input-justify-content: flex-end;
--settings-row-content-width: 100%;
--settings-row-prefix-display: contents;
border-top: 1px solid var(--divider-color);
}
`,
@@ -10,6 +10,7 @@ import "../../../../components/ha-select";
import type { HaSelect } from "../../../../components/ha-select";
import "../../../../components/ha-yaml-editor";
import type { Condition } from "../../../../data/automation";
import { expandConditionWithShorthand } from "../../../../data/automation";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "./types/ha-automation-condition-and";
@@ -42,10 +43,14 @@ const OPTIONS = [
export default class HaAutomationConditionEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public condition!: Condition;
@property() condition!: Condition;
@property() public yamlMode = false;
private _processedCondition = memoizeOne((condition) =>
expandConditionWithShorthand(condition)
);
private _processedTypes = memoizeOne(
(localize: LocalizeFunc): [string, string][] =>
OPTIONS.map(
@@ -60,7 +65,8 @@ export default class HaAutomationConditionEditor extends LitElement {
);
protected render() {
const selected = OPTIONS.indexOf(this.condition.condition);
const condition = this._processedCondition(this.condition);
const selected = OPTIONS.indexOf(condition.condition);
const yamlMode = this.yamlMode || selected === -1;
return html`
${yamlMode
@@ -70,7 +76,7 @@ export default class HaAutomationConditionEditor extends LitElement {
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.unsupported_condition",
"condition",
this.condition.condition
condition.condition
)}
`
: ""}
@@ -90,7 +96,7 @@ export default class HaAutomationConditionEditor extends LitElement {
.label=${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type_select"
)}
.value=${this.condition.condition}
.value=${condition.condition}
naturalMenuWidth
@selected=${this._typeChanged}
>
@@ -103,8 +109,8 @@ export default class HaAutomationConditionEditor extends LitElement {
<div>
${dynamicElement(
`ha-automation-condition-${this.condition.condition}`,
{ hass: this.hass, condition: this.condition }
`ha-automation-condition-${condition.condition}`,
{ hass: this.hass, condition: condition }
)}
</div>
`}
@@ -124,7 +130,7 @@ export default class HaAutomationConditionEditor extends LitElement {
defaultConfig: Omit<Condition, "condition">;
};
if (type !== this.condition.condition) {
if (type !== this._processedCondition(this.condition).condition) {
fireEvent(this, "value-changed", {
value: {
condition: type,
@@ -2,7 +2,7 @@ import { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import "@material/mwc-list/mwc-list-item";
import { mdiDotsVertical } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { handleStructError } from "../../../../common/structs/handle-errors";
import "../../../../components/ha-button-menu";
@@ -19,6 +19,7 @@ import { haStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
import "./ha-automation-condition-editor";
import { validateConfig } from "../../../../data/config";
import { HaYamlEditor } from "../../../../components/ha-yaml-editor";
export interface ConditionElement extends LitElement {
condition: Condition;
@@ -59,47 +60,69 @@ export default class HaAutomationConditionRow extends LitElement {
@state() private _warnings?: string[];
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;
protected render() {
if (!this.condition) {
return html``;
}
return html`
<ha-card>
<div class="card-content">
<div class="card-menu">
<ha-progress-button @click=${this._testCondition}>
${this.condition.enabled === false
? html`<div class="disabled-bar">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.test"
"ui.panel.config.automation.editor.actions.disabled"
)}
</ha-progress-button>
<ha-button-menu corner="BOTTOM_START" @action=${this._handleAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
>
</ha-icon-button>
<mwc-list-item>
${this._yamlMode
? this.hass.localize(
"ui.panel.config.automation.editor.edit_ui"
)
: this.hass.localize(
"ui.panel.config.automation.editor.edit_yaml"
)}
</mwc-list-item>
<mwc-list-item>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
</mwc-list-item>
<mwc-list-item class="warning">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
)}
</mwc-list-item>
</ha-button-menu>
</div>
</div>`
: ""}
<div class="card-menu">
<ha-progress-button @click=${this._testCondition}>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.test"
)}
</ha-progress-button>
<ha-button-menu corner="BOTTOM_START" @action=${this._handleAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
>
</ha-icon-button>
<mwc-list-item>
${this._yamlMode
? this.hass.localize(
"ui.panel.config.automation.editor.edit_ui"
)
: this.hass.localize(
"ui.panel.config.automation.editor.edit_yaml"
)}
</mwc-list-item>
<mwc-list-item>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
</mwc-list-item>
<mwc-list-item>
${this.condition.enabled === false
? this.hass.localize(
"ui.panel.config.automation.editor.actions.enable"
)
: this.hass.localize(
"ui.panel.config.automation.editor.actions.disable"
)}
</mwc-list-item>
<mwc-list-item class="warning">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
)}
</mwc-list-item>
</ha-button-menu>
</div>
<div
class="card-content ${this.condition.enabled === false
? "disabled"
: ""}"
>
${this._warnings
? html`<ha-alert
alert-type="warning"
@@ -153,11 +176,23 @@ export default class HaAutomationConditionRow extends LitElement {
fireEvent(this, "duplicate");
break;
case 2:
this._onDisable();
break;
case 3:
this._onDelete();
break;
}
}
private _onDisable() {
const enabled = !(this.condition.enabled ?? true);
const value = { ...this.condition, enabled };
fireEvent(this, "value-changed", { value });
if (this._yamlMode) {
this._yamlEditor?.setValue(value);
}
}
private _onDelete() {
showConfirmationDialog(this, {
text: this.hass.localize(
@@ -238,9 +273,24 @@ export default class HaAutomationConditionRow extends LitElement {
return [
haStyle,
css`
.disabled {
opacity: 0.5;
pointer-events: none;
}
.card-content {
padding-top: 16px;
margin-top: 0;
}
.disabled-bar {
background: var(--divider-color, #e0e0e0);
text-align: center;
border-top-right-radius: var(--ha-card-border-radius);
border-top-left-radius: var(--ha-card-border-radius);
}
.card-menu {
float: right;
z-index: 3;
margin: 4px;
--mdc-theme-text-primary-on-background: var(--primary-text-color);
display: flex;
align-items: center;
+2 -1
View File
@@ -1,8 +1,9 @@
import { object, optional, number, string } from "superstruct";
import { object, optional, number, string, boolean } from "superstruct";
export const baseTriggerStruct = object({
platform: string(),
id: optional(string()),
enabled: optional(boolean()),
});
export const forDictStruct = object({
@@ -3,7 +3,7 @@ import "@material/mwc-list/mwc-list-item";
import { mdiDotsVertical } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
@@ -16,7 +16,7 @@ import "../../../../components/ha-alert";
import "../../../../components/ha-button-menu";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-yaml-editor";
import { HaYamlEditor } from "../../../../components/ha-yaml-editor";
import "../../../../components/ha-select";
import type { HaSelect } from "../../../../components/ha-select";
import "../../../../components/ha-textfield";
@@ -28,6 +28,7 @@ import {
} from "../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "./types/ha-automation-trigger-calendar";
import "./types/ha-automation-trigger-device";
import "./types/ha-automation-trigger-event";
import "./types/ha-automation-trigger-geo_location";
@@ -44,6 +45,7 @@ import "./types/ha-automation-trigger-webhook";
import "./types/ha-automation-trigger-zone";
const OPTIONS = [
"calendar",
"device",
"event",
"state",
@@ -102,6 +104,8 @@ export default class HaAutomationTriggerRow extends LitElement {
@state() private _triggerColor = false;
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;
private _triggerUnsub?: Promise<UnsubscribeFunc>;
private _processedTypes = memoizeOne(
@@ -124,40 +128,60 @@ export default class HaAutomationTriggerRow extends LitElement {
return html`
<ha-card>
<div class="card-content">
<div class="card-menu">
<ha-button-menu corner="BOTTOM_START" @action=${this._handleAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<mwc-list-item>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)}
</mwc-list-item>
<mwc-list-item .disabled=${selected === -1}>
${yamlMode
? this.hass.localize(
"ui.panel.config.automation.editor.edit_ui"
)
: this.hass.localize(
"ui.panel.config.automation.editor.edit_yaml"
)}
</mwc-list-item>
<mwc-list-item>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
</mwc-list-item>
<mwc-list-item class="warning">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
)}
</mwc-list-item>
</ha-button-menu>
</div>
${this.trigger.enabled === false
? html`<div class="disabled-bar">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.disabled"
)}
</div>`
: ""}
<div class="card-menu">
<ha-button-menu corner="BOTTOM_START" @action=${this._handleAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<mwc-list-item>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)}
</mwc-list-item>
<mwc-list-item .disabled=${selected === -1}>
${yamlMode
? this.hass.localize(
"ui.panel.config.automation.editor.edit_ui"
)
: this.hass.localize(
"ui.panel.config.automation.editor.edit_yaml"
)}
</mwc-list-item>
<mwc-list-item>
${this.hass.localize(
"ui.panel.config.automation.editor.actions.duplicate"
)}
</mwc-list-item>
<mwc-list-item>
${this.trigger.enabled === false
? this.hass.localize(
"ui.panel.config.automation.editor.actions.enable"
)
: this.hass.localize(
"ui.panel.config.automation.editor.actions.disable"
)}
</mwc-list-item>
<mwc-list-item class="warning">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
)}
</mwc-list-item>
</ha-button-menu>
</div>
<div
class="card-content ${this.trigger.enabled === false
? "disabled"
: ""}"
>
${this._warnings
? html`<ha-alert
alert-type="warning"
@@ -212,7 +236,6 @@ export default class HaAutomationTriggerRow extends LitElement {
`
)}
</ha-select>
${showId
? html`
<ha-textfield
@@ -248,7 +271,7 @@ export default class HaAutomationTriggerRow extends LitElement {
`;
}
protected override updated(changedProps: PropertyValues): void {
protected override updated(changedProps: PropertyValues<this>): void {
super.updated(changedProps);
if (changedProps.has("trigger")) {
this._subscribeTrigger();
@@ -345,6 +368,9 @@ export default class HaAutomationTriggerRow extends LitElement {
fireEvent(this, "duplicate");
break;
case 3:
this._onDisable();
break;
case 4:
this._onDelete();
break;
}
@@ -363,6 +389,15 @@ export default class HaAutomationTriggerRow extends LitElement {
});
}
private _onDisable() {
const enabled = !(this.trigger.enabled ?? true);
const value = { ...this.trigger, enabled };
fireEvent(this, "value-changed", { value });
if (this._yamlMode) {
this._yamlEditor?.setValue(value);
}
}
private _typeChanged(ev: CustomEvent) {
const type = (ev.target as HaSelect).value;
@@ -437,10 +472,27 @@ export default class HaAutomationTriggerRow extends LitElement {
return [
haStyle,
css`
.disabled {
opacity: 0.5;
pointer-events: none;
}
.card-content {
padding-top: 16px;
margin-top: 0;
}
.disabled-bar {
background: var(--divider-color, #e0e0e0);
text-align: center;
border-top-right-radius: var(--ha-card-border-radius);
border-top-left-radius: var(--ha-card-border-radius);
}
.card-menu {
float: right;
z-index: 3;
margin: 4px;
--mdc-theme-text-primary-on-background: var(--primary-text-color);
display: flex;
align-items: center;
}
:host-context([style*="direction: rtl;"]) .card-menu {
float: left;
@@ -0,0 +1,79 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { CalendarTrigger } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
import type { TriggerElement } from "../ha-automation-trigger-row";
import type { HaFormSchema } from "../../../../../components/ha-form/types";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
@customElement("ha-automation-trigger-calendar")
export class HaCalendarTrigger extends LitElement implements TriggerElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trigger!: CalendarTrigger;
private _schema = memoizeOne((localize: LocalizeFunc) => [
{
name: "entity_id",
required: true,
selector: { entity: { domain: "calendar" } },
},
{
name: "event",
type: "select",
required: true,
options: [
[
"start",
localize(
"ui.panel.config.automation.editor.triggers.type.calendar.start"
),
],
[
"end",
localize(
"ui.panel.config.automation.editor.triggers.type.calendar.end"
),
],
],
},
]);
public static get defaultConfig() {
return {
event: "start" as CalendarTrigger["event"],
};
}
protected render() {
const schema = this._schema(this.hass.localize);
return html`
<ha-form
.schema=${schema}
.data=${this.trigger}
.hass=${this.hass}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
const newTrigger = ev.detail.value;
fireEvent(this, "value-changed", { value: newTrigger });
}
private _computeLabelCallback = (schema: HaFormSchema): string =>
this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.calendar.${schema.name}`
);
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-trigger-calendar": HaCalendarTrigger;
}
}
+11 -4
View File
@@ -80,6 +80,7 @@ class HaConfigBackup extends LitElement {
actions: {
title: "",
width: "15%",
type: "overflow-menu",
template: (_: string, backup: BackupContent) =>
html`<ha-icon-overflow-menu
.hass=${this.hass}
@@ -126,17 +127,23 @@ class HaConfigBackup extends LitElement {
return html`
<hass-tabs-subpage-data-table
.tabs=${[
{
translationKey: "ui.panel.config.backup.caption",
path: `/config/backup`,
},
]}
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/system"
.route=${this.route}
.columns=${this._columns(this.narrow, this.hass.language)}
.data=${this._getItems(this._backupData.backups)}
.noDataText=${this.hass.localize("ui.panel.config.backup.no_bakcups")}
.noDataText=${this.hass.localize("ui.panel.config.backup.no_backups")}
.searchLabel=${this.hass.localize(
"ui.panel.config.backup.picker.search"
)}
>
<span slot="header"
>${this.hass.localize("ui.panel.config.backup.caption")}</span
>
<ha-fab
slot="fab"
?disabled=${this._backupData.backing_up}
@@ -224,7 +224,7 @@ class HaBlueprintOverview extends LitElement {
.narrow=${this.narrow}
back-path="/config"
.route=${this.route}
.tabs=${configSections.blueprints}
.tabs=${configSections.automations}
.columns=${this._columns(this.narrow, this.hass.language)}
.data=${this._processedBlueprints(this.blueprints)}
id="entity_id"
@@ -58,9 +58,9 @@ class ConfigAnalytics extends LitElement {
"ui.panel.config.core.section.core.core_config.save_button"
)}
</mwc-button>
${analyticsLearnMore(this.hass)}
</div>
</ha-card>
<div class="footer">${analyticsLearnMore(this.hass)}</div>
`;
}
@@ -110,13 +110,19 @@ class ConfigAnalytics extends LitElement {
ha-settings-row {
padding: 0;
}
p {
margin-top: 0;
}
.card-actions {
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
align-items: center;
}
.footer {
padding: 32px 0 16px;
text-align: center;
}
`, // row-reverse so we tab first to "save"
];
}
@@ -1,342 +0,0 @@
import "@material/mwc-button/mwc-button";
import "@polymer/paper-input/paper-input";
import type { PaperInputElement } from "@polymer/paper-input/paper-input";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { UNIT_C } from "../../../common/const";
import { createCurrencyListEl } from "../../../components/currency-datalist";
import "../../../components/ha-card";
import "../../../components/map/ha-locations-editor";
import type { MarkerLocation } from "../../../components/map/ha-locations-editor";
import { createTimezoneListEl } from "../../../components/timezone-datalist";
import { ConfigUpdateValues, saveCoreConfig } from "../../../data/core";
import { SYMBOL_TO_ISO } from "../../../data/currency";
import type { PolymerChangedEvent } from "../../../polymer-types";
import type { HomeAssistant } from "../../../types";
import "../../../components/ha-formfield";
import "../../../components/ha-radio";
import type { HaRadio } from "../../../components/ha-radio";
@customElement("ha-config-core-form")
class ConfigCoreForm extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _working = false;
@state() private _location?: [number, number];
@state() private _currency?: string;
@state() private _elevation?: string;
@state() private _unitSystem?: ConfigUpdateValues["unit_system"];
@state() private _timeZone?: string;
protected render(): TemplateResult {
const canEdit = ["storage", "default"].includes(
this.hass.config.config_source
);
const disabled = this._working || !canEdit;
return html`
<ha-card
.header=${this.hass.localize(
"ui.panel.config.core.section.core.form.heading"
)}
>
<div class="card-content">
${!canEdit
? html`
<p>
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.edit_requires_storage"
)}
</p>
`
: ""}
<div class="row">
<ha-locations-editor
class="flex"
.hass=${this.hass}
.locations=${this._markerLocation(
this.hass.config.latitude,
this.hass.config.longitude,
this._location
)}
@location-updated=${this._locationChanged}
></ha-locations-editor>
</div>
<div class="row">
<div class="flex">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.time_zone"
)}
</div>
<paper-input
class="flex"
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.time_zone"
)}
name="timeZone"
list="timezones"
.disabled=${disabled}
.value=${this._timeZoneValue}
@value-changed=${this._handleChange}
></paper-input>
</div>
<div class="row">
<div class="flex">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.elevation"
)}
</div>
<paper-input
class="flex"
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.elevation"
)}
name="elevation"
type="number"
.disabled=${disabled}
.value=${this._elevationValue}
@value-changed=${this._handleChange}
>
<span slot="suffix">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.elevation_meters"
)}
</span>
</paper-input>
</div>
<div class="row">
<div class="flex">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.unit_system"
)}
</div>
<div class="radio-group">
<ha-formfield
.label=${html`${this.hass.localize(
"ui.panel.config.core.section.core.core_config.unit_system_metric"
)}
<div class="secondary">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.metric_example"
)}
</div>`}
>
<ha-radio
name="unit_system"
value="metric"
.checked=${this._unitSystemValue === "metric"}
@change=${this._unitSystemChanged}
.disabled=${this._working}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${html`${this.hass.localize(
"ui.panel.config.core.section.core.core_config.unit_system_imperial"
)}
<div class="secondary">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.imperial_example"
)}
</div>`}
>
<ha-radio
name="unit_system"
value="imperial"
.checked=${this._unitSystemValue === "imperial"}
@change=${this._unitSystemChanged}
.disabled=${this._working}
></ha-radio>
</ha-formfield>
</div>
</div>
<div class="row">
<div class="flex">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.currency"
)}<br />
<a
href="https://en.wikipedia.org/wiki/ISO_4217#Active_codes"
target="_blank"
rel="noopener noreferrer"
>${this.hass.localize(
"ui.panel.config.core.section.core.core_config.find_currency_value"
)}</a
>
</div>
<paper-input
class="flex"
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.currency"
)}
name="currency"
list="currencies"
.disabled=${disabled}
.value=${this._currencyValue}
@value-changed=${this._handleChange}
></paper-input>
</div>
</div>
<div class="card-actions">
<mwc-button @click=${this._save} .disabled=${disabled}>
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.save_button"
)}
</mwc-button>
</div>
</ha-card>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
const tzInput = this.shadowRoot!.querySelector(
"[name=timeZone]"
) as PaperInputElement;
tzInput.inputElement.appendChild(createTimezoneListEl());
const cInput = this.shadowRoot!.querySelector(
"[name=currency]"
) as PaperInputElement;
cInput.inputElement.appendChild(createCurrencyListEl());
}
private _markerLocation = memoizeOne(
(
lat: number,
lng: number,
location?: [number, number]
): MarkerLocation[] => [
{
id: "location",
latitude: location ? location[0] : lat,
longitude: location ? location[1] : lng,
location_editable: true,
},
]
);
private get _currencyValue() {
return this._currency !== undefined
? this._currency
: this.hass.config.currency;
}
private get _elevationValue() {
return this._elevation !== undefined
? this._elevation
: this.hass.config.elevation;
}
private get _timeZoneValue() {
return this._timeZone !== undefined
? this._timeZone
: this.hass.config.time_zone;
}
private get _unitSystemValue() {
return this._unitSystem !== undefined
? this._unitSystem
: this.hass.config.unit_system.temperature === UNIT_C
? "metric"
: "imperial";
}
private _handleChange(ev: PolymerChangedEvent<string>) {
const target = ev.currentTarget as PaperInputElement;
let value = target.value;
if (target.name === "currency" && value) {
if (value in SYMBOL_TO_ISO) {
value = SYMBOL_TO_ISO[value];
}
}
this[`_${target.name}`] = value;
}
private _locationChanged(ev) {
this._location = ev.detail.location;
}
private _unitSystemChanged(ev: CustomEvent) {
this._unitSystem = (ev.target as HaRadio).value as "metric" | "imperial";
}
private async _save() {
this._working = true;
try {
const location = this._location || [
this.hass.config.latitude,
this.hass.config.longitude,
];
await saveCoreConfig(this.hass, {
latitude: location[0],
longitude: location[1],
currency: this._currencyValue,
elevation: Number(this._elevationValue),
unit_system: this._unitSystemValue,
time_zone: this._timeZoneValue,
});
} catch (err: any) {
alert(`Error saving config: ${err.message}`);
} finally {
this._working = false;
}
}
static get styles(): CSSResultGroup {
return css`
.row {
display: flex;
flex-direction: row;
margin: 0 -8px;
align-items: center;
}
.secondary {
color: var(--secondary-text-color);
}
.flex {
flex: 1;
}
.row > * {
margin: 0 8px;
}
.radio-group {
display: flex;
flex-direction: column;
flex: 1;
}
.card-actions {
text-align: right;
}
a {
color: var(--primary-color);
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-core-form": ConfigCoreForm;
}
}
-57
View File
@@ -1,57 +0,0 @@
import "@polymer/app-layout/app-header/app-header";
import "@polymer/app-layout/app-toolbar/app-toolbar";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import "../../../layouts/hass-subpage";
import LocalizeMixin from "../../../mixins/localize-mixin";
import "../../../styles/polymer-ha-style";
import "./ha-config-core-form";
import "./ha-config-name-form";
/*
* @appliesMixin LocalizeMixin
*/
class HaConfigCore extends LocalizeMixin(PolymerElement) {
static get template() {
return html`
<style include="iron-flex ha-style">
.content {
padding: 28px 20px 0;
max-width: 1040px;
margin: 0 auto;
}
ha-config-name-form,
ha-config-core-form {
display: block;
margin-top: 24px;
}
</style>
<hass-subpage
hass="[[hass]]"
narrow="[[narrow]]"
header="[[localize('ui.panel.config.core.caption')]]"
back-path="/config/system"
>
<div class="content">
<ha-config-name-form hass="[[hass]]"></ha-config-name-form>
<ha-config-core-form hass="[[hass]]"></ha-config-core-form>
</div>
</hass-subpage>
`;
}
static get properties() {
return {
hass: Object,
isWide: Boolean,
narrow: Boolean,
showAdvanced: Boolean,
route: Object,
};
}
}
customElements.define("ha-config-core", HaConfigCore);
@@ -1,97 +0,0 @@
import "@material/mwc-button/mwc-button";
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../components/ha-card";
import { ConfigUpdateValues, saveCoreConfig } from "../../../data/core";
import type { HomeAssistant } from "../../../types";
import "../../../components/ha-textfield";
import type { HaTextField } from "../../../components/ha-textfield";
@customElement("ha-config-name-form")
class ConfigNameForm extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _working = false;
@state() private _name!: ConfigUpdateValues["location_name"];
protected render(): TemplateResult {
const canEdit = ["storage", "default"].includes(
this.hass.config.config_source
);
const disabled = this._working || !canEdit;
return html`
<ha-card>
<div class="card-content">
${!canEdit
? html`
<p>
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.edit_requires_storage"
)}
</p>
`
: ""}
<ha-textfield
class="flex"
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.location_name"
)}
.disabled=${disabled}
.value=${this._nameValue}
@change=${this._handleChange}
></ha-textfield>
</div>
<div class="card-actions">
<mwc-button @click=${this._save} .disabled=${disabled}>
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.save_button"
)}
</mwc-button>
</div>
</ha-card>
`;
}
private get _nameValue() {
return this._name !== undefined
? this._name
: this.hass.config.location_name;
}
private _handleChange(ev) {
const target = ev.currentTarget as HaTextField;
this._name = target.value;
}
private async _save() {
this._working = true;
try {
await saveCoreConfig(this.hass, {
location_name: this._nameValue,
});
} catch (err: any) {
alert("FAIL");
} finally {
this._working = false;
}
}
static get styles() {
return css`
.card-actions {
text-align: right;
}
ha-textfield {
display: block;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-name-form": ConfigNameForm;
}
}
@@ -33,6 +33,11 @@ class HaConfigSectionAnalytics extends LitElement {
max-width: 1040px;
margin: 0 auto;
}
ha-config-analytics {
display: block;
max-width: 600px;
margin: 0 auto;
}
`;
}
@@ -0,0 +1,366 @@
import "@material/mwc-list/mwc-list-item";
import timezones from "google-timezones-json";
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { UNIT_C } from "../../../common/const";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { navigate } from "../../../common/navigate";
import "../../../components/buttons/ha-progress-button";
import type { HaProgressButton } from "../../../components/buttons/ha-progress-button";
import { currencies } from "../../../components/currency-datalist";
import "../../../components/ha-card";
import "../../../components/ha-formfield";
import "../../../components/ha-radio";
import type { HaRadio } from "../../../components/ha-radio";
import "../../../components/ha-select";
import "../../../components/ha-settings-row";
import "../../../components/ha-textfield";
import "../../../components/map/ha-locations-editor";
import type { MarkerLocation } from "../../../components/map/ha-locations-editor";
import { ConfigUpdateValues, saveCoreConfig } from "../../../data/core";
import { SYMBOL_TO_ISO } from "../../../data/currency";
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@customElement("ha-config-section-general")
class HaConfigSectionGeneral extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow!: boolean;
@state() private _submitting = false;
@state() private _unitSystem?: ConfigUpdateValues["unit_system"];
@state() private _currency?: string;
@state() private _name?: string;
@state() private _elevation?: number;
@state() private _timeZone?: string;
@state() private _location?: [number, number];
protected render(): TemplateResult {
const canEdit = ["storage", "default"].includes(
this.hass.config.config_source
);
const disabled = this._submitting || !canEdit;
return html`
<hass-subpage
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.core.caption")}
>
<div class="content">
<ha-card outlined>
<div class="card-content">
${!canEdit
? html`
<p>
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.edit_requires_storage"
)}
</p>
`
: ""}
<ha-textfield
name="name"
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.location_name"
)}
.disabled=${disabled}
.value=${this._name}
@change=${this._handleChange}
></ha-textfield>
<ha-select
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.time_zone"
)}
name="timeZone"
fixedMenuPosition
naturalMenuWidth
.disabled=${disabled}
.value=${this._timeZone}
@closed=${stopPropagation}
@change=${this._handleChange}
>
${Object.keys(timezones).map(
(tz) =>
html`<mwc-list-item value=${tz}
>${timezones[tz]}</mwc-list-item
>`
)}
</ha-select>
<ha-textfield
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.elevation"
)}
name="elevation"
type="number"
.disabled=${disabled}
.value=${this._elevation}
@change=${this._handleChange}
>
<span slot="suffix">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.elevation_meters"
)}
</span>
</ha-textfield>
<div>
<div>
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.unit_system"
)}
</div>
<ha-formfield
.label=${html`
<span style="font-size: 14px">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.metric_example"
)}
</span>
<div style="color: var(--secondary-text-color)">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.unit_system_metric"
)}
</div>
`}
>
<ha-radio
name="unit_system"
value="metric"
.checked=${this._unitSystem === "metric"}
@change=${this._unitSystemChanged}
.disabled=${this._submitting}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${html`
<span style="font-size: 14px">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.imperial_example"
)}
</span>
<div style="color: var(--secondary-text-color)">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.unit_system_imperial"
)}
</div>
`}
>
<ha-radio
name="unit_system"
value="imperial"
.checked=${this._unitSystem === "imperial"}
@change=${this._unitSystemChanged}
.disabled=${this._submitting}
></ha-radio>
</ha-formfield>
</div>
<div>
<ha-select
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.currency"
)}
name="currency"
fixedMenuPosition
naturalMenuWidth
.disabled=${disabled}
.value=${this._currency}
@closed=${stopPropagation}
@change=${this._handleChange}
>
${currencies.map(
(currency) =>
html`<mwc-list-item .value=${currency}
>${currency}</mwc-list-item
>`
)}</ha-select
>
<a
href="https://en.wikipedia.org/wiki/ISO_4217#Active_codes"
target="_blank"
rel="noopener noreferrer"
class="find-value"
>${this.hass.localize(
"ui.panel.config.core.section.core.core_config.find_currency_value"
)}</a
>
</div>
</div>
${this.narrow
? html`
<ha-locations-editor
.hass=${this.hass}
.locations=${this._markerLocation(
this.hass.config.latitude,
this.hass.config.longitude,
this._location
)}
@location-updated=${this._locationChanged}
></ha-locations-editor>
`
: html`
<ha-settings-row>
<div slot="heading">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.edit_location"
)}
</div>
<div slot="description" class="secondary">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.edit_location_description"
)}
</div>
<mwc-button @click=${this._editLocation}
>${this.hass.localize("ui.common.edit")}</mwc-button
>
</ha-settings-row>
`}
<div class="card-actions">
<ha-progress-button @click=${this._updateEntry}>
${this.hass!.localize("ui.panel.config.zone.detail.update")}
</ha-progress-button>
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
protected firstUpdated(): void {
this._unitSystem =
this.hass.config.unit_system.temperature === UNIT_C
? "metric"
: "imperial";
this._currency = this.hass.config.currency;
this._elevation = this.hass.config.elevation;
this._timeZone = this.hass.config.time_zone;
this._name = this.hass.config.location_name;
}
private _handleChange(ev) {
const target = ev.currentTarget;
let value = target.value;
if (target.name === "currency" && value) {
if (value in SYMBOL_TO_ISO) {
value = SYMBOL_TO_ISO[value];
}
}
this[`_${target.name}`] = value;
}
private _unitSystemChanged(ev: CustomEvent) {
this._unitSystem = (ev.target as HaRadio).value as "metric" | "imperial";
}
private _locationChanged(ev: CustomEvent) {
this._location = ev.detail.location;
}
private async _updateEntry(ev: CustomEvent) {
const button = ev.target as HaProgressButton;
if (button.progress) {
return;
}
button.progress = true;
try {
await saveCoreConfig(this.hass, {
currency: this._currency,
elevation: Number(this._elevation),
unit_system: this._unitSystem,
time_zone: this._timeZone,
location_name: this._name,
});
button.actionSuccess();
} catch (err: any) {
button.actionError();
alert(`Error saving config: ${err.message}`);
} finally {
button.progress = false;
}
}
private _markerLocation = memoizeOne(
(
lat: number,
lng: number,
location?: [number, number]
): MarkerLocation[] => [
{
id: "location",
latitude: location ? location[0] : lat,
longitude: location ? location[1] : lng,
location_editable: true,
},
]
);
private _editLocation() {
navigate("/config/zone");
}
static styles = [
haStyle,
css`
.content {
padding: 28px 20px 0;
max-width: 1040px;
margin: 0 auto;
}
ha-card {
max-width: 600px;
margin: 0 auto;
height: 100%;
justify-content: space-between;
flex-direction: column;
display: flex;
}
.card-content {
display: flex;
justify-content: space-between;
flex-direction: column;
padding: 16px 16px 0 16px;
}
.card-actions {
text-align: right;
height: 48px;
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 16px;
}
.card-content > * {
display: block;
margin-top: 16px;
}
ha-select {
display: block;
}
a.find-value {
margin-top: 8px;
display: inline-block;
}
ha-locations-editor {
display: block;
height: 400px;
padding: 16px;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-section-general": HaConfigSectionGeneral;
}
}
@@ -1,40 +0,0 @@
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../types";
import "./ha-config-analytics";
@customElement("ha-config-section-storage")
class HaConfigSectionStorage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public route!: Route;
@property({ type: Boolean }) public narrow!: boolean;
protected render(): TemplateResult {
return html`
<hass-subpage
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
>
<div class="content"></div>
</hass-subpage>
`;
}
static styles = css`
.content {
padding: 28px 20px 0;
max-width: 1040px;
margin: 0 auto;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-section-storage": HaConfigSectionStorage;
}
}
@@ -0,0 +1,208 @@
import type { ActionDetail } from "@material/mwc-list";
import "@material/mwc-list/mwc-list-item";
import { mdiDotsVertical, mdiRefresh } from "@mdi/js";
import { HassEntities } from "home-assistant-js-websocket";
import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/ha-alert";
import "../../../components/ha-bar";
import "../../../components/ha-button-menu";
import "../../../components/ha-card";
import "../../../components/ha-metric";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import {
fetchHassioSupervisorInfo,
HassioSupervisorInfo,
reloadSupervisor,
setSupervisorOption,
SupervisorOptions,
} from "../../../data/hassio/supervisor";
import {
checkForEntityUpdates,
filterUpdateEntitiesWithInstall,
} from "../../../data/update";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import "../dashboard/ha-config-updates";
@customElement("ha-config-section-updates")
class HaConfigSectionUpdates extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow!: boolean;
@state() private _showSkipped = false;
@state() private _supervisorInfo?: HassioSupervisorInfo;
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
if (isComponentLoaded(this.hass, "hassio")) {
fetchHassioSupervisorInfo(this.hass).then((data) => {
this._supervisorInfo = data;
});
}
}
protected render(): TemplateResult {
const canInstallUpdates = this._filterUpdateEntitiesWithInstall(
this.hass.states,
this._showSkipped
);
return html`
<hass-subpage
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.updates.caption")}
>
<div slot="toolbar-icon">
<ha-icon-button
.label=${this.hass.localize(
"ui.panel.config.updates.check_updates"
)}
.path=${mdiRefresh}
@click=${this._checkUpdates}
></ha-icon-button>
<ha-button-menu corner="BOTTOM_START" @action=${this._handleAction}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<mwc-list-item id="skipped">
${this._showSkipped
? this.hass.localize("ui.panel.config.updates.hide_skipped")
: this.hass.localize("ui.panel.config.updates.show_skipped")}
</mwc-list-item>
${this._supervisorInfo?.channel !== "dev"
? html`
<mwc-list-item id="beta">
${this._supervisorInfo?.channel === "stable"
? this.hass.localize("ui.panel.config.updates.join_beta")
: this.hass.localize(
"ui.panel.config.updates.leave_beta"
)}
</mwc-list-item>
`
: ""}
</ha-button-menu>
</div>
<div class="content">
<ha-card outlined>
<div class="card-content">
${canInstallUpdates.length
? html`
<ha-config-updates
.hass=${this.hass}
.narrow=${this.narrow}
.updateEntities=${canInstallUpdates}
showAll
></ha-config-updates>
`
: html`
${this.hass.localize("ui.panel.config.updates.no_updates")}
`}
</div>
</ha-card>
</div>
</hass-subpage>
`;
}
private _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
this._showSkipped = !this._showSkipped;
break;
case 1:
this._toggleBeta();
break;
}
}
private async _toggleBeta(): Promise<void> {
if (this._supervisorInfo!.channel === "stable") {
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize("ui.dialogs.join_beta_channel.title"),
text: html`${this.hass.localize("ui.dialogs.join_beta_channel.warning")}
<br />
<b> ${this.hass.localize("ui.dialogs.join_beta_channel.backup")} </b>
<br /><br />
${this.hass.localize("ui.dialogs.join_beta_channel.release_items")}
<ul>
<li>Home Assistant Core</li>
<li>Home Assistant Supervisor</li>
<li>Home Assistant Operating System</li>
</ul>
<br />
${this.hass.localize("ui.dialogs.join_beta_channel.confirm")}`,
confirmText: this.hass.localize("ui.panel.config.updates.join_beta"),
dismissText: this.hass.localize("ui.common.cancel"),
});
if (!confirmed) {
return;
}
}
try {
const data: Partial<SupervisorOptions> = {
channel: this._supervisorInfo!.channel === "stable" ? "beta" : "stable",
};
await setSupervisorOption(this.hass, data);
await reloadSupervisor(this.hass);
} catch (err: any) {
showAlertDialog(this, {
text: extractApiErrorMessage(err),
});
}
}
private async _checkUpdates(): Promise<void> {
checkForEntityUpdates(this, this.hass);
}
private _filterUpdateEntitiesWithInstall = memoizeOne(
(entities: HassEntities, showSkipped: boolean) =>
filterUpdateEntitiesWithInstall(entities, showSkipped)
);
static styles = css`
.content {
padding: 28px 20px 0;
max-width: 1040px;
margin: 0 auto;
}
ha-card {
max-width: 600px;
margin: 0 auto;
height: 100%;
justify-content: space-between;
flex-direction: column;
display: flex;
margin-bottom: max(24px, env(safe-area-inset-bottom));
}
.card-content {
display: flex;
justify-content: space-between;
flex-direction: column;
padding: 16px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-section-updates": HaConfigSectionUpdates;
}
}
@@ -1,8 +1,13 @@
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { canShowPage } from "../../../common/config/can_show_page";
import "../../../components/ha-card";
import "../../../components/ha-navigation-list";
import { CloudStatus } from "../../../data/cloud";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@@ -23,49 +28,73 @@ class HaConfigSystemNavigation extends LitElement {
@property({ type: Boolean }) public showAdvanced!: boolean;
protected render(): TemplateResult {
const pages = configSections.general.map((page) => ({
...page,
name: page.translationKey
? this.hass.localize(page.translationKey)
: page.name,
}));
const pages = configSections.general
.filter((page) => canShowPage(this.hass, page))
.map((page) => ({
...page,
name: page.translationKey
? this.hass.localize(page.translationKey)
: page.name,
}));
return html`
<hass-subpage
back-path="/config"
.header=${this.hass.localize("ui.panel.config.dashboard.system.title")}
.header=${this.hass.localize("ui.panel.config.dashboard.system.main")}
>
<mwc-button
slot="toolbar-icon"
.label=${this.hass.localize(
"ui.panel.config.system_dashboard.restart_homeassistant_short"
)}
@click=${this._restart}
></mwc-button>
<ha-config-section
.narrow=${this.narrow}
.isWide=${this.isWide}
full-width
>
<ha-card>
${this.narrow
? html`<div class="title">
${this.hass.localize(
"ui.panel.config.dashboard.system.title"
)}
</div>`
: ""}
<ha-card outlined>
<ha-navigation-list
.hass=${this.hass}
.narrow=${this.narrow}
.pages=${pages}
></ha-navigation-list>
</ha-card>
<div class="yaml-config">Looking for YAML Configuration? It has moved to <a href="/developer-tools/yaml">Developer Tools</a></a></div>
</ha-config-section>
</hass-subpage>
`;
}
private _restart() {
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.system_dashboard.confirm_restart_title"
),
text: this.hass.localize(
"ui.panel.config.system_dashboard.confirm_restart_text"
),
confirmText: this.hass.localize(
"ui.panel.config.system_dashboard.restart_homeassistant_short"
),
confirm: () => {
this.hass.callService("homeassistant", "restart").catch((reason) => {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.system_dashboard.restart_error"
),
text: reason.message,
});
});
},
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
ha-card {
margin-bottom: env(safe-area-inset-bottom);
}
:host(:not([narrow])) ha-card {
margin-bottom: max(24px, env(safe-area-inset-bottom));
}
@@ -78,6 +107,8 @@ class HaConfigSystemNavigation extends LitElement {
ha-card {
overflow: hidden;
margin-bottom: 24px;
margin-bottom: max(24px, env(safe-area-inset-bottom));
}
ha-card a {
@@ -91,17 +122,25 @@ class HaConfigSystemNavigation extends LitElement {
padding-bottom: 0;
}
:host([narrow]) ha-card {
border-radius: 0;
box-shadow: unset;
}
:host([narrow]) ha-config-section {
margin-top: -42px;
@media all and (max-width: 600px) {
ha-card {
border-width: 1px 0;
border-radius: 0;
box-shadow: unset;
}
ha-config-section {
margin-top: -42px;
}
}
ha-navigation-list {
--navigation-list-item-title-font-size: 16px;
--navigation-list-item-padding: 4px;
}
.yaml-config {
margin-bottom: max(env(safe-area-inset-bottom), 24px);
text-align: center;
font-style: italic;
}
`,
];
@@ -1,9 +1,9 @@
import type { ActionDetail } from "@material/mwc-list";
import "@material/mwc-list/mwc-list-item";
import { mdiCloudLock, mdiDotsVertical, mdiMagnify, mdiNewBox } from "@mdi/js";
import { mdiCloudLock, mdiDotsVertical, mdiMagnify } from "@mdi/js";
import "@polymer/app-layout/app-header/app-header";
import "@polymer/app-layout/app-toolbar/app-toolbar";
import type { HassEntities } from "home-assistant-js-websocket";
import { HassEntities } from "home-assistant-js-websocket";
import {
css,
CSSResultGroup,
@@ -15,8 +15,6 @@ import {
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import "../../../components/ha-button-menu";
import "../../../components/ha-card";
import "../../../components/ha-icon-button";
@@ -25,23 +23,25 @@ import "../../../components/ha-menu-button";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tip";
import { CloudStatus } from "../../../data/cloud";
import { updateCanInstall, UpdateEntity } from "../../../data/update";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import {
checkForEntityUpdates,
filterUpdateEntitiesWithInstall,
UpdateEntity,
} from "../../../data/update";
import { showQuickBar } from "../../../dialogs/quick-bar/show-dialog-quick-bar";
import "../../../layouts/ha-app-layout";
import { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
import "../ha-config-section";
import { configSections } from "../ha-panel-config";
import "./ha-config-navigation";
import "./ha-config-updates";
const randomTip = (hass: HomeAssistant) => {
const randomTip = (hass: HomeAssistant, narrow: boolean) => {
const weighted: string[] = [];
const tips = [
let tips = [
{
content: hass.localize(
"ui.panel.config.tips.join",
@@ -81,14 +81,19 @@ const randomTip = (hass: HomeAssistant) => {
rel="noreferrer"
>Newsletter</a
>
<ha-svg-icon class="new" .path=${mdiNewBox}></ha-svg-icon
></span>`
</span>`
),
weight: 2,
narrow: true,
},
{ content: hass.localize("ui.tips.key_c_hint"), weight: 1 },
{ content: hass.localize("ui.tips.key_c_hint"), weight: 1, narrow: false },
{ content: hass.localize("ui.tips.key_m_hint"), weight: 1, narrow: false },
];
if (narrow) {
tips = tips.filter((tip) => tip.narrow);
}
tips.forEach((tip) => {
for (let i = 0; i < tip.weight; i++) {
weighted.push(tip.content);
@@ -113,8 +118,6 @@ class HaConfigDashboard extends LitElement {
@state() private _tip?: string;
private _notifyUpdates = false;
private _pages = memoizeOne((clouStatus, isLoaded) => {
const pages: PageNavigation[] = [];
if (clouStatus && isLoaded) {
@@ -131,9 +134,8 @@ class HaConfigDashboard extends LitElement {
});
protected render(): TemplateResult {
const canInstallUpdates = this._filterUpdateEntitiesWithInstall(
this.hass.states
);
const [canInstallUpdates, totalUpdates] =
this._filterUpdateEntitiesWithInstall(this.hass.states);
return html`
<ha-app-layout>
@@ -173,20 +175,26 @@ class HaConfigDashboard extends LitElement {
full-width
>
${canInstallUpdates.length
? html`<ha-card>
? html`<ha-card outlined>
<ha-config-updates
.hass=${this.hass}
.narrow=${this.narrow}
.total=${totalUpdates}
.updateEntities=${canInstallUpdates}
></ha-config-updates>
${totalUpdates > canInstallUpdates.length
? html`<a class="button" href="/config/updates">
${this.hass.localize(
"ui.panel.config.updates.more_updates",
{
count: totalUpdates - canInstallUpdates.length,
}
)}
</a>`
: ""}
</ha-card>`
: ""}
<ha-card>
${this.narrow && canInstallUpdates.length
? html`<div class="title">
${this.hass.localize("panel.config")}
</div>`
: ""}
<ha-card outlined>
<ha-config-navigation
.hass=${this.hass}
.narrow=${this.narrow}
@@ -207,62 +215,19 @@ class HaConfigDashboard extends LitElement {
super.updated(changedProps);
if (!this._tip && changedProps.has("hass")) {
this._tip = randomTip(this.hass);
}
if (!changedProps.has("hass") || !this._notifyUpdates) {
return;
}
this._notifyUpdates = false;
if (this._filterUpdateEntitiesWithInstall(this.hass.states).length) {
showToast(this, {
message: this.hass.localize(
"ui.panel.config.updates.updates_refreshed"
),
});
} else {
showToast(this, {
message: this.hass.localize("ui.panel.config.updates.no_new_updates"),
});
this._tip = randomTip(this.hass, this.narrow);
}
}
private _filterUpdateEntities = memoizeOne((entities: HassEntities) =>
(
Object.values(entities).filter(
(entity) => computeStateDomain(entity) === "update"
) as UpdateEntity[]
).sort((a, b) => {
if (a.attributes.title === "Home Assistant Core") {
return -3;
}
if (b.attributes.title === "Home Assistant Core") {
return 3;
}
if (a.attributes.title === "Home Assistant Operating System") {
return -2;
}
if (b.attributes.title === "Home Assistant Operating System") {
return 2;
}
if (a.attributes.title === "Home Assistant Supervisor") {
return -1;
}
if (b.attributes.title === "Home Assistant Supervisor") {
return 1;
}
return caseInsensitiveStringCompare(
a.attributes.title || a.attributes.friendly_name || "",
b.attributes.title || b.attributes.friendly_name || ""
);
})
);
private _filterUpdateEntitiesWithInstall = memoizeOne(
(entities: HassEntities) =>
this._filterUpdateEntities(entities).filter((entity) =>
updateCanInstall(entity)
)
(entities: HassEntities): [UpdateEntity[], number] => {
const updates = filterUpdateEntitiesWithInstall(entities);
return [
updates.slice(0, updates.length === 3 ? updates.length : 2),
updates.length,
];
}
);
private _showQuickBar(): void {
@@ -273,27 +238,9 @@ class HaConfigDashboard extends LitElement {
}
private async _handleMenuAction(ev: CustomEvent<ActionDetail>) {
const _entities = this._filterUpdateEntities(this.hass.states).map(
(entity) => entity.entity_id
);
switch (ev.detail.index) {
case 0:
if (_entities.length) {
this._notifyUpdates = true;
await this.hass.callService("homeassistant", "update_entity", {
entity_id: _entities,
});
return;
}
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.updates.no_update_entities.title"
),
text: this.hass.localize(
"ui.panel.config.updates.no_update_entities.description"
),
warning: true,
});
checkForEntityUpdates(this, this.hass);
break;
}
}
@@ -320,18 +267,26 @@ class HaConfigDashboard extends LitElement {
text-decoration: none;
color: var(--primary-text-color);
}
a.button {
display: block;
color: var(--primary-color);
padding: 16px;
}
.title {
font-size: 16px;
padding: 16px;
padding-bottom: 0;
}
:host([narrow]) ha-card {
border-radius: 0;
box-shadow: unset;
}
:host([narrow]) ha-config-section {
margin-top: -42px;
@media all and (max-width: 600px) {
ha-card {
border-width: 1px 0;
border-radius: 0;
box-shadow: unset;
}
ha-config-section {
margin-top: -42px;
}
}
ha-tip {
@@ -1,6 +1,6 @@
import "@material/mwc-list/mwc-list";
import "@material/mwc-list/mwc-list-item";
import { html, LitElement, TemplateResult } from "lit";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { canShowPage } from "../../../common/config/can_show_page";
import "../../../components/ha-card";
@@ -30,7 +30,7 @@ class HaConfigNavigation extends LitElement {
name:
page.name ||
this.hass.localize(
`ui.panel.config.dashboard.${page.translationKey}.title`
`ui.panel.config.dashboard.${page.translationKey}.main`
),
description:
page.component === "cloud" && (page.info as CloudStatus)
@@ -51,7 +51,7 @@ class HaConfigNavigation extends LitElement {
${
page.description ||
this.hass.localize(
`ui.panel.config.dashboard.${page.translationKey}.description`
`ui.panel.config.dashboard.${page.translationKey}.secondary`
)
}
`,
@@ -81,6 +81,12 @@ class HaConfigNavigation extends LitElement {
});
}
}
static styles: CSSResultGroup = css`
ha-navigation-list {
--navigation-list-item-title-font-size: 16px;
}
`;
}
declare global {
@@ -1,14 +1,14 @@
import "@material/mwc-button/mwc-button";
import "@polymer/paper-item/paper-icon-item";
import "@polymer/paper-item/paper-item-body";
import "@material/mwc-list/mwc-list";
import "@material/mwc-list/mwc-list-item";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/entity/state-badge";
import "../../../components/ha-alert";
import "../../../components/ha-icon-next";
import type { UpdateEntity } from "../../../data/update";
import { HomeAssistant } from "../../../types";
import type { HomeAssistant } from "../../../types";
@customElement("ha-config-updates")
class HaConfigUpdates extends LitElement {
@@ -19,62 +19,60 @@ class HaConfigUpdates extends LitElement {
@property({ attribute: false })
public updateEntities?: UpdateEntity[];
@state() private _showAll = false;
@property({ type: Number })
public total?: number;
protected render(): TemplateResult {
if (!this.updateEntities?.length) {
return html``;
}
const updates =
this._showAll || this.updateEntities.length <= 3
? this.updateEntities
: this.updateEntities.slice(0, 2);
const updates = this.updateEntities;
return html`
<div class="title">
${this.hass.localize("ui.panel.config.updates.title", {
count: this.updateEntities.length,
count: this.total || this.updateEntities.length,
})}
</div>
${updates.map(
(entity) => html`
<paper-icon-item
@click=${this._openMoreInfo}
.entity_id=${entity.entity_id}
>
<span slot="item-icon" class="icon">
<mwc-list>
${updates.map(
(entity) => html`
<mwc-list-item
twoline
graphic="avatar"
class=${entity.attributes.skipped_version ? "skipped" : ""}
.entity_id=${entity.entity_id}
.hasMeta=${!this.narrow}
@click=${this._openMoreInfo}
>
<state-badge
slot="graphic"
.title=${entity.attributes.title ||
entity.attributes.friendly_name}
.stateObj=${entity}
slot="item-icon"
></state-badge>
</span>
<paper-item-body two-line>
${entity.attributes.title || entity.attributes.friendly_name}
<div secondary>
<span
>${entity.attributes.title ||
entity.attributes.friendly_name}</span
>
<span slot="secondary">
${this.hass.localize(
"ui.panel.config.updates.version_available",
{
version_available: entity.attributes.latest_version,
}
)}
</div>
</paper-item-body>
${!this.narrow ? html`<ha-icon-next></ha-icon-next>` : ""}
</paper-icon-item>
`
)}
${!this._showAll && this.updateEntities.length >= 4
? html`
<button class="show-more" @click=${this._showAllClicked}>
${this.hass.localize("ui.panel.config.updates.more_updates", {
count: this.updateEntities!.length - updates.length,
})}
</button>
)}${entity.attributes.skipped_version
? `(${this.hass.localize("ui.panel.config.updates.skipped")})`
: ""}
</span>
${!this.narrow
? html`<ha-icon-next slot="meta"></ha-icon-next>`
: ""}
</mwc-list-item>
`
: ""}
)}
</mwc-list>
`;
}
@@ -84,22 +82,19 @@ class HaConfigUpdates extends LitElement {
});
}
private _showAllClicked() {
this._showAll = true;
}
static get styles(): CSSResultGroup[] {
return [
css`
:host {
--mdc-list-vertical-padding: 0;
}
.title {
font-size: 16px;
padding: 16px;
padding-bottom: 0;
}
.icon {
display: inline-flex;
height: 100%;
align-items: center;
.skipped {
background: var(--secondary-background-color);
}
ha-icon-next {
color: var(--secondary-text-color);
@@ -122,8 +117,9 @@ class HaConfigUpdates extends LitElement {
outline: none;
text-decoration: underline;
}
paper-icon-item {
mwc-list-item {
cursor: pointer;
font-size: 16px;
}
`,
];
@@ -1,5 +1,5 @@
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { property } from "lit/decorators";
import { css, html, LitElement, TemplateResult } from "lit";
import { property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-card";
import "../../../../components/ha-chip";
@@ -10,6 +10,7 @@ import {
DeviceAutomation,
} from "../../../../data/device_automation";
import { showScriptEditor } from "../../../../data/script";
import { buttonLinkStyle } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
declare global {
@@ -29,6 +30,8 @@ export abstract class HaDeviceAutomationCard<
@property() public automations: T[] = [];
@state() public _showSecondary = false;
protected headerKey = "";
protected type = "";
@@ -60,28 +63,47 @@ export abstract class HaDeviceAutomationCard<
if (this.automations.length === 0) {
return html``;
}
const automations = this._showSecondary
? this.automations
: this.automations.filter(
(automation) => automation.metadata?.secondary === false
);
return html`
<h3>${this.hass.localize(this.headerKey)}</h3>
<div class="content">
<ha-chip-set>
${this.automations.map(
${automations.map(
(automation, idx) =>
html`
<ha-chip .index=${idx} @click=${this._handleAutomationClicked}>
<ha-chip
.index=${idx}
@click=${this._handleAutomationClicked}
class=${automation.metadata?.secondary ? "secondary" : ""}
>
${this._localizeDeviceAutomation(this.hass, automation)}
</ha-chip>
`
)}
</ha-chip-set>
${!this._showSecondary && automations.length < this.automations.length
? html`<button class="link" @click=${this._toggleSecondary}>
Show ${this.automations.length - automations.length} more...
</button>`
: ""}
</div>
`;
}
private _toggleSecondary() {
this._showSecondary = !this._showSecondary;
}
private _handleAutomationClicked(ev: CustomEvent) {
const automation = this.automations[(ev.currentTarget as any).index];
const automation = { ...this.automations[(ev.currentTarget as any).index] };
if (!automation) {
return;
}
delete automation.metadata;
if (this.script) {
showScriptEditor({ sequence: [automation as DeviceAction] });
fireEvent(this, "entry-selected");
@@ -93,11 +115,18 @@ export abstract class HaDeviceAutomationCard<
fireEvent(this, "entry-selected");
}
static get styles(): CSSResultGroup {
return css`
static styles = [
buttonLinkStyle,
css`
h3 {
color: var(--primary-text-color);
}
`;
}
.secondary {
--ha-chip-background-color: rgba(var(--rgb-primary-text-color), 0.07);
}
button.link {
color: var(--primary-color);
}
`,
];
}
@@ -10,6 +10,7 @@ import {
fetchDeviceActions,
fetchDeviceConditions,
fetchDeviceTriggers,
sortDeviceAutomations,
} from "../../../../data/device_automation";
import { haStyleDialog } from "../../../../resources/styles";
import { HomeAssistant } from "../../../../types";
@@ -63,16 +64,16 @@ export class DialogDeviceAutomation extends LitElement {
const { device, script } = this._params;
fetchDeviceActions(this.hass, device.id).then((actions) => {
this._actions = actions;
this._actions = actions.sort(sortDeviceAutomations);
});
if (script) {
return;
}
fetchDeviceTriggers(this.hass, device.id).then((triggers) => {
this._triggers = triggers;
this._triggers = triggers.sort(sortDeviceAutomations);
});
fetchDeviceConditions(this.hass, device.id).then((conditions) => {
this._conditions = conditions;
this._conditions = conditions.sort(sortDeviceAutomations);
});
}
@@ -197,7 +197,10 @@ export class HaConfigDeviceDashboard extends LitElement {
),
model: device.model || "<unknown>",
manufacturer: device.manufacturer || "<unknown>",
area: device.area_id ? areaLookup[device.area_id].name : "—",
area:
device.area_id && areaLookup[device.area_id]
? areaLookup[device.area_id].name
: "—",
integration: device.config_entries.length
? device.config_entries
.filter((entId) => entId in entryLookup)
@@ -276,6 +276,7 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
@selected=${this._deviceClassChanged}
@closed=${stopPropagation}
>
<mwc-list-item></mwc-list-item>
${this._deviceClassesSorted(
domain,
this._deviceClassOptions[0],
@@ -355,6 +356,25 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
)}
</ha-select>`
: ""}
${this._helperConfigEntry
? html`
<div class="row">
<mwc-button
@click=${this._showOptionsFlow}
.disabled=${this._submitting}
>
${this.hass.localize(
"ui.dialogs.entity_registry.editor.configure_state",
"integration",
domainToName(
this.hass.localize,
this._helperConfigEntry.domain
)
)}
</mwc-button>
</div>
`
: ""}
<ha-textfield
error-message="Domain needs to stay the same"
.value=${this._entityId}
@@ -372,20 +392,6 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
@value-changed=${this._areaPicked}
></ha-area-picker>`
: ""}
${this._helperConfigEntry
? html`
<div class="row">
<mwc-button
@click=${this._showOptionsFlow}
.disabled=${this._submitting}
>
${this.hass.localize(
"ui.dialogs.entity_registry.editor.configure_state"
)}
</mwc-button>
</div>
`
: ""}
<ha-expansion-panel
.header=${this.hass.localize(
+58 -46
View File
@@ -4,14 +4,15 @@ import {
mdiBadgeAccountHorizontal,
mdiCellphoneCog,
mdiCog,
mdiCpu32Bit,
mdiDatabase,
mdiDevices,
mdiHomeAssistant,
mdiHeart,
mdiInformation,
mdiInformationOutline,
mdiLightningBolt,
mdiMapMarkerRadius,
mdiMathLog,
mdiMemory,
mdiNetwork,
mdiNfcVariant,
mdiPalette,
@@ -19,7 +20,6 @@ import {
mdiPuzzle,
mdiRobot,
mdiScriptText,
mdiServer,
mdiShape,
mdiSofa,
mdiTools,
@@ -68,13 +68,6 @@ export const configSections: { [name: string]: PageNavigation[] } = {
iconColor: "#E48629",
components: ["zone"],
},
{
path: "/config/backup",
translationKey: "backup",
iconPath: mdiBackupRestore,
iconColor: "#4084CD",
component: "backup",
},
{
path: "/hassio",
translationKey: "supervisor",
@@ -100,7 +93,7 @@ export const configSections: { [name: string]: PageNavigation[] } = {
path: "/config/person",
translationKey: "people",
iconPath: mdiAccount,
iconColor: "#E48629",
iconColor: "#5A87FA",
components: ["person", "users"],
},
{
@@ -231,14 +224,14 @@ export const configSections: { [name: string]: PageNavigation[] } = {
path: "/config/person",
translationKey: "ui.panel.config.person.caption",
iconPath: mdiAccount,
iconColor: "#E48629",
iconColor: "#5A87FA",
},
{
component: "users",
path: "/config/users",
translationKey: "ui.panel.config.users.caption",
iconPath: mdiBadgeAccountHorizontal,
iconColor: "#E48629",
iconColor: "#5A87FA",
core: true,
advancedOnly: true,
},
@@ -262,48 +255,46 @@ export const configSections: { [name: string]: PageNavigation[] } = {
],
general: [
{
component: "core",
path: "/config/core",
path: "/config/general",
translationKey: "ui.panel.config.core.caption",
iconPath: mdiHomeAssistant,
iconColor: "#4A5963",
iconPath: mdiCog,
iconColor: "#653249",
core: true,
},
{
component: "server_control",
path: "/config/server_control",
translationKey: "ui.panel.config.server_control.caption",
iconPath: mdiServer,
iconColor: "#4A5963",
core: true,
path: "/config/updates",
translationKey: "ui.panel.config.updates.caption",
iconPath: mdiUpdate,
iconColor: "#3B808E",
},
{
component: "logs",
path: "/config/logs",
translationKey: "ui.panel.config.logs.caption",
iconPath: mdiMathLog,
iconColor: "#4A5963",
iconColor: "#C65326",
core: true,
},
{
path: "/config/backup",
translationKey: "ui.panel.config.backup.caption",
iconPath: mdiBackupRestore,
iconColor: "#4084CD",
iconColor: "#0D47A1",
component: "backup",
},
{
path: "/hassio/backups",
translationKey: "ui.panel.config.backup.caption",
iconPath: mdiBackupRestore,
iconColor: "#0D47A1",
component: "hassio",
},
{
path: "/config/analytics",
translationKey: "ui.panel.config.analytics.caption",
iconPath: mdiShape,
iconColor: "#f1c447",
},
{
path: "/config/hardware",
translationKey: "ui.panel.config.hardware.caption",
iconPath: mdiCpu32Bit,
iconColor: "#4A5963",
},
{
path: "/config/network",
translationKey: "ui.panel.config.network.caption",
@@ -313,14 +304,23 @@ export const configSections: { [name: string]: PageNavigation[] } = {
{
path: "/config/storage",
translationKey: "ui.panel.config.storage.caption",
iconPath: mdiServer,
iconPath: mdiDatabase,
iconColor: "#518C43",
component: "hassio",
},
{
path: "/config/update",
translationKey: "ui.panel.config.updates.caption",
iconPath: mdiUpdate,
iconColor: "#4A5963",
path: "/config/hardware",
translationKey: "ui.panel.config.hardware.caption",
iconPath: mdiMemory,
iconColor: "#301A8E",
component: "hassio",
},
{
path: "/config/system_health",
translationKey: "ui.panel.config.system_health.caption",
iconPath: mdiHeart,
iconColor: "#507FfE",
components: ["system_health", "hassio"],
},
],
about: [
@@ -374,10 +374,6 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-cloud",
load: () => import("./cloud/ha-config-cloud"),
},
core: {
tag: "ha-config-core",
load: () => import("./core/ha-config-core"),
},
devices: {
tag: "ha-config-devices",
load: () => import("./devices/ha-config-devices"),
@@ -408,6 +404,10 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-energy",
load: () => import("./energy/ha-config-energy"),
},
hardware: {
tag: "ha-config-hardware",
load: () => import("./hardware/ha-config-hardware"),
},
integrations: {
tag: "ha-config-integrations",
load: () => import("./integrations/ha-config-integrations"),
@@ -418,7 +418,7 @@ class HaPanelConfig extends HassRouterPage {
},
network: {
tag: "ha-config-section-network",
load: () => import("./core/ha-config-section-network"),
load: () => import("./network/ha-config-section-network"),
},
person: {
tag: "ha-config-person",
@@ -436,13 +436,17 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-helpers",
load: () => import("./helpers/ha-config-helpers"),
},
server_control: {
tag: "ha-config-server-control",
load: () => import("./server_control/ha-config-server-control"),
},
storage: {
tag: "ha-config-section-storage",
load: () => import("./core/ha-config-section-storage"),
load: () => import("./storage/ha-config-section-storage"),
},
system_health: {
tag: "ha-config-system-health",
load: () => import("./system-health/ha-config-system-health"),
},
updates: {
tag: "ha-config-section-updates",
load: () => import("./core/ha-config-section-updates"),
},
users: {
tag: "ha-config-users",
@@ -452,6 +456,10 @@ class HaPanelConfig extends HassRouterPage {
tag: "ha-config-zone",
load: () => import("./zone/ha-config-zone"),
},
general: {
tag: "ha-config-section-general",
load: () => import("./core/ha-config-section-general"),
},
zha: {
tag: "zha-config-dashboard-router",
load: () =>
@@ -530,6 +538,10 @@ class HaPanelConfig extends HassRouterPage {
"--app-header-border-bottom",
"1px solid var(--divider-color)"
);
this.style.setProperty(
"--ha-card-border-radius",
"var(--ha-config-card-border-radius, 8px)"
);
}
protected updatePageEl(el) {
@@ -0,0 +1,213 @@
import { mdiClose } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { stringCompare } from "../../../common/string/compare";
import "../../../components/ha-dialog";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-icon-next";
import "../../../components/search-input";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import {
fetchHassioHardwareInfo,
HassioHardwareInfo,
} from "../../../data/hassio/hardware";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { dump } from "../../../resources/js-yaml-dump";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
const _filterDevices = memoizeOne(
(showAdvanced: boolean, hardware: HassioHardwareInfo, filter: string) =>
hardware.devices
.filter(
(device) =>
(showAdvanced ||
["tty", "gpio", "input"].includes(device.subsystem)) &&
(device.by_id?.toLowerCase().includes(filter) ||
device.name.toLowerCase().includes(filter) ||
device.dev_path.toLocaleLowerCase().includes(filter) ||
JSON.stringify(device.attributes)
.toLocaleLowerCase()
.includes(filter))
)
.sort((a, b) => stringCompare(a.name, b.name))
);
@customElement("ha-dialog-hardware-available")
class DialogHardwareAvailable extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _hardware?: HassioHardwareInfo;
@state() private _filter?: string;
public async showDialog(): Promise<Promise<void>> {
try {
this._hardware = await fetchHassioHardwareInfo(this.hass);
} catch (err: any) {
await showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.hardware.available_hardware.failed_to_get"
),
text: extractApiErrorMessage(err),
});
}
}
public closeDialog(): void {
this._hardware = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render(): TemplateResult {
if (!this._hardware) {
return html``;
}
const devices = _filterDevices(
this.hass.userData?.showAdvanced || false,
this._hardware,
(this._filter || "").toLowerCase()
);
return html`
<ha-dialog
open
hideActions
@closed=${this.closeDialog}
.heading=${this.hass.localize(
"ui.panel.config.hardware.available_hardware.title"
)}
>
<div class="header" slot="heading">
<h2>
${this.hass.localize(
"ui.panel.config.hardware.available_hardware.title"
)}
</h2>
<ha-icon-button
.label=${this.hass.localize("common.close")}
.path=${mdiClose}
dialogAction="close"
></ha-icon-button>
<search-input
.hass=${this.hass}
.filter=${this._filter}
@value-changed=${this._handleSearchChange}
.label=${this.hass.localize("common.search")}
>
</search-input>
</div>
${devices.map(
(device) =>
html`
<ha-expansion-panel
.header=${device.name}
.secondary=${device.by_id || undefined}
outlined
>
<div class="device-property">
<span>
${this.hass.localize(
"ui.panel.config.hardware.available_hardware.subsystem"
)}:
</span>
<span>${device.subsystem}</span>
</div>
<div class="device-property">
<span>
${this.hass.localize(
"ui.panel.config.hardware.available_hardware.device_path"
)}:
</span>
<code>${device.dev_path}</code>
</div>
${device.by_id
? html`
<div class="device-property">
<span>
${this.hass.localize(
"ui.panel.config.hardware.available_hardware.id"
)}:
</span>
<code>${device.by_id}</code>
</div>
`
: ""}
<div class="attributes">
<span>
${this.hass.localize(
"ui.panel.config.hardware.available_hardware.attributes"
)}:
</span>
<pre>${dump(device.attributes, { indent: 2 })}</pre>
</div>
</ha-expansion-panel>
`
)}
</ha-dialog>
`;
}
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
ha-icon-button {
position: absolute;
right: 16px;
top: 10px;
text-decoration: none;
color: var(--primary-text-color);
}
h2 {
margin: 18px 42px 0 18px;
color: var(--primary-text-color);
}
ha-expansion-panel {
margin: 4px 0;
}
pre,
code {
background-color: var(--markdown-code-background-color, none);
border-radius: 3px;
}
pre {
padding: 16px;
overflow: auto;
line-height: 1.45;
font-family: var(--code-font-family, monospace);
}
code {
font-size: 85%;
padding: 0.2em 0.4em;
}
search-input {
margin: 8px 16px 0;
display: block;
}
.device-property {
display: flex;
justify-content: space-between;
}
.attributes {
margin-top: 12px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-hardware-available": DialogHardwareAvailable;
}
}
@@ -0,0 +1,257 @@
import "@material/mwc-list/mwc-list-item";
import { mdiDotsVertical } from "@mdi/js";
import { css, html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button-menu";
import "../../../components/ha-card";
import "../../../components/ha-settings-row";
import { BOARD_NAMES } from "../../../data/hardware";
import {
extractApiErrorMessage,
ignoreSupervisorError,
} from "../../../data/hassio/common";
import {
fetchHassioHassOsInfo,
fetchHassioHostInfo,
HassioHassOSInfo,
HassioHostInfo,
rebootHost,
shutdownHost,
} from "../../../data/hassio/host";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { showhardwareAvailableDialog } from "./show-dialog-hardware-available";
@customElement("ha-config-hardware")
class HaConfigHardware extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow!: boolean;
@state() private _error?: { code: string; message: string };
@state() private _OSData?: HassioHassOSInfo;
@state() private _hostData?: HassioHostInfo;
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
if (isComponentLoaded(this.hass, "hassio")) {
this._load();
}
}
protected render(): TemplateResult {
return html`
<hass-subpage
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.hardware.caption")}
>
<ha-button-menu corner="BOTTOM_START" slot="toolbar-icon">
<ha-icon-button
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
slot="trigger"
></ha-icon-button>
<mwc-list-item @click=${this._openHardware}
>${this.hass.localize(
"ui.panel.config.hardware.available_hardware.title"
)}</mwc-list-item
>
</ha-button-menu>
${this._error
? html`
<ha-alert alert-type="error"
>${this._error.message || this._error.code}</ha-alert
>
`
: ""}
${this._OSData || this._hostData
? html`
<div class="content">
<ha-card outlined>
${this._OSData?.board
? html`
<div class="card-content">
<ha-settings-row>
<span slot="heading"
>${BOARD_NAMES[this._OSData.board] ||
this.hass.localize(
"ui.panel.config.hardware.board"
)}</span
>
<div slot="description">
<span class="value">${this._OSData.board}</span>
</div>
</ha-settings-row>
</div>
`
: ""}
${this._hostData
? html`
<div class="card-actions">
${this._hostData.features.includes("reboot")
? html`
<ha-progress-button
class="warning"
@click=${this._hostReboot}
>
${this.hass.localize(
"ui.panel.config.hardware.reboot_host"
)}
</ha-progress-button>
`
: ""}
${this._hostData.features.includes("shutdown")
? html`
<ha-progress-button
class="warning"
@click=${this._hostShutdown}
>
${this.hass.localize(
"ui.panel.config.hardware.shutdown_host"
)}
</ha-progress-button>
`
: ""}
</div>
`
: ""}
</ha-card>
</div>
`
: ""}
</hass-subpage>
`;
}
private async _load() {
try {
this._OSData = await fetchHassioHassOsInfo(this.hass);
this._hostData = await fetchHassioHostInfo(this.hass);
} catch (err: any) {
this._error = err.message || err;
}
}
private async _openHardware() {
showhardwareAvailableDialog(this);
}
private async _hostReboot(ev: CustomEvent): Promise<void> {
const button = ev.currentTarget as any;
button.progress = true;
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.config.hardware.reboot_host"),
text: this.hass.localize("ui.panel.config.hardware.reboot_host_confirm"),
confirmText: this.hass.localize("ui.panel.config.hardware.reboot_host"),
dismissText: this.hass.localize("common.cancel"),
});
if (!confirmed) {
button.progress = false;
return;
}
try {
await rebootHost(this.hass);
} catch (err: any) {
// Ignore connection errors, these are all expected
if (this.hass.connection.connected && !ignoreSupervisorError(err)) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.hardware.failed_to_reboot_host"
),
text: extractApiErrorMessage(err),
});
}
}
button.progress = false;
}
private async _hostShutdown(ev: CustomEvent): Promise<void> {
const button = ev.currentTarget as any;
button.progress = true;
const confirmed = await showConfirmationDialog(this, {
title: this.hass.localize("ui.panel.config.hardware.shutdown_host"),
text: this.hass.localize(
"ui.panel.config.hardware.shutdown_host_confirm"
),
confirmText: this.hass.localize("ui.panel.config.hardware.shutdown_host"),
dismissText: this.hass.localize("common.cancel"),
});
if (!confirmed) {
button.progress = false;
return;
}
try {
await shutdownHost(this.hass);
} catch (err: any) {
// Ignore connection errors, these are all expected
if (this.hass.connection.connected && !ignoreSupervisorError(err)) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.hardware.failed_to_shutdown_host"
),
text: extractApiErrorMessage(err),
});
}
}
button.progress = false;
}
static styles = [
haStyle,
css`
.content {
padding: 28px 20px 0;
max-width: 1040px;
margin: 0 auto;
}
ha-card {
max-width: 600px;
margin: 0 auto;
height: 100%;
justify-content: space-between;
flex-direction: column;
display: flex;
}
.card-content {
display: flex;
justify-content: space-between;
flex-direction: column;
padding: 16px 16px 0 16px;
}
ha-button-menu {
color: var(--secondary-text-color);
--mdc-menu-min-width: 200px;
}
.card-actions {
height: 48px;
display: flex;
justify-content: space-between;
align-items: center;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-config-hardware": HaConfigHardware;
}
}
@@ -0,0 +1,12 @@
import { fireEvent } from "../../../common/dom/fire_event";
export const loadHardwareAvailableDialog = () =>
import("./dialog-hardware-available");
export const showhardwareAvailableDialog = (element: HTMLElement): void => {
fireEvent(element, "show-dialog", {
dialogTag: "ha-dialog-hardware-available",
dialogImport: loadHardwareAvailableDialog,
dialogParams: {},
});
};

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