Compare commits

...

56 Commits

Author SHA1 Message Date
Bram Kragten 1ba71d940d Bumped version to 20260527.6 2026-06-11 15:42:24 +02:00
Aidan Timson 948b7489c2 Gate more info "Add to" button to admins (#52547) 2026-06-11 15:39:19 +02:00
Bram Kragten 370d755a9d Filter expired camera/image proxy requests in service worker (#52534)
Pre-validate the credential on camera_proxy, camera_proxy_stream and
image_proxy URLs before letting them hit core. Requests with a missing
or "undefined" token, or with an authSig JWT whose exp has passed, are
short-circuited to a synthetic 401 and never reach the server.

This silences spurious "Login attempt or request with invalid
authentication" warnings from homeassistant.components.http.ban that
fire when the browser replays a stale <img src> after BFCache restore,
tab resume, or a network change. The signed-path TTL is short (30s by
default) and image elements happily hold onto the URL long after that.

Limitations: service workers only run on secure contexts, so this does
not help users on plain http LAN access. A core-side fix to ban.py
that distinguishes expired-but-validly-signed paths from real login
attempts remains the principled fix and covers all clients.
2026-06-11 15:39:18 +02:00
Bram Kragten 57f0b7dbb7 Don't try to load brand images without a token (#52532) 2026-06-11 15:39:17 +02:00
Marcin Bauer eb17fd4b31 Show condition row icon on mobile in visibility editor (#52527)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 15:39:16 +02:00
Bram Kragten 92461f90d9 Fix camera/image proxy URLs sent with token=undefined (#52514) 2026-06-11 15:39:14 +02:00
Bram Kragten 4a43f22abf Add condition live testing to action conditions too (#52511)
* Add condition live testing to action conditions too

* Update src/panels/config/automation/action/ha-automation-action-row.ts

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Apply prettier formatting

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 15:39:13 +02:00
Jan-Philipp Benecke f2175f5fe7 Fix scrolling behavior for auto-height data table (#52508) 2026-06-11 15:39:12 +02:00
Jan-Philipp Benecke bc533c1fc9 Fix disabled action items icon button color in hui edit mode (#52507) 2026-06-11 15:39:10 +02:00
Petar Petrov 9cfdb9d2a2 Open more-info from energy pie chart legend, enlarge legend toggle on touch (#52506) 2026-06-11 15:39:09 +02:00
Bram Kragten 49f34e3a93 Bumped version to 20260527.5 2026-06-07 20:19:38 +02:00
karwosts e04e38f4de Fix yaml entity autocomplete (#52475) 2026-06-07 20:18:46 +02:00
karwosts 6f372a8f70 Fix hui-editor search (#52453) 2026-06-07 19:44:48 +02:00
Aidan Timson cd728e221d Add maintenance my redirect (#52442)
Add maintenance My redirect
2026-06-07 19:44:47 +02:00
Jan-Philipp Benecke 6b6c159d5f Patch tinykeys v4 to make it compatible with older iOS versions (#52420)
* Downgrade tinykeys to 3.1.0 to make it compatible with older iOS versions

* Patch tinykeys v4

* Remove umd patch
2026-06-07 19:44:07 +02:00
Paul Bottein a4199d079b Add customize toggle to media player source and sound mode feature editors (#52414) 2026-06-07 19:41:15 +02:00
Aidan Timson f5edffc153 Match the card style of apps repo to installed (#52407) 2026-06-07 19:41:14 +02:00
ildar170975 78a2cd2485 Statistics graph card editor: add sub editor (#52182)
* add canEdit

* add canEdit

* add subEditor

* linter

* linter

* linter

* linter

* Remove div

* Update src/components/entity/ha-statistic-picker.ts

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Update src/components/entity/ha-statistic-picker.ts

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Update ha-statistic-picker.ts

* Update ha-statistic-picker.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-06-07 19:41:14 +02:00
Bram Kragten ba26e9f491 Bumped version to 20260527.4 2026-06-03 12:03:26 +02:00
Paul Bottein 8778fe8577 Restore search field autofocus in card and badge pickers (#52387) 2026-06-03 12:03:12 +02:00
Aidan Timson 6801aaea30 Fix automation building block action icon style (#52382) 2026-06-03 12:03:12 +02:00
Wendelin c3f5b6693a Landingpage download progress (#52359)
* Simplify and improve landingpage

* add core download progress

* reduce to 2 seconds

* Use round to display full integer as progress percentage

* Use find to get the job object

* Don't show progress label when progress is at 0

Before download starts, progress is at 0. At this point we may trying
to reach a server (and error out), so we aren't really in downloading
phase just yet. Simply treat 0 as "not started" and hide the progress
label until we have a real progress value.

---------

Co-authored-by: Stefan Agner <stefan@agner.ch>
2026-06-03 12:03:10 +02:00
Bram Kragten 68f75c82eb Bumped version to 20260527.3 2026-06-02 23:55:02 +02:00
Bram Kragten 6660e4799c Add tags in app store too, plus show if addon is installed already (#52373) 2026-06-02 23:54:24 +02:00
Petar Petrov 08bfafea21 Fix raw div tag showing in Sankey chart tooltips (#52365)
Fix raw div tag showing in sankey chart tooltips
2026-06-02 23:54:23 +02:00
Bram Kragten 5677e60fcc Matter add device: change how main entity is found (#52361)
Don't search for a entity based on main entity but use entity_category
2026-06-02 23:54:22 +02:00
Bram Kragten 73557e6464 Migrate trigger behavior (#52360)
* Migrate trigger behavior

* Apply suggestions from code review

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-06-02 23:54:21 +02:00
Marcin Bauer e9e6c60d8b Move live-test indicator to badge on condition icon (#52352)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Wendelin <w@pe8.at>
2026-06-02 23:54:20 +02:00
Aidan Timson 1651c210be Improve messaging and consolidate add to dialogs (#52330) 2026-06-02 23:54:19 +02:00
Bram Kragten 927c036454 Bumped version to 20260527.2 2026-06-01 19:52:36 +02:00
Paul Bottein 0fefcf809f Fix vacuum and lawn mower features not showing default buttons (#52343) 2026-06-01 19:52:19 +02:00
Bram Kragten a176f3c1ef Allow to set refresh url while dialog is open, use for matter device (#52341)
Allow to set refresh dialog while dialog is open, use for matter device
2026-06-01 19:52:18 +02:00
Wendelin c5152c3472 App-Info: Hide app title on narrow (#52337)
Hide app title on narrow
2026-06-01 19:52:17 +02:00
Wendelin 0150337522 Fix picker default popover-placement (#52336) 2026-06-01 19:52:16 +02:00
Paul Bottein 5d55d543b1 Respect backend order for floors and areas in entity tree (#52329) 2026-06-01 19:52:14 +02:00
George Caliment 4805b22289 Fixed filter flex direction on mobile + removed unused classes (#52327)
* Fixed filter flex direction on mobile + removed unused classes

* Removed hard-coded height to fill all viewport
2026-06-01 19:52:13 +02:00
Simon Lamon 8de411abc3 Show all counter actions if none specified (#52317)
Show all actions if none specified
2026-06-01 19:52:12 +02:00
Jan-Philipp Benecke e455d4384a Use right token for topbar shadow transition (#52306) 2026-06-01 19:52:11 +02:00
karwosts b0dbd825c8 Fix behavior for move view left/right (#52300) 2026-06-01 19:52:10 +02:00
karwosts 69d0fcb666 Fix untracked legend in detail graph card (#52299) 2026-06-01 19:52:09 +02:00
Simon Lamon f7c3ed3b77 Ignore location in description (#52297) 2026-06-01 19:52:08 +02:00
Jan-Philipp Benecke 5ee5b5120e Add box-shadow transition to top app bar (#52292) 2026-06-01 19:52:07 +02:00
karwosts 58fc8160fd Fix missing location data in calendar (#52291) 2026-06-01 19:52:06 +02:00
Bram Kragten 30930e18ab Bumped version to 20260527.1 2026-05-28 16:47:56 +02:00
Paul Bottein 8d0978817d Don't lowercase translated default action label (#52283) 2026-05-28 16:45:20 +02:00
Paul Bottein fc684218ce Preserve PNG transparency on area pictures (#52282) 2026-05-28 16:45:18 +02:00
Paul Bottein 22f29b7561 Fix sun condition Between description showing reversed values (#52279) 2026-05-28 16:45:16 +02:00
Wendelin c7d48aba44 Fix automation add TCA paste (#52276)
Fix automation add paste
2026-05-28 16:45:15 +02:00
Wendelin aeb2285f30 App details improve mobile and icon (#52275)
* icon instead of logo, enable wrap

* Keep logo

* revert test url
2026-05-28 16:45:14 +02:00
Wendelin c692d7cd4e Card visibility-status use ha-alert (#52271) 2026-05-28 16:45:12 +02:00
Wendelin f2d7021a7d Fix automation note keyboard a11y (#52270) 2026-05-28 16:45:11 +02:00
Wendelin 3a649fba22 Fix automation behavior img file names (#52247)
fix behavior img names
2026-05-28 16:45:09 +02:00
Simon Lamon 5362b8f853 Don't redispatch the original event in a checklist item (#52242) 2026-05-28 16:45:08 +02:00
Wendelin d05800bda6 Fix ha-radio-option checked theming (#52237)
Update ha-radio-option theming to use checked-icon-color for text and border
2026-05-28 16:45:07 +02:00
Wendelin d67530ea37 Fix row target count flickering, keyboard nav, type device (#52236)
* Fix row target count flickering

* Add noninteractive for device, fix keyboard nav

* Noninteractive action, conditon

* Remove unsued hass

* invert noninteractive
2026-05-28 16:45:05 +02:00
Petar Petrov bbd7ef676e Render echarts tooltips with Lit templates (#52235)
* Render echarts tooltips with Lit templates

Replace raw HTML string interpolation in echarts tooltip formatters with Lit templates so user-controlled fields (entity friendly_name, device names, node labels) are auto-escaped instead of relying on per-string filterXSS. ha-chart-base now wraps any function tooltip.formatter into a stable per-formatter container and handles Lit TemplateResult / nothing / null returns; the public HaECOption type lets charts express Lit-returning formatters without per-callsite casts.

* Simplify

* Refactor _getSeries

* Small fix

* Fix merge mistake

* Marker component and wrapper test
2026-05-28 16:45:04 +02:00
115 changed files with 3732 additions and 2260 deletions
@@ -0,0 +1,86 @@
diff --git a/dist/tinykeys.cjs b/dist/tinykeys.cjs
index 08c98b6eff3b8fb4b727fe8e6b096951d6ef6347..9c44f14862f582766ea1733b6dc0e97f962800d8 100644
--- a/dist/tinykeys.cjs
+++ b/dist/tinykeys.cjs
@@ -61,6 +61,18 @@ function defaultKeybindingsHandlerIgnore(event) {
function getModifierState(event, mod) {
return typeof event.getModifierState === "function" ? event.getModifierState(mod) || ALT_GRAPH_ALIASES.includes(mod) && event.getModifierState("AltGraph") : false;
}
+function splitKeybindingPress(press) {
+ let parts = [];
+ let start = 0;
+ for (let index = 0; index < press.length; index++) {
+ if (press[index] === "+" && /[\w\]]/.test(press[index - 1] || "")) {
+ parts.push(press.slice(start, index));
+ start = index + 1;
+ }
+ }
+ parts.push(press.slice(start));
+ return parts;
+}
/**
* Parses a keybinding string into its parts.
*
@@ -76,10 +88,10 @@ function getModifierState(event, mod) {
*/
function parseKeybinding(str) {
return str.trim().split(" ").map((press) => {
- let parts = press.split(/(?<=\w|\])\+/);
+ let parts = splitKeybindingPress(press);
let last = parts.pop();
let regex = last.match(/^\((.+)\)$/);
- let key = regex ? new RegExp(`^(?:${regex[1]})$`, "iv") : last;
+ let key = regex ? new RegExp(`^(?:${regex[1]})$`, "i") : last;
let requiredModifiers = [];
let optionalModifiers = [];
for (const part of parts) {
@@ -201,5 +213,3 @@ exports.defaultKeybindingsHandlerIgnore = defaultKeybindingsHandlerIgnore;
exports.matchKeybindingPress = matchKeybindingPress;
exports.parseKeybinding = parseKeybinding;
exports.tinykeys = tinykeys;
-
-//# sourceMappingURL=tinykeys.cjs.map
\ No newline at end of file
diff --git a/dist/tinykeys.mjs b/dist/tinykeys.mjs
index c289972d2728e03d9b272268c38fd3392e8845bf..e22897b00aae6cdb0dbbb971445227c07be52918 100644
--- a/dist/tinykeys.mjs
+++ b/dist/tinykeys.mjs
@@ -60,6 +60,18 @@ function defaultKeybindingsHandlerIgnore(event) {
function getModifierState(event, mod) {
return typeof event.getModifierState === "function" ? event.getModifierState(mod) || ALT_GRAPH_ALIASES.includes(mod) && event.getModifierState("AltGraph") : false;
}
+function splitKeybindingPress(press) {
+ let parts = [];
+ let start = 0;
+ for (let index = 0; index < press.length; index++) {
+ if (press[index] === "+" && /[\w\]]/.test(press[index - 1] || "")) {
+ parts.push(press.slice(start, index));
+ start = index + 1;
+ }
+ }
+ parts.push(press.slice(start));
+ return parts;
+}
/**
* Parses a keybinding string into its parts.
*
@@ -75,10 +87,10 @@ function getModifierState(event, mod) {
*/
function parseKeybinding(str) {
return str.trim().split(" ").map((press) => {
- let parts = press.split(/(?<=\w|\])\+/);
+ let parts = splitKeybindingPress(press);
let last = parts.pop();
let regex = last.match(/^\((.+)\)$/);
- let key = regex ? new RegExp(`^(?:${regex[1]})$`, "iv") : last;
+ let key = regex ? new RegExp(`^(?:${regex[1]})$`, "i") : last;
let requiredModifiers = [];
let optionalModifiers = [];
for (const part of parts) {
@@ -196,5 +208,3 @@ function tinykeys(target, keybindingMap, options = {}) {
}
//#endregion
export { createKeybindingsHandler, defaultKeybindingsHandlerIgnore, matchKeybindingPress, parseKeybinding, tinykeys };
-
-//# sourceMappingURL=tinykeys.mjs.map
\ No newline at end of file
+31
View File
@@ -13,6 +13,28 @@ export interface NetworkInfo {
supervisor_internet: boolean;
}
interface SupervisorJob {
name: string;
reference: string | null;
uuid: string;
progress: number; // float, 0100
stage: string | null;
done: boolean;
errors: {
type: string;
message: string;
stage: string | null;
}[];
created: string; // ISO datetime string
extra: Record<string, unknown> | null;
child_jobs: SupervisorJob[];
}
export interface SupervisorJobInfo {
ignore_conditions: string[];
jobs: SupervisorJob[];
}
export const ALTERNATIVE_DNS_SERVERS: {
ipv4: string[];
ipv6: string[];
@@ -57,6 +79,15 @@ export async function getSupervisorNetworkInfo(): Promise<NetworkInfo> {
return responseData?.data;
}
export async function getSupervisorJobsInfo(): Promise<
HassioResponse<SupervisorJobInfo>
> {
const responseData = await handleFetchPromise<
HassioResponse<SupervisorJobInfo>
>(fetch("/supervisor-api/jobs/info"));
return responseData;
}
export const setSupervisorNetworkDns = async (
dnsServerIndex: number,
primaryInterface: string
+58 -6
View File
@@ -2,9 +2,9 @@ import { mdiOpenInNew } from "@mdi/js";
import { css, html, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { extractSearchParam } from "../../src/common/url/search-params";
import "../../src/components/animation/ha-fade-in";
import "../../src/components/ha-alert";
import "../../src/components/ha-button";
import "../../src/components/animation/ha-fade-in";
import "../../src/components/ha-spinner";
import "../../src/components/ha-svg-icon";
import "../../src/components/progress/ha-progress-bar";
@@ -15,6 +15,7 @@ import { haStyle } from "../../src/resources/styles";
import "./components/landing-page-logs";
import "./components/landing-page-network";
import {
getSupervisorJobsInfo,
getSupervisorNetworkInfo,
pingSupervisor,
type NetworkInfo,
@@ -24,6 +25,7 @@ import { LandingPageBaseElement } from "./landing-page-base-element";
export const ASSUME_CORE_START_SECONDS = 60;
const SCHEDULE_CORE_CHECK_SECONDS = 1;
const SCHEDULE_FETCH_NETWORK_INFO_SECONDS = 5;
const SCHEDULE_FETCH_JOBS_INFO_SECONDS = 2;
@customElement("ha-landing-page")
class HaLandingPage extends LandingPageBaseElement {
@@ -39,6 +41,8 @@ class HaLandingPage extends LandingPageBaseElement {
@state() private _coreCheckActive = false;
@state() private _progress = -1;
private _mobileApp =
extractSearchParam("redirect_uri") === "homeassistant://auth-callback";
@@ -60,7 +64,14 @@ class HaLandingPage extends LandingPageBaseElement {
${!networkIssue && !this._supervisorError
? html`
<p>${this.localize("subheader")}</p>
<ha-progress-bar indeterminate></ha-progress-bar>
<ha-progress-bar
.indeterminate=${this._progress <= 0}
.value=${this._progress > 0 ? this._progress : undefined}
.loading=${this._progress >= 0}
>${this._progress > 0
? `${Math.round(this._progress)}%`
: nothing}</ha-progress-bar
>
`
: nothing}
${networkIssue || this._networkInfoError
@@ -126,6 +137,7 @@ class HaLandingPage extends LandingPageBaseElement {
import("../../src/components/ha-language-picker");
this._fetchSupervisorInfo(true);
this._fetchSupervisorJobsInfo();
}
private _scheduleFetchSupervisorInfo() {
@@ -138,6 +150,13 @@ class HaLandingPage extends LandingPageBaseElement {
);
}
private _scheduleFetchSupervisorJobsInfo() {
setTimeout(
() => this._fetchSupervisorJobsInfo(),
SCHEDULE_FETCH_JOBS_INFO_SECONDS * 1000
);
}
private _scheduleTurnOffCoreCheck() {
setTimeout(() => {
this._coreCheckActive = false;
@@ -165,7 +184,7 @@ class HaLandingPage extends LandingPageBaseElement {
// assume supervisor update if ping fails -> don't show an error
if (!this._coreCheckActive && err.message !== "ping-failed") {
// eslint-disable-next-line no-console
console.error(err);
console.error("Failed to fetch supervisor info", err);
this._networkInfoError = true;
}
}
@@ -175,6 +194,33 @@ class HaLandingPage extends LandingPageBaseElement {
}
}
private async _fetchSupervisorJobsInfo() {
try {
const jobsInfo = await getSupervisorJobsInfo();
const coreInstallJob =
jobsInfo.result === "ok"
? jobsInfo.data.jobs.find(
(job) => job.name === "home_assistant_core_install"
)
: undefined;
if (coreInstallJob) {
this._progress = coreInstallJob.progress;
} else {
this._progress = -1;
}
} catch (err: any) {
await this._checkCoreAvailability();
if (!this._coreCheckActive) {
this._progress = -1;
// eslint-disable-next-line no-console
console.error("Failed to fetch supervisor jobs info", err);
}
}
this._scheduleFetchSupervisorJobsInfo();
}
private async _checkCoreAvailability() {
try {
const response = await fetch("/manifest.json");
@@ -222,21 +268,27 @@ class HaLandingPage extends LandingPageBaseElement {
flex-direction: column;
gap: var(--ha-space-4);
}
ha-language-picker {
min-width: 200px;
}
ha-alert p {
text-align: unset;
}
.footer ha-svg-icon {
--mdc-icon-size: var(--ha-space-5);
}
ha-language-picker {
margin-inline-start: calc(-1 * var(--ha-space-4));
}
ha-button {
margin-inline-end: calc(-1 * var(--ha-space-2));
}
ha-fade-in {
min-height: calc(100vh - 64px - 88px);
display: flex;
justify-content: center;
align-items: center;
}
ha-progress-bar {
--ha-progress-bar-track-height: 20px;
}
`,
];
}
+1 -1
View File
@@ -115,7 +115,7 @@
"sortablejs": "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch",
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "4.0.0",
"tinykeys": "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260527.0"
version = "20260527.6"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+13
View File
@@ -17,6 +17,19 @@ export interface NavigateOptions {
// max time to wait for dialogs to close before navigating
const DIALOG_WAIT_TIMEOUT = 500;
/**
* Stash a destination URL in the current history entry's state. If the page
* is refreshed while a dialog is open, urlSyncMixin will navigate to this URL
* on load instead of cleaning up the stale dialog state by going back.
* The current URL is not changed.
*/
export const setRefreshUrl = (path: string) => {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, refreshUrl: path },
""
);
};
/**
* Ensures all dialogs are closed before navigation.
* Returns true if navigation can proceed, false if a dialog refused to close.
@@ -0,0 +1,144 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { debounce } from "../../common/util/debounce";
import type { Condition } from "../../data/automation";
import { subscribeCondition } from "../../data/automation";
import type { HomeAssistant } from "../../types";
import "../ha-tooltip";
import "./ha-automation-row-live-test";
import type { LiveTestState } from "./ha-automation-row-live-test";
@customElement("ha-automation-condition-live-test")
export class HaAutomationConditionLiveTest extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: Condition;
@state() private _liveTestResult: {
state: LiveTestState;
message?: string;
} = { state: "unknown" };
private _conditionUnsub?: Promise<UnsubscribeFunc>;
public connectedCallback(): void {
super.connectedCallback();
this._subscribeCondition();
}
protected override updated(changedProps: PropertyValues<this>): void {
super.updated(changedProps);
if (
changedProps.has("condition") &&
changedProps.get("condition") !== undefined
) {
this._resetSubscription();
this._debounceSubscribeCondition();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._debounceSubscribeCondition.cancel();
this._resetSubscription();
}
protected render() {
return html`
<div id="indicator">
<slot></slot>
<ha-automation-row-live-test
.state=${this._liveTestResult.state}
.label=${this.hass.localize(
`ui.panel.config.automation.editor.conditions.live_test_state.${this._liveTestResult.state}`
)}
></ha-automation-row-live-test>
</div>
${this._liveTestResult.message
? html`<ha-tooltip for="indicator"
>${this._liveTestResult.message}</ha-tooltip
>`
: nothing}
`;
}
private _resetSubscription() {
this._liveTestResult = {
state: "unknown",
message: this.hass.localize(
"ui.panel.config.automation.editor.conditions.live_test_state.unknown"
),
};
if (this._conditionUnsub) {
this._conditionUnsub.then((unsub) => unsub());
this._conditionUnsub = undefined;
}
}
private _debounceSubscribeCondition = debounce(
() => this._subscribeCondition(),
500
);
private async _subscribeCondition() {
this._resetSubscription();
if (!this.condition) {
return;
}
const conditionUnsub = subscribeCondition(
this.hass.connection,
(result) => {
if (result.error) {
this._handleLiveTestError(result.error);
} else {
this._liveTestResult = {
state: result.result ? "pass" : "fail",
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.testing_${result.result ? "pass" : "error"}`
),
};
}
},
this.condition
);
conditionUnsub.catch((err: any) => {
this._handleLiveTestError(err);
if (this._conditionUnsub === conditionUnsub) {
this._conditionUnsub = undefined;
}
});
this._conditionUnsub = conditionUnsub;
}
private _handleLiveTestError(error: any) {
const invalid =
typeof error !== "string" && error.code === "invalid_format";
this._liveTestResult = {
state: invalid ? "invalid" : "unknown",
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.${invalid ? "invalid_condition" : "live_test_state.unknown"}`
),
};
}
static styles = css`
:host {
display: inline-flex;
position: relative;
}
#indicator {
display: inline-flex;
position: relative;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-condition-live-test": HaAutomationConditionLiveTest;
}
}
@@ -1,6 +1,5 @@
import { LitElement, css, html, nothing } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-tooltip";
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
@@ -13,7 +12,6 @@ export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
*
* @attr {"pass"|"fail"|"invalid"|"unknown"} state - The current live-test state. Defaults to `unknown`.
* @attr {string} label - Accessible label announced by assistive technology.
* @attr {string} message - Optional tooltip body shown on hover/focus.
*/
@customElement("ha-automation-row-live-test")
export class HaAutomationRowLiveTest extends LitElement {
@@ -21,8 +19,6 @@ export class HaAutomationRowLiveTest extends LitElement {
@property() public label = "";
@property() public message?: string;
protected render() {
return html`
<div
@@ -31,39 +27,38 @@ export class HaAutomationRowLiveTest extends LitElement {
tabindex="0"
aria-label=${this.label}
></div>
${this.message
? html`<ha-tooltip for="indicator">${this.message}</ha-tooltip>`
: nothing}
`;
}
static styles = css`
:host {
position: absolute;
top: -5px;
inset-inline-end: -6px;
display: inline-block;
}
#indicator {
width: 12px;
height: 12px;
width: 10px;
height: 10px;
border-radius: var(--ha-border-radius-circle);
border: 3px solid;
border: var(--ha-border-width-md) solid;
box-sizing: border-box;
background-color: var(--card-background-color);
box-shadow: 0 0 0 2px var(--card-background-color);
transition: all var(--ha-animation-duration-normal) ease-in-out;
}
:host([state="pass"]) #indicator {
background-color: var(--ha-color-fill-success-loud-resting);
border-color: var(--ha-color-fill-success-loud-resting);
background-color: var(--ha-color-green-60);
border-color: var(--ha-color-green-60);
}
:host([state="fail"]) #indicator {
border-color: var(--ha-color-fill-warning-loud-resting);
border-color: var(--ha-color-orange-60);
}
:host([state="invalid"]) #indicator {
border-color: var(--ha-color-fill-danger-loud-resting);
border-color: var(--ha-color-red-60);
}
:host([state="unknown"]) #indicator {
border-color: var(--ha-color-fill-neutral-loud-resting);
border-color: var(--ha-color-neutral-60);
}
`;
}
@@ -161,11 +161,14 @@ export class HaAutomationRow extends LitElement {
}
.leading-icon-wrapper {
padding-top: var(--ha-space-3);
position: relative;
z-index: 1;
}
::slotted([slot="leading-icon"]) {
color: var(--ha-color-on-neutral-quiet);
}
:host([building-block]) ::slotted([slot="leading-icon"]) {
:host([building-block]) ::slotted([slot="leading-icon"].action-icon),
:host([building-block]) ::slotted(#condition-icon) {
--mdc-icon-size: var(--ha-space-5);
color: var(--white-color);
transform: rotate(-45deg);
+101 -29
View File
@@ -14,6 +14,7 @@ import type {
ECElementEvent,
LegendComponentOption,
LineSeriesOption,
TooltipOption,
XAXisOption,
YAXisOption,
} from "echarts/types/dist/shared";
@@ -29,22 +30,59 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { afterNextRender } from "../../common/util/render-status";
import { filterXSS } from "../../common/util/xss";
import { uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type { ECOption } from "../../resources/echarts/echarts";
import type {
ECOption,
HaECOption,
HaECSeries,
HaECSeriesItem,
HaTooltipOption,
} from "../../resources/echarts/echarts";
import type { HomeAssistant, HomeAssistantUI } from "../../types";
import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
const DOUBLE_TAP_TIME = 300;
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
readonly unknown[]
>;
const toEChartsFormatter = (
fn: ReturnType<typeof wrapLitTooltipFormatter>
): NonNullable<TooltipOption["formatter"]> =>
fn as NonNullable<TooltipOption["formatter"]>;
const convertHaTooltipFormatter = (tooltip: HaTooltipOption): TooltipOption => {
const { formatter, ...rest } = tooltip;
const next: TooltipOption = { ...rest };
if (typeof formatter === "function") {
next.formatter = toEChartsFormatter(wrapLitTooltipFormatter(formatter));
} else if (formatter !== undefined) {
next.formatter = formatter;
}
return next;
};
const processSeriesTooltipFormatter = (s: HaECSeriesItem): RawSeriesOption => {
if (s.tooltip && typeof s.tooltip.formatter === "function") {
return {
...s,
tooltip: convertHaTooltipFormatter(s.tooltip),
} as RawSeriesOption;
}
return s as RawSeriesOption;
};
export type CustomLegendOption = ECOption["legend"] & {
type: "custom";
data?: {
@@ -66,9 +104,9 @@ export class HaChartBase extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data: ECOption["series"] = [];
@property({ attribute: false }) public data: HaECSeries = [];
@property({ attribute: false }) public options?: ECOption;
@property({ attribute: false }) public options?: HaECOption;
@property({ type: String }) public height?: string;
@@ -614,7 +652,7 @@ export class HaChartBase extends LitElement {
// Return an array of all IDs associated with the legend item of the primaryId
private _getAllIdsFromLegend(
options: ECOption | undefined,
options: HaECOption | undefined,
primaryId: string
): string[] {
if (!options) return [primaryId];
@@ -634,7 +672,7 @@ export class HaChartBase extends LitElement {
// Parses the options structure and adds all ids of unselected legend items to hiddenDatasets.
// No known need to remove items at this time.
private _updateHiddenStatsFromOptions(options: ECOption | undefined) {
private _updateHiddenStatsFromOptions(options: HaECOption | undefined) {
if (!options) return;
const legend = ensureArray(this.options?.legend || [])[0] as
| LegendComponentOption
@@ -757,22 +795,34 @@ export class HaChartBase extends LitElement {
xAxis,
};
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
if (isMobile && options.tooltip) {
// mobile charts are full width so we need to confine the tooltip to the chart
const tooltips = Array.isArray(options.tooltip)
? options.tooltip
: [options.tooltip];
tooltips.forEach((tooltip) => {
tooltip.confine = true;
tooltip.appendTo = undefined;
tooltip.triggerOn = "click";
});
options.tooltip = tooltips;
if (options.tooltip) {
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
// Shallow-copy each tooltip object so wrap/mobile mutations don't leak
// back into the caller's options.tooltip reference (callers may cache the
// options object via memoizeOne, in which case in-place mutation would
// pollute that cache across chart instances).
const processTooltip = (tooltip: HaTooltipOption): TooltipOption => {
const next = convertHaTooltipFormatter(tooltip);
if (isMobile) {
// mobile charts are full width so we need to confine the tooltip to the chart
next.confine = true;
next.appendTo = undefined;
next.triggerOn = "click";
}
return next;
};
const haTooltip = options.tooltip;
const processedTooltip = Array.isArray(haTooltip)
? haTooltip.map(processTooltip)
: processTooltip(haTooltip);
return {
...options,
tooltip: processedTooltip,
} as ECOption;
}
return options;
return options as ECOption;
}
private _createTheme(style: CSSStyleDeclaration) {
@@ -960,8 +1010,12 @@ export class HaChartBase extends LitElement {
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
? undefined
: s.data;
let result = {
...s,
data,
} as HaECSeriesItem;
if (data && s.type === "line") {
if (s.sampling === "minmax") {
if ((s as LineSeriesOption).sampling === "minmax") {
const minX = xAxis?.min
? xAxis.min instanceof Date
? xAxis.min.getTime()
@@ -976,8 +1030,8 @@ export class HaChartBase extends LitElement {
? xAxis.max
: undefined
: undefined;
return {
...s,
result = {
...result,
sampling: undefined,
data: downSampleLineData(
data as LineSeriesOption["data"],
@@ -985,11 +1039,10 @@ export class HaChartBase extends LitElement {
minX,
maxX
),
};
} as HaECSeriesItem;
}
}
const name = filterXSS(String(s.name ?? s.id ?? ""));
return { ...s, name, data };
return processSeriesTooltipFormatter(result);
});
return series as ECOption["series"];
}
@@ -1326,8 +1379,8 @@ export class HaChartBase extends LitElement {
}
private _compareCustomLegendOptions(
oldOptions: ECOption | undefined,
newOptions: ECOption | undefined
oldOptions: HaECOption | undefined,
newOptions: HaECOption | undefined
): boolean {
const oldLegends = ensureArray(
oldOptions?.legend || []
@@ -1505,6 +1558,25 @@ export class HaChartBase extends LitElement {
.chart-legend .legend-toggle ha-svg-icon {
--mdc-icon-size: 18px;
}
/* On touch devices, enlarge the toggle tap target via taller rows and
leading padding (which also separates it from the previous item), while
keeping the icon tight to its own label so the pairing stays clear.
Drop the now-pointless row gap and li padding. */
@media (pointer: coarse) {
.chart-legend ul {
row-gap: 0;
}
/* Only grow the toggle rows, not the expand/collapse chip's row. */
.chart-legend li:has(.legend-toggle) {
height: 40px;
padding: 0;
}
.chart-legend .legend-toggle {
padding: 11px;
padding-inline-end: 4px;
margin: 0;
}
}
ha-assist-chip {
height: 100%;
--_label-text-weight: 500;
@@ -0,0 +1,41 @@
import type { PropertyValues } from "lit";
import { css, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("ha-chart-tooltip-marker")
class HaChartTooltipMarker extends LitElement {
@property() public color = "";
@property({ type: Boolean, reflect: true }) public rtl = false;
protected willUpdate(changed: PropertyValues) {
if (changed.has("color")) {
this.style.backgroundColor = this.color;
}
}
protected render() {
return nothing;
}
static styles = css`
:host {
display: inline-block;
margin-inline-end: 4px;
margin-inline-start: initial;
border-radius: 10px;
width: 10px;
height: 10px;
vertical-align: middle;
}
:host([rtl]) {
direction: rtl;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-chart-tooltip-marker": HaChartTooltipMarker;
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
import type { PropertyValues } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
@@ -11,7 +11,7 @@ import type {
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
import memoizeOne from "memoize-one";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption } from "../../resources/echarts/echarts";
import "./ha-chart-base";
import type { HaChartBase } from "./ha-chart-base";
import type { HomeAssistant } from "../../types";
@@ -78,7 +78,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public tooltipFormatter?: (
params: TopLevelFormatterParams
) => string;
) => TemplateResult | typeof nothing | null;
/**
* Optional callback that returns additional searchable strings for a node.
@@ -182,7 +182,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
}
private _createOptions = memoizeOne(
(categories?: NetworkData["categories"]): ECOption => ({
(categories?: NetworkData["categories"]): HaECOption => ({
tooltip: {
trigger: "item",
confine: true,
+14 -6
View File
@@ -11,10 +11,10 @@ import { ResizeController } from "@lit-labs/observers/resize-controller";
import { fireEvent } from "../../common/dom/fire_event";
import SankeyChart from "../../resources/echarts/components/sankey/install";
import type { HomeAssistant } from "../../types";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption } from "../../resources/echarts/echarts";
import { measureTextWidth } from "../../util/text";
import { filterXSS } from "../../common/util/xss";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
import { NODE_SIZE } from "../trace/hat-graph-const";
import "../ha-alert";
@@ -71,7 +71,7 @@ export class HaSankeyChart extends LitElement {
});
render() {
const options = {
const options: HaECOption = {
grid: {
top: 0,
bottom: 0,
@@ -83,7 +83,7 @@ export class HaSankeyChart extends LitElement {
formatter: this._renderTooltip,
appendTo: document.body,
},
} as ECOption;
};
return html`<ha-chart-base
.hass=${this.hass}
@@ -101,14 +101,22 @@ export class HaSankeyChart extends LitElement {
const value = this.valueFormatter
? this.valueFormatter(data.value)
: data.value;
// Keep numbers and units left-to-right, even in RTL locales.
const formattedValue = html`<div style="direction:ltr; display: inline;">
${value}
</div>`;
if (data.id) {
const node = this.data.nodes.find((n) => n.id === data.id);
return `${params.marker} ${filterXSS(node?.label ?? data.id)}<br>${value}`;
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${node?.label ?? data.id}<br />${formattedValue}`;
}
if (data.source && data.target) {
const source = this.data.nodes.find((n) => n.id === data.source);
const target = this.data.nodes.find((n) => n.id === data.target);
return `${filterXSS(source?.label ?? data.source)} ${filterXSS(target?.label ?? data.target)}<br>${value}`;
return html`${source?.label ?? data.source}
${target?.label ?? data.target}<br />${formattedValue}`;
}
return null;
};
+8 -5
View File
@@ -5,10 +5,10 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors";
import { filterXSS } from "../../common/util/xss";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let SunburstChart: typeof import("echarts/lib/chart/sunburst/install");
@@ -50,13 +50,13 @@ export class HaSunburstChart extends LitElement {
return nothing;
}
const options = {
const options: HaECOption = {
tooltip: {
trigger: "item",
formatter: this._renderTooltip,
appendTo: document.body,
},
} as ECOption;
};
return html`<ha-chart-base
.data=${this._createData(this.data)}
@@ -71,7 +71,10 @@ export class HaSunburstChart extends LitElement {
const value = this.valueFormatter
? this.valueFormatter(data.value)
: data.value;
return `${params.marker} ${filterXSS(data.name)}<br>${value}`;
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${data.name}<br />${value}`;
};
private _createData = memoizeOne(
@@ -0,0 +1,41 @@
import { nothing, render } from "lit";
import type { LitTooltipFormatter } from "../../resources/echarts/echarts";
type WrappedTooltipFormatter = (
params: unknown,
ticket?: string
) => HTMLElement | null;
export type { WrappedTooltipFormatter };
const litTooltipFormatterCache = new WeakMap<
LitTooltipFormatter | WrappedTooltipFormatter,
WrappedTooltipFormatter
>();
export const wrapLitTooltipFormatter = (
fn: LitTooltipFormatter | WrappedTooltipFormatter
): WrappedTooltipFormatter => {
const cached = litTooltipFormatterCache.get(fn);
if (cached) return cached;
const container = document.createElement("div");
// display:contents keeps the wrapper layout-invisible so its children act as
// direct children of echarts' tooltip box, matching the prior innerHTML behavior.
container.style.display = "contents";
const wrapped: WrappedTooltipFormatter = (params, ticket) => {
const result = (fn as LitTooltipFormatter)(params, ticket);
// `nothing` and null/undefined must all suppress the tooltip. Returning
// `nothing` to echarts via `render(nothing, container)` leaves a Lit
// comment marker behind so echarts would show an empty box; convert it to
// null instead so `setContent(null)` clears innerHTML and `show()` hides.
if (result === null || result === undefined || result === nothing) {
return null;
}
render(result, container);
return container;
};
litTooltipFormatterCache.set(fn, wrapped);
// Idempotent re-wrap: looking up the wrapped fn returns itself.
litTooltipFormatterCache.set(wrapped, wrapped);
return wrapped;
};
@@ -1,5 +1,5 @@
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts";
@@ -12,8 +12,9 @@ import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
getNumberFormatOptions,
@@ -24,7 +25,6 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { filterXSS } from "../../common/util/xss";
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
const safeParseFloat = (value) => {
@@ -108,7 +108,7 @@ export class StateHistoryChartLine extends LitElement {
private _datasetToDataIndex: number[] = [];
@state() private _chartOptions?: ECOption;
@state() private _chartOptions?: HaECOption;
private _hiddenStats = new Set<string>();
@@ -141,12 +141,11 @@ export class StateHistoryChartLine extends LitElement {
private _renderTooltip = (params: any) => {
const time = params[0].axisValue;
const title =
formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
) + "<br>";
const title = formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
);
const datapoints: Record<string, any>[] = [];
this._chartData.forEach((dataset, index) => {
if (
@@ -177,52 +176,44 @@ export class StateHistoryChartLine extends LitElement {
seriesName: dataset.name,
seriesIndex: index,
value: lastData,
// HTML copied from echarts. May change based on options
marker: `<span style="display:inline-block;margin-right:4px;margin-inline-end:4px;margin-inline-start:initial;border-radius:10px;width:10px;height:10px;background-color:${dataset.color};"></span>`,
color: dataset.color,
});
});
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return (
title +
datapoints
.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
let value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
if (data.statistics && data.statistics.length > 0) {
value += "<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;";
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? `${this.hass.localize(
"ui.components.history_charts.source_stats"
)}`
: `${this.hass.localize(
"ui.components.history_charts.source_history"
)}`;
value += source;
}
if (param.seriesName) {
return `${param.marker} ${filterXSS(param.seriesName)}: ${value}`;
}
return `${param.marker} ${value}`;
})
.join("<br>")
);
return html`${title}${datapoints.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
const value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
let statSuffix: TemplateResult | typeof nothing = nothing;
if (data.statistics && data.statistics.length > 0) {
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? this.hass.localize("ui.components.history_charts.source_stats")
: this.hass.localize("ui.components.history_charts.source_history");
// Five non-breaking spaces indent the source label.
statSuffix = html`<br />${"\u00a0".repeat(5)}${source}`;
}
return html`<br /><ha-chart-tooltip-marker
.color=${String(param.color ?? "")}
></ha-chart-tooltip-marker>
${param.seriesName
? html`${param.seriesName}: `
: nothing}${value}${statSuffix}`;
})}`;
};
private _datasetHidden(ev: CustomEvent) {
@@ -1,11 +1,10 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type {
CustomSeriesOption,
CustomSeriesRenderItem,
ECElementEvent,
TooltipFormatterCallback,
TooltipPositionCallbackParams,
} from "echarts/types/dist/shared";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
@@ -15,8 +14,9 @@ import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
import echarts from "../../resources/echarts/echarts";
import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
@@ -57,7 +57,7 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _chartData: CustomSeriesOption[] = [];
@state() private _chartOptions?: ECOption;
@state() private _chartOptions?: HaECOption;
@state() private _yWidth = 0;
@@ -69,7 +69,7 @@ export class StateHistoryChartTimeline extends LitElement {
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData as ECOption["series"]}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@chart-zoom=${this._handleDataZoom}
@@ -132,42 +132,35 @@ export class StateHistoryChartTimeline extends LitElement {
return rect;
};
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
(params: TooltipPositionCallbackParams) => {
const { value, name, marker, seriesName, color } = Array.isArray(params)
? params[0]
: params;
const title = seriesName
? `<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: "";
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
private _renderTooltip = (params: TooltipPositionCallbackParams) => {
const { value, name, seriesName, color } = Array.isArray(params)
? params[0]
: params;
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
const markerLocalized = !computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? marker
: `<span style="direction: rtl;display:inline-block;margin-right:4px;margin-inline-end:4px;border-radius:10px;width:10px;height:10px;background-color:${color};"></span>`;
const lines = [
markerLocalized + name,
formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
),
formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
),
formattedDuration,
].join("<br>");
return [title, lines].join("");
};
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`${seriesName
? html`<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: nothing}<ha-chart-tooltip-marker
.color=${String(color ?? "")}
.rtl=${rtl}
></ha-chart-tooltip-marker
>${name}<br />${formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
)}<br />${formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
)}<br />${formattedDuration}`;
};
public willUpdate(changedProps: PropertyValues) {
if (
+93 -82
View File
@@ -4,7 +4,7 @@ import type {
ZRColor,
} from "echarts/types/dist/shared";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
@@ -34,12 +34,13 @@ import {
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { fillDataGapsAndRoundCaps } from "./round-caps";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
@@ -126,7 +127,7 @@ export class StatisticsChart extends LitElement {
@state() private _statisticIds: string[] = [];
@state() private _chartOptions?: ECOption;
@state() private _chartOptions?: HaECOption;
@state() private _hiddenStats = new Set<string>();
@@ -251,91 +252,101 @@ export class StatisticsChart extends LitElement {
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return params
.map((param, index: number) => {
if (rendered[param.seriesIndex]) return "";
rendered[param.seriesIndex] = true;
const rows: {
time?: string;
color: string;
seriesName?: string;
value: string;
}[] = [];
for (const param of params) {
if (rendered[param.seriesIndex]) continue;
rendered[param.seriesIndex] = true;
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
let rawValue: string;
let rawTime: string;
if (chartIsBar) {
// For bar charts value is always second value.
rawValue = String(param.value[1]);
// Time value is third value (un-shifted date) if given, otherwise first value
let startTime: Date;
let endTime: Date | undefined;
if (param.value[2]) {
startTime = new Date(param.value[2]);
if (param.value[3]) {
endTime = new Date(param.value[3]);
}
} else {
startTime = new Date(param.value[0]);
}
if (
period === "year" ||
period === "month" ||
period === "week" ||
period === "day"
) {
// For year/month/day periods, show only the date
rawTime =
formatDate(startTime, this.hass.locale, this.hass.config) +
(endTime && period !== "day"
? ` ${formatDate(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
} else {
// For other time periods, include time in render, and optionally show range
// if we have an end time.
rawTime =
formatDateTimeWithSeconds(
startTime,
this.hass.locale,
this.hass.config
) +
(endTime
? ` ${formatTimeWithSeconds(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
let rawValue: string;
let rawTime: string;
if (chartIsBar) {
// For bar charts value is always second value.
rawValue = String(param.value[1]);
// Time value is third value (un-shifted date) if given, otherwise first value
let startTime: Date;
let endTime: Date | undefined;
if (param.value[2]) {
startTime = new Date(param.value[2]);
if (param.value[3]) {
endTime = new Date(param.value[3]);
}
} else {
// For lines max series can have 3 values, as the second value is the max-min to form a band
rawValue = String(param.value[2] ?? param.value[1]);
// Time value is always first value
rawTime = `${formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
)} <br>`;
startTime = new Date(param.value[0]);
}
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(
rawValue,
if (
period === "year" ||
period === "month" ||
period === "week" ||
period === "day"
) {
// For year/month/day periods, show only the date
rawTime =
formatDate(startTime, this.hass.locale, this.hass.config) +
(endTime && period !== "day"
? ` ${formatDate(endTime, this.hass.locale, this.hass.config)}`
: "");
} else {
// For other time periods, include time in render, and optionally show range
// if we have an end time.
rawTime =
formatDateTimeWithSeconds(
startTime,
this.hass.locale,
this.hass.config
) +
(endTime
? ` ${formatTimeWithSeconds(
endTime,
this.hass.locale,
this.hass.config
)}`
: "");
}
} else {
// For lines max series can have 3 values, as the second value is the max-min to form a band
rawValue = String(param.value[2] ?? param.value[1]);
// Time value is always first value
rawTime = formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
options
)}${unit}`;
this.hass.config
);
}
const time = index === 0 ? rawTime : "";
return `${time}${param.marker} ${param.seriesName}: ${value}`;
})
.filter(Boolean)
.join("<br>");
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(rawValue, this.hass.locale, options)}${unit}`;
rows.push({
time: rows.length === 0 ? rawTime : undefined,
color: String(param.color ?? ""),
seriesName: param.seriesName,
value,
});
}
if (rows.length === 0) return nothing;
return html`${rows.map(
(row, i) =>
html`${row.time
? html`${row.time}<br />`
: nothing}<ha-chart-tooltip-marker
.color=${row.color}
></ha-chart-tooltip-marker>
${row.seriesName}:
${row.value}${i < rows.length - 1 ? html`<br />` : nothing}`
)}`;
};
private _createOptions() {
@@ -1465,6 +1465,11 @@ export class HaDataTable extends LitElement {
.mdc-data-table__table.auto-height .scroller {
overflow-y: hidden !important;
}
.mdc-data-table__table.auto-height lit-virtualizer {
overscroll-behavior-y: auto;
}
.grows {
flex-grow: 1;
flex-shrink: 1;
+34 -2
View File
@@ -1,11 +1,16 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiChartLine, mdiHelpCircleOutline, mdiShape } from "@mdi/js";
import {
mdiChartLine,
mdiHelpCircleOutline,
mdiPencil,
mdiShape,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../common/entity/compute_state_name";
import { computeRTL } from "../../common/util/compute_rtl";
@@ -53,6 +58,16 @@ const SEARCH_KEYS = [
{ name: "id", weight: 2 },
];
export interface StatisticElementChangedEvent {
statisticId: string;
}
declare global {
interface HASSDomEvents {
"edit-statistics-element": StatisticElementChangedEvent;
}
}
@customElement("ha-statistic-picker")
export class HaStatisticPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -130,6 +145,8 @@ export class HaStatisticPicker extends LitElement {
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@property({ attribute: "can-edit", type: Boolean }) public canEdit?: boolean;
public willUpdate(changedProps: PropertyValues<this>) {
if (
(!this.hasUpdated && !this.statisticIds) ||
@@ -341,6 +358,15 @@ export class HaStatisticPicker extends LitElement {
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${this.canEdit
? html`<ha-icon-button
slot="end"
.value=${statisticId}
.label=${this.hass.localize("ui.common.edit")}
.path=${mdiPencil}
@click=${this._editItem}
></ha-icon-button>`
: nothing}
`;
}
@@ -350,6 +376,12 @@ export class HaStatisticPicker extends LitElement {
private _valueRenderer: PickerValueRenderer = this._makeValueRenderer();
private _editItem(ev: HASSDomEvent<StatisticElementChangedEvent>) {
ev.stopPropagation();
const statisticId = (ev.currentTarget as any).value;
fireEvent(this, "edit-statistics-element", { statisticId });
}
private _computeItem(statisticId: string): StatisticComboBoxItem {
const stateObj = this.hass.states[statisticId];
+17 -1
View File
@@ -1,9 +1,10 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
import type { ValueChangedEvent, HomeAssistant } from "../../types";
import "./ha-statistic-picker";
import type { StatisticElementChangedEvent } from "./ha-statistic-picker";
@customElement("ha-statistics-picker")
class HaStatisticsPicker extends LitElement {
@@ -59,6 +60,8 @@ class HaStatisticsPicker extends LitElement {
})
public ignoreRestrictionsOnFirstStatistic = false;
@property({ attribute: "can-edit", type: Boolean }) public canEdit?;
protected render() {
if (!this.hass) {
return nothing;
@@ -99,7 +102,9 @@ class HaStatisticsPicker extends LitElement {
.statisticIds=${this.statisticIds}
.excludeStatistics=${this.value}
.allowCustomEntity=${this.allowCustomEntity}
.canEdit=${this.canEdit}
@value-changed=${this._statisticChanged}
@edit-statistics-element=${this._editItem}
></ha-statistic-picker>
</div>
`
@@ -122,6 +127,17 @@ class HaStatisticsPicker extends LitElement {
`;
}
private _editItem(ev: HASSDomEvent<StatisticElementChangedEvent>) {
const statisticId = ev.detail.statisticId;
const index = this._currentStatistics!.findIndex((e) => e === statisticId);
fireEvent(this, "edit-detail-element", {
subElementConfig: {
index,
type: "row",
},
});
}
private get _currentStatistics() {
return this.value || [];
}
+9 -5
View File
@@ -112,12 +112,16 @@ export class HaCameraStream extends LitElement {
return nothing;
}
if (stream.type === MJPEG_STREAM) {
const streamUrl = __DEMO__
? this.stateObj.attributes.entity_picture
: this._connected
? computeMJPEGStreamUrl(this.stateObj)
: this._posterUrl;
if (!streamUrl) {
return nothing;
}
return html`<img
.src=${__DEMO__
? this.stateObj.attributes.entity_picture!
: this._connected
? computeMJPEGStreamUrl(this.stateObj)
: this._posterUrl || ""}
.src=${streamUrl}
style=${styleMap({
aspectRatio: this.aspectRatio,
objectFit: this.fitMode,
+1
View File
@@ -20,6 +20,7 @@ export class HaCheckListItem extends CheckListItemBase {
separateCheckboxClick = false;
async onChange(event) {
event.stopPropagation();
super.onChange(event);
fireEvent(this, event.type);
}
+4 -4
View File
@@ -1600,8 +1600,8 @@ export class HaCodeEditor extends ReactiveElement {
// Filter states based on what's typed
const filteredStates = typedText
? states.filter((entityState) =>
entityState.label
.toLowerCase()
entityState.displayLabel
?.toLowerCase()
.startsWith(typedText.toLowerCase())
)
: states;
@@ -1658,8 +1658,8 @@ export class HaCodeEditor extends ReactiveElement {
// Filter states based on what's typed
const filteredStates = typedText
? states.filter((entityState) =>
entityState.label
.toLowerCase()
entityState.displayLabel
?.toLowerCase()
.startsWith(typedText.toLowerCase())
)
: states;
+1 -1
View File
@@ -77,7 +77,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
| "bottom-start"
| "bottom-end"
| "left-start"
| "left-end" = "bottom-start";
| "left-end" = "bottom";
/** If set picker shows an add button instead of textbox when value isn't set */
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
-1
View File
@@ -121,7 +121,6 @@ export class HaIconPicker extends LitElement {
.label=${this.label}
.value=${this._value}
.searchFn=${this._filterIcons}
popover-placement="bottom-start"
@value-changed=${this._valueChanged}
>
<slot name="start"></slot>
-1
View File
@@ -152,7 +152,6 @@ export class HaLanguagePicker extends LitElement {
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
popover-placement="bottom-end"
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass?.localize(
"ui.components.language-picker.no_languages"
-1
View File
@@ -82,7 +82,6 @@ export class HaThemePicker extends LitElement {
.disabled=${this.disabled}
.required=${this.required}
@value-changed=${this._changed}
popover-placement="bottom"
></ha-generic-picker>
`;
}
+1
View File
@@ -22,6 +22,7 @@ export const haTopAppBarFixedStyles = css`
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
transition:
box-shadow var(--ha-animation-duration-normal) ease,
width var(--ha-animation-duration-normal) ease,
padding-left var(--ha-animation-duration-normal) ease,
padding-right var(--ha-animation-duration-normal) ease;
+2 -2
View File
@@ -99,8 +99,8 @@ export class HaRadioOption extends Radio {
--ha-radio-option-checked-background-color,
var(--ha-color-fill-primary-normal-resting)
);
color: var(--ha-color-fill-primary-loud-resting);
border-color: var(--ha-color-fill-primary-loud-resting);
color: var(--checked-icon-color);
border-color: var(--checked-icon-color);
}
[part~="label"] {
+11
View File
@@ -485,6 +485,17 @@ export const migrateAutomationTrigger = (
}
delete trigger.platform;
}
if ("options" in trigger) {
if (trigger.options && "behavior" in trigger.options) {
if (trigger.options.behavior === "any") {
trigger.options.behavior = "each";
} else if (trigger.options.behavior === "last") {
trigger.options.behavior = "all";
}
}
}
return trigger;
};
+1
View File
@@ -256,6 +256,7 @@ export const normalizeSubscriptionEventData = (
dtstart: eventStart,
dtend: eventEnd,
description: eventData.description ?? undefined,
location: eventData.location ?? undefined,
uid: eventData.uid ?? undefined,
recurrence_id: eventData.recurrence_id ?? undefined,
rrule: eventData.rrule ?? undefined,
+7 -3
View File
@@ -17,7 +17,7 @@ export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
interface CameraEntityAttributes extends HassEntityAttributeBase {
model_name: string;
access_token: string;
access_token?: string;
brand: string;
motion_detection: boolean;
frontend_stream_type: string;
@@ -78,8 +78,12 @@ export const cameraUrlWithWidthHeight = (
height: number
) => `${base_url}&width=${width}&height=${height}`;
export const computeMJPEGStreamUrl = (entity: CameraEntity) =>
`/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`;
export const computeMJPEGStreamUrl = (
entity: CameraEntity
): string | undefined =>
entity.attributes.access_token
? `/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`
: undefined;
export const fetchThumbnailUrlWithCache = async (
hass: HomeAssistant,
+5 -3
View File
@@ -4,12 +4,14 @@ import type {
} from "home-assistant-js-websocket";
interface ImageEntityAttributes extends HassEntityAttributeBase {
access_token: string;
access_token?: string;
}
export interface ImageEntity extends HassEntityBase {
attributes: ImageEntityAttributes;
}
export const computeImageUrl = (entity: ImageEntity): string =>
`/api/image_proxy/${entity.entity_id}?token=${entity.attributes.access_token}&state=${entity.state}`;
export const computeImageUrl = (entity: ImageEntity): string | undefined =>
entity.attributes.access_token
? `/api/image_proxy/${entity.entity_id}?token=${entity.attributes.access_token}&state=${entity.state}`
: undefined;
@@ -1,26 +1,37 @@
import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { LocalizeKeys } from "../../common/translations/localize";
import { createSearchParam } from "../../common/url/search-params";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { SCENE_IGNORED_DOMAINS, type SceneEntities } from "../../data/scene";
import type { SingleHassServiceTarget } from "../../data/target";
import {
ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_QUERY_PARAM,
ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_QUERY_PARAM,
} from "../../panels/config/automation/show-add-automation-element-dialog";
import type { HomeAssistant, TranslationDict } from "../../types";
/** Add to action keys are the keys of the translation dictionary for the add to actions. */
export type AddToActionKey =
TranslationDict["ui"]["dialogs"]["more_info_control"]["add_to"]["actions"] extends infer Actions
? keyof Actions
: never;
/** Add to action keys are the keys of the translation dictionary for the add to action options. */
type AddToActionOptions =
TranslationDict["ui"]["dialogs"]["more_info_control"]["add_to"]["action_options"];
export type AddToActionKey = Extract<keyof AddToActionOptions, string>;
export type AddToAutomationScriptActionKey = Exclude<AddToActionKey, "scene">;
/** Fully-qualified localize key for an add to action option label. */
type AddToActionOptionLabelKey = LocalizeKeys &
`ui.dialogs.more_info_control.add_to.action_options.${AddToActionKey}`;
interface BaseEntityAddToAction {
/** Whether the action is enabled and can be selected. */
enabled: boolean;
/** Translated name of the action */
name: string;
/** Translated label of the action option */
name?: string;
/** Fully-qualified localize key for the action option label */
nameKey?: AddToActionOptionLabelKey;
/** Optional translated description of the action */
description?: string;
/** MDI icon name (e.g., "mdi:car") */
@@ -31,7 +42,7 @@ export interface DefaultEntityAddToAction extends BaseEntityAddToAction {
/** Type of action handled in the frontend */
type: "default";
/** Stable key used to resolve the action handler */
key: AddToActionKey;
key: AddToAutomationScriptActionKey;
}
export interface ExternalEntityAddToAction extends BaseEntityAddToAction {
@@ -48,11 +59,11 @@ export type EntityAddToAction =
export type EntityAddToActions = EntityAddToAction[];
interface ActionDefinition {
translation_key: AddToActionKey;
translation_key: AddToAutomationScriptActionKey;
icon: string;
}
export const DEFAULT_ACTION_DEFS: ActionDefinition[] = [
const DEFAULT_ACTION_DEFS: ActionDefinition[] = [
{
translation_key: "automation_trigger",
icon: "mdi:robot-outline",
@@ -71,33 +82,49 @@ export const DEFAULT_ACTION_DEFS: ActionDefinition[] = [
},
];
export const getDefaultAddToActions = (
states: HomeAssistant["states"],
localize: LocalizeFunc,
formatEntityName: HomeAssistant["formatEntityName"],
entityId: string
): EntityAddToActions =>
export const getDefaultAddToActions = (): EntityAddToActions =>
DEFAULT_ACTION_DEFS.map(
(def: ActionDefinition): EntityAddToAction => ({
type: "default",
key: def.translation_key,
enabled: true,
name: localize(
`ui.dialogs.more_info_control.add_to.actions.${def.translation_key}`,
{
target:
states[entityId] !== undefined
? formatEntityName(states[entityId], undefined)
: entityId,
}
),
nameKey: `ui.dialogs.more_info_control.add_to.action_options.${def.translation_key}`,
icon: def.icon,
})
);
export const createAddToSceneEntities = (
entityIds: string[]
): SceneEntities => {
const entities: SceneEntities = {};
for (const entityId of entityIds) {
entities[entityId] = "";
}
return entities;
};
export const filterAddToSceneEntityIds = (
entityIds: string[],
entityRegistry: readonly EntityRegistryEntry[],
states: HomeAssistant["states"]
): string[] => {
const entityIdSet = new Set(entityIds);
return entityRegistry
.filter((entry) => entityIdSet.has(entry.entity_id))
.filter(
(entry) =>
!entry.entity_category &&
!entry.hidden_by &&
!SCENE_IGNORED_DOMAINS.includes(computeDomain(entry.entity_id)) &&
states[entry.entity_id]
)
.map((entry) => entry.entity_id);
};
/** Handler for adding a target to an automation/script. */
export function addToActionHandler(
key: AddToActionKey,
key: AddToAutomationScriptActionKey,
target: SingleHassServiceTarget
): Promise<boolean> {
const searchParams: Record<string, string> = {};
+211
View File
@@ -0,0 +1,211 @@
import { mdiPlus } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../../common/translations/localize";
import "../../components/ha-icon";
import "../../components/ha-svg-icon";
import type { HaListItemButton } from "../../components/item/ha-list-item-button";
import "../../components/item/ha-list-item-button";
import "../../components/list/ha-list-base";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
export interface AddToActionListItem {
name?: string;
nameKey?: LocalizeKeys;
description?: string;
descriptionKey?: LocalizeKeys;
icon?: string;
iconPath?: string;
enabled?: boolean;
}
export interface AddToActionListSection<
Item extends AddToActionListItem = AddToActionListItem,
> {
title?: string;
titleKey?: LocalizeKeys;
actions: readonly Item[];
empty?: string;
emptyKey?: LocalizeKeys;
}
export interface AddToActionListActionSelectedDetail<
Item extends AddToActionListItem = AddToActionListItem,
> {
action: Item;
}
export type AddToActionListActionSelectedEvent<
Item extends AddToActionListItem = AddToActionListItem,
> = HASSDomEvent<AddToActionListActionSelectedDetail<Item>>;
@customElement("ha-add-to-action-list")
class HaAddToActionList extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false })
public sections: readonly AddToActionListSection[] = [];
protected render(): TemplateResult | typeof nothing {
if (!this.sections.length) {
return nothing;
}
return html`${this.sections.map((section, sectionIndex) =>
this._renderSection(section, sectionIndex)
)}`;
}
private _renderSection(
section: AddToActionListSection,
sectionIndex: number
): TemplateResult | typeof nothing {
if (!section.actions.length && !section.empty && !section.emptyKey) {
return nothing;
}
return html`
<h3 class="section-header">
${this._localizeValue(section.title, section.titleKey)}
</h3>
${section.actions.length
? html`<ha-list-base>
${section.actions.map((action, actionIndex) =>
this._renderActionItem(action, sectionIndex, actionIndex)
)}
</ha-list-base>`
: html`<h4 class="empty">
${this._localizeValue(section.empty, section.emptyKey)}
</h4>`}
`;
}
private _renderActionItem(
action: AddToActionListItem,
sectionIndex: number,
actionIndex: number
): TemplateResult {
return html`
<ha-list-item-button
.disabled=${action.enabled === false}
data-section-index=${sectionIndex}
data-action-index=${actionIndex}
.headline=${this._localizeValue(action.name, action.nameKey)}
.supportingText=${this._localizeValue(
action.description,
action.descriptionKey
)}
@click=${this._actionSelected}
>
${action.icon
? html`<ha-icon
class="start-icon"
slot="start"
.icon=${action.icon}
></ha-icon>`
: action.iconPath
? html`<ha-svg-icon
class="start-icon"
slot="start"
.path=${action.iconPath}
></ha-svg-icon>`
: nothing}
<ha-svg-icon class="plus" slot="end" .path=${mdiPlus}></ha-svg-icon>
</ha-list-item-button>
`;
}
private _localizeValue(
value?: string,
localizeKey?: LocalizeKeys
): string | undefined {
return value || (localizeKey ? this._localize(localizeKey) : undefined);
}
private _actionSelected(
ev: HASSDomCurrentTargetEvent<HaListItemButton>
): void {
const action =
this.sections[Number(ev.currentTarget.dataset.sectionIndex)]?.actions[
Number(ev.currentTarget.dataset.actionIndex)
];
if (!action) {
return;
}
if (action.enabled === false) {
return;
}
fireEvent(this, "add-to-list-action-selected", {
action,
});
}
static styles: CSSResultGroup = css`
:host {
display: block;
}
.section-header {
padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-1);
margin: 0;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
color: var(--secondary-text-color);
}
.empty {
padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-1);
margin: 0;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal);
color: var(--secondary-text-color);
}
ha-list-item-button {
--ha-row-item-padding-inline: var(--ha-space-5);
}
ha-icon,
ha-svg-icon {
display: flex;
align-items: center;
}
.start-icon {
color: var(--ha-color-text-secondary);
}
.plus {
color: var(--primary-color);
}
ha-list-item-button[disabled] .start-icon,
ha-list-item-button[disabled] .plus {
color: var(--disabled-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-add-to-action-list": HaAddToActionList;
}
interface HASSDomEvents {
"add-to-list-action-selected": AddToActionListActionSelectedDetail;
}
}
@@ -106,7 +106,9 @@ class EntityPreviewRow extends LitElement {
}
`;
private _renderEntityState(stateObj: HassEntity): TemplateResult | string {
private _renderEntityState(
stateObj: HassEntity
): TemplateResult | string | typeof nothing {
const domain = stateObj.entity_id.split(".", 1)[0];
const disabled = stateObj.state === UNAVAILABLE;
const noValue =
@@ -222,7 +224,10 @@ class EntityPreviewRow extends LitElement {
}
if (domain === "image") {
const image: string = computeImageUrl(stateObj as ImageEntity);
const image = computeImageUrl(stateObj as ImageEntity);
if (!image) {
return nothing;
}
return html`
<img
alt=${ifDefined(stateObj?.attributes.friendly_name)}
@@ -15,9 +15,13 @@ class MoreInfoImage extends LitElement {
if (!this.hass || !this.stateObj) {
return nothing;
}
const imageUrl = computeImageUrl(this.stateObj);
if (!imageUrl) {
return nothing;
}
return html`<img
alt=${this.stateObj.attributes.friendly_name || this.stateObj.entity_id}
src=${this.hass.hassUrl(computeImageUrl(this.stateObj))}
src=${this.hass.hassUrl(imageUrl)}
/> `;
}
+57 -75
View File
@@ -1,26 +1,35 @@
import { LitElement, css, html, nothing } from "lit";
import { consume, type ContextType } from "@lit/context";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-alert";
import "../../components/ha-icon";
import "../../components/ha-spinner";
import "../../components/item/ha-list-item-button";
import "../../components/list/ha-list-base";
import type { HaListItemButton } from "../../components/item/ha-list-item-button";
import { showToast } from "../../util/toast";
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types";
import { configContext } from "../../data/context";
import "../add-to/ha-add-to-action-list";
import type {
AddToActionListActionSelectedEvent,
AddToActionListSection,
} from "../add-to/ha-add-to-action-list";
import {
type EntityAddToAction,
type EntityAddToActions,
addToActionHandler,
getDefaultAddToActions,
} from "./add-to";
} from "../add-to/add-to";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../common/translations/localize";
@customElement("ha-more-info-add-to")
export class HaMoreInfoAddTo extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: configContext, subscribe: true })
private _config?: ContextType<typeof configContext>;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public entityId!: string;
@@ -31,18 +40,15 @@ export class HaMoreInfoAddTo extends LitElement {
@state() private _loading = true;
private async _loadActions() {
this._defaultActions = getDefaultAddToActions(
this.hass.states,
this.hass.localize,
this.hass.formatEntityName,
this.entityId
);
this._defaultActions = this._config?.user?.is_admin
? getDefaultAddToActions()
: [];
this._externalActions = [];
if (this.hass.auth.external?.config.hasEntityAddTo) {
if (this._config?.auth.external?.config.hasEntityAddTo) {
try {
const response =
await this.hass.auth.external?.sendMessage<"entity/add_to/get_actions">(
await this._config.auth.external.sendMessage<"entity/add_to/get_actions">(
{
type: "entity/add_to/get_actions",
payload: { entity_id: this.entityId },
@@ -66,13 +72,9 @@ export class HaMoreInfoAddTo extends LitElement {
}
private async _actionSelected(
ev: HASSDomCurrentTargetEvent<
HaListItemButton & {
action: EntityAddToAction;
}
>
ev: AddToActionListActionSelectedEvent<EntityAddToAction>
) {
const action = ev.currentTarget.action;
const { action } = ev.detail;
if (!action.enabled) {
return;
}
@@ -82,7 +84,10 @@ export class HaMoreInfoAddTo extends LitElement {
if (!action.payload) {
throw new Error("Missing external action payload");
}
this.hass.auth.external!.fireMessage({
if (!this._config?.auth.external) {
throw new Error("Missing external app connection");
}
this._config.auth.external.fireMessage({
type: "entity/add_to",
payload: {
entity_id: this.entityId,
@@ -92,7 +97,7 @@ export class HaMoreInfoAddTo extends LitElement {
fireEvent(this, "add-to-action-selected");
} catch (err: unknown) {
showToast(this, {
message: this.hass.localize(
message: this._localize(
"ui.dialogs.more_info_control.add_to.action_failed",
{
error: err instanceof Error ? err.message : String(err),
@@ -110,24 +115,6 @@ export class HaMoreInfoAddTo extends LitElement {
addToActionHandler(action.key, { entity_id: this.entityId });
}
private _renderActionItems(actions: EntityAddToActions) {
return actions.map(
(action) => html`
<ha-list-item-button
.disabled=${!action.enabled}
.action=${action}
@click=${this._actionSelected}
>
<ha-icon slot="start" .icon=${action.icon}></ha-icon>
<span slot="headline">${action.name}</span>
${action.description
? html`<span slot="supporting-text">${action.description}</span>`
: nothing}
</ha-list-item-button>
`
);
}
protected async firstUpdated() {
await this._loadActions();
this._loading = false;
@@ -145,29 +132,38 @@ export class HaMoreInfoAddTo extends LitElement {
if (!this._defaultActions.length && !this._externalActions.length) {
return html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.no_actions"
)}
${this._localize("ui.dialogs.more_info_control.add_to.no_actions")}
</ha-alert>
`;
}
const automationActions = this._defaultActions.filter(
(action) => action.type === "default" && action.key !== "script_action"
);
const scriptActions = this._defaultActions.filter(
(action) => action.type === "default" && action.key === "script_action"
);
const sections: AddToActionListSection<EntityAddToAction>[] = [
{
titleKey: "ui.dialogs.more_info_control.add_to.automations_heading",
actions: automationActions,
},
{
titleKey: "ui.dialogs.more_info_control.add_to.scripts_heading",
actions: scriptActions,
},
{
titleKey: "ui.dialogs.more_info_control.add_to.app_actions",
actions: this._externalActions,
},
];
return html`
<ha-list-base>
${this._renderActionItems(this._defaultActions)}
</ha-list-base>
${this._externalActions.length
? html`
<h2 class="section-title">
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.app_actions"
)}
</h2>
<ha-list-base>
${this._renderActionItems(this._externalActions)}
</ha-list-base>
`
: nothing}
<ha-add-to-action-list
.sections=${sections}
@add-to-list-action-selected=${this._actionSelected}
></ha-add-to-action-list>
`;
}
@@ -183,20 +179,6 @@ export class HaMoreInfoAddTo extends LitElement {
align-items: center;
padding: var(--ha-space-8);
}
.section-title {
padding: 0 var(--ha-space-6);
margin: var(--ha-space-4) 0 var(--ha-space-1);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-normal);
color: var(--secondary-text-color);
}
ha-icon {
display: flex;
align-items: center;
}
`;
}
+15 -11
View File
@@ -23,6 +23,7 @@ import { customElement, property, query, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import { classMap } from "lit/directives/class-map";
import { keyed } from "lit/directives/keyed";
import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
@@ -261,9 +262,8 @@ export class MoreInfoDialog extends SubscribeMixin(
}
private _shouldShowAddEntityTo(): boolean {
// When new_triggers_conditions labs feature is promoted, this whole check can be removed.
return (
this._newTriggersAndConditions ||
(this._newTriggersAndConditions && !!this.hass.user?.is_admin) ||
!!this.hass.auth.external?.config.hasEntityAddTo
);
}
@@ -517,7 +517,7 @@ export class MoreInfoDialog extends SubscribeMixin(
await favoritesHandler.copy(favoritesContext);
}
private _goToAddEntityTo(ev) {
private _goToAddEntityTo(ev: CustomEvent<RequestSelectedDetail>) {
// Only check for request-selected events (from menu items), not regular clicks (from icon button)
if (
ev.type === "request-selected" &&
@@ -590,10 +590,19 @@ export class MoreInfoDialog extends SubscribeMixin(
(v): v is string => Boolean(v)
);
const defaultTitle = breadcrumb.pop() || entityId;
const addToTitle = this.hass.localize(
"ui.dialogs.more_info_control.add_to.title",
{ target: defaultTitle }
);
const addToMenuItem = this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
);
const title =
this._currView === "details"
? this.hass.localize("ui.dialogs.more_info_control.details")
: this._childView?.viewTitle || defaultTitle;
: this._currView === "add_to"
? addToTitle
: this._childView?.viewTitle || defaultTitle;
const favoritesContext =
this._entry && stateObj
@@ -711,9 +720,7 @@ export class MoreInfoDialog extends SubscribeMixin(
slot="icon"
.path=${mdiPlusBoxMultipleOutline}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.title"
)}
${addToMenuItem}
</ha-dropdown-item>
<wa-divider></wa-divider>
@@ -814,9 +821,7 @@ export class MoreInfoDialog extends SubscribeMixin(
? html`
<ha-icon-button
slot="headerActionItems"
.label=${this.hass.localize(
"ui.dialogs.more_info_control.add_to.title"
)}
.label=${addToMenuItem}
.path=${mdiPlusBoxMultipleOutline}
@click=${this._goToAddEntityTo}
></ha-icon-button>
@@ -906,7 +911,6 @@ export class MoreInfoDialog extends SubscribeMixin(
: this._currView === "add_to"
? html`
<ha-more-info-add-to
.hass=${this.hass}
.entityId=${entityId}
@add-to-action-selected=${this._goBack}
></ha-more-info-add-to>
+67
View File
@@ -19,6 +19,64 @@ declare const __WB_MANIFEST__: Parameters<typeof precacheAndRoute>[0];
const noFallBackRegEx =
/\/(api|static|auth|frontend_latest|frontend_es5|local)\/.*/;
// Camera / image proxy endpoints that carry credentials in the URL.
// We pre-validate the credential in the service worker so obviously invalid
// requests (signature expired, token missing) never reach the server and
// don't trigger spurious "Login attempt" warnings from http.ban after BFCache
// restore, tab resume, network change, or any other browser-initiated replay
// of a stale `<img>` URL.
const proxyPathRegEx =
/^\/api\/(camera_proxy_stream|camera_proxy|image_proxy)\//;
// Reject signatures this many ms before their nominal expiry to absorb small
// client/server clock differences. Erring this direction only ever turns a
// would-be valid request into a local 401; we cannot err the other way without
// re-introducing the warnings this filter exists to prevent.
const JWT_EXPIRY_SKEW_MS = 5000;
const base64UrlDecode = (input: string): string => {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4)) % 4);
return atob(padded);
};
const isJwtExpired = (jwt: string): boolean => {
try {
const parts = jwt.split(".");
if (parts.length !== 3) {
return false;
}
const payload = JSON.parse(base64UrlDecode(parts[1]));
if (typeof payload.exp !== "number") {
return false;
}
return payload.exp * 1000 < Date.now() + JWT_EXPIRY_SKEW_MS;
} catch (_err) {
// If we can't parse the JWT for any reason, defer to the server.
return false;
}
};
const handleProxyRequest: RouteHandler = async ({ request }) => {
const req = request as Request;
const url = new URL(req.url);
const token = url.searchParams.get("token");
if (token === "undefined" || token === "null" || token === "") {
return new Response(null, { status: 401, statusText: "Invalid token" });
}
const authSig = url.searchParams.get("authSig");
if (authSig && isJwtExpired(authSig)) {
return new Response(null, {
status: 401,
statusText: "Signature expired",
});
}
return fetch(req);
};
const initRouting = () => {
precacheAndRoute(__WB_MANIFEST__, {
// Ignore all URL parameters.
@@ -59,6 +117,15 @@ const initRouting = () => {
})
);
// Short-circuit camera/image proxy requests with an expired signature or a
// missing/undefined token so they don't hit core and get logged as invalid
// login attempts. Registered before the generic /api route below so it wins.
registerRoute(
({ url, request }) =>
proxyPathRegEx.test(url.pathname) && request.method === "GET",
handleProxyRequest
);
// Get api from network.
registerRoute(/\/(api|auth)\/.*/, new NetworkOnly());
+19 -30
View File
@@ -1,39 +1,20 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { LocalizeFunc } from "../../common/translations/localize";
import { fireEvent } from "../../common/dom/fire_event";
import { customElement } from "lit/decorators";
import "../../components/ha-dialog";
import { DialogMixin } from "../../dialogs/dialog-mixin";
import type { AppDialogParams } from "./show-app-dialog";
@customElement("app-dialog")
class DialogApp extends LitElement {
@property({ attribute: false }) public localize?: LocalizeFunc;
@state() private _open = false;
public async showDialog(params: { localize: LocalizeFunc }): Promise<void> {
this.localize = params.localize;
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this.localize = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
class DialogApp extends DialogMixin<AppDialogParams>(LitElement) {
protected render() {
if (!this.localize) {
if (!this.params?.localize) {
return nothing;
}
return html`<ha-dialog
.open=${this._open}
header-title=${this.localize(
open
header-title=${this.params.localize(
"ui.panel.page-onboarding.welcome.download_app"
) || "Click here to download the app"}
@closed=${this._dialogClosed}
>
<div>
<div class="app-qr">
@@ -45,13 +26,17 @@ class DialogApp extends LitElement {
<img
loading="lazy"
src="/static/images/appstore.svg"
alt=${this.localize("ui.panel.page-onboarding.welcome.appstore")}
alt=${this.params.localize(
"ui.panel.page-onboarding.welcome.appstore"
)}
class="icon"
/>
<img
loading="lazy"
src="/static/images/qr-appstore.svg"
alt=${this.localize("ui.panel.page-onboarding.welcome.appstore")}
alt=${this.params.localize(
"ui.panel.page-onboarding.welcome.appstore"
)}
/>
</a>
<a
@@ -62,13 +47,17 @@ class DialogApp extends LitElement {
<img
loading="lazy"
src="/static/images/playstore.svg"
alt=${this.localize("ui.panel.page-onboarding.welcome.playstore")}
alt=${this.params.localize(
"ui.panel.page-onboarding.welcome.playstore"
)}
class="icon"
/>
<img
loading="lazy"
src="/static/images/qr-playstore.svg"
alt=${this.localize("ui.panel.page-onboarding.welcome.playstore")}
alt=${this.params.localize(
"ui.panel.page-onboarding.welcome.playstore"
)}
/>
</a>
</div>
+59 -75
View File
@@ -1,103 +1,87 @@
import { mdiAccountGroup, mdiOpenInNew } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import { customElement } from "lit/decorators";
import "../../components/ha-dialog";
import "../../components/ha-list";
import "../../components/ha-list-item";
import "../../components/ha-svg-icon";
import "../../components/item/ha-list-item-button";
import "../../components/list/ha-list-nav";
import { DialogMixin } from "../../dialogs/dialog-mixin";
import type { CommunityDialogParams } from "./show-community-dialog";
@customElement("community-dialog")
class DialogCommunity extends LitElement {
@property({ attribute: false }) public localize?: LocalizeFunc;
@state() private _open = false;
public async showDialog(params): Promise<void> {
this.localize = params.localize;
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this.localize = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
class DialogCommunity extends DialogMixin<CommunityDialogParams>(LitElement) {
protected render() {
if (!this.localize) {
if (!this.params?.localize) {
return nothing;
}
return html`<ha-dialog
.open=${this._open}
header-title=${this.localize(
open
header-title=${this.params.localize(
"ui.panel.page-onboarding.welcome.community"
)}
@closed=${this._dialogClosed}
>
<ha-list>
<a
<ha-list-nav>
<ha-list-item-button
target="_blank"
rel="noreferrer noopener"
href="https://community.home-assistant.io/"
>
<ha-list-item hasMeta graphic="icon">
<img
src="/static/icons/favicon-192x192.png"
slot="graphic"
alt="Home Assistant Logo"
/>
${this.localize("ui.panel.page-onboarding.welcome.forums")}
<ha-svg-icon slot="meta" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item>
</a>
<a
<img
src="/static/icons/favicon-192x192.png"
slot="start"
alt="Home Assistant Logo"
/>
<span slot="headline">
${this.params.localize("ui.panel.page-onboarding.welcome.forums")}
</span>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item-button>
<ha-list-item-button
target="_blank"
rel="noreferrer noopener"
href="https://newsletter.openhomefoundation.org/"
>
<ha-list-item hasMeta graphic="icon">
<img
src="/static/icons/logo_ohf.svg"
slot="graphic"
alt="Open Home Foundation Logo"
/>
${this.localize(
<img
src="/static/icons/logo_ohf.svg"
slot="start"
alt="Open Home Foundation Logo"
/>
<span slot="headline">
${this.params.localize(
"ui.panel.page-onboarding.welcome.open_home_newsletter"
)}
<ha-svg-icon slot="meta" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item>
</a>
<a
</span>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item-button>
<ha-list-item-button
target="_blank"
rel="noreferrer noopener"
href="https://www.home-assistant.io/join-chat"
>
<ha-list-item hasMeta graphic="icon">
<img
src="/static/images/logo_discord.png"
slot="graphic"
alt="Discord Logo"
/>
${this.localize("ui.panel.page-onboarding.welcome.discord")}
<ha-svg-icon slot="meta" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item>
</a>
<a
<img
src="/static/images/logo_discord.png"
slot="start"
alt="Discord Logo"
/>
<span slot="headline">
${this.params.localize("ui.panel.page-onboarding.welcome.discord")}
</span>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item-button>
<ha-list-item-button
target="_blank"
rel="noreferrer noopener"
href="https://fosstodon.org/@homeassistant"
>
<ha-list-item hasMeta graphic="icon">
<ha-svg-icon .path=${mdiAccountGroup} slot="graphic"></ha-svg-icon>
${this.localize("ui.panel.page-onboarding.welcome.social_media")}
<ha-svg-icon slot="meta" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item>
</a>
</ha-list>
<ha-svg-icon .path=${mdiAccountGroup} slot="start"></ha-svg-icon>
<span slot="headline">
${this.params.localize(
"ui.panel.page-onboarding.welcome.social_media"
)}
</span>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item-button>
</ha-list-nav>
</ha-dialog>`;
}
@@ -105,12 +89,12 @@ class DialogCommunity extends LitElement {
ha-dialog {
--dialog-content-padding: 0;
}
ha-list-item {
height: 56px;
--mdc-list-item-meta-size: 20px;
img {
width: 32px;
height: 32px;
}
a {
text-decoration: none;
ha-svg-icon {
color: var(--ha-color-text-secondary);
}
`;
}
+6 -1
View File
@@ -3,13 +3,18 @@ import type { LocalizeFunc } from "../../common/translations/localize";
export const loadAppDialog = () => import("./app-dialog");
export interface AppDialogParams {
localize: LocalizeFunc;
}
export const showAppDialog = (
element: HTMLElement,
params: { localize: LocalizeFunc }
params: AppDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "app-dialog",
dialogImport: loadAppDialog,
dialogParams: params,
addHistory: false,
});
};
@@ -3,13 +3,18 @@ import type { LocalizeFunc } from "../../common/translations/localize";
export const loadCommunityDialog = () => import("./community-dialog");
export interface CommunityDialogParams {
localize: LocalizeFunc;
}
export const showCommunityDialog = (
element: HTMLElement,
params: { localize: LocalizeFunc }
params: CommunityDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "community-dialog",
dialogImport: loadCommunityDialog,
dialogParams: params,
addHistory: false,
});
};
@@ -185,23 +185,25 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
private _renderInfoCard() {
const systemManaged = this._isSystemManaged(this._currentAddon);
return html`<ha-card outlined>
return html` <ha-card outlined>
<div class="card-content">
<div class="addon-header">
${this._currentAddon.logo
? html`
<img
class="logo"
alt=""
src="/api/hassio/addons/${this._currentAddon.slug}/logo"
/>
`
: nothing}
<div class="title">
${getAppDisplayName(
this._currentAddon.name,
this._currentAddon.stage
)}
${this._currentAddon.logo
? html`
<img
class="logo"
alt=""
src="/api/hassio/addons/${this._currentAddon.slug}/logo"
/>
`
: nothing}
${!this.narrow
? getAppDisplayName(
this._currentAddon.name,
this._currentAddon.stage
)
: nothing}
<div class="description">
${this._currentAddon.version
? html`
@@ -239,17 +241,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
? html`<supervisor-apps-state
.state=${this._currentAddon.state}
></supervisor-apps-state>`
: html`
<ha-progress-button
.disabled=${!this._currentAddon.available}
@click=${this._installClicked}
.iconPath=${mdiApplicationImport}
>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.install"
)}
</ha-progress-button>
`}
: nothing}
</div>
<ha-chip-set class="capabilities">
@@ -513,7 +505,8 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
</div>
${(this._currentAddon.update_available && this._updateEntityId) ||
this._computeShowWebUI ||
this._computeShowIngressUI
this._computeShowIngressUI ||
!this._currentAddon.version
? html`
<div class="card-actions">
${this._currentAddon.update_available && this._updateEntityId
@@ -549,6 +542,19 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
</ha-button>
`
: nothing}
${!this._currentAddon.version
? html`
<ha-progress-button
.disabled=${!this._currentAddon.available}
@click=${this._installClicked}
.iconPath=${mdiApplicationImport}
>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.install"
)}
</ha-progress-button>
`
: nothing}
</div>
`
: nothing}
@@ -1497,16 +1503,17 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
}
.addon-header {
display: flex;
padding-inline-start: var(--ha-space-2);
padding-inline-end: initial;
font-size: var(--ha-font-size-2xl);
color: var(--ha-card-header-color, var(--primary-text-color));
align-items: center;
gap: var(--ha-space-2);
flex-wrap: wrap;
margin-bottom: var(--ha-space-4);
}
.addon-header .title {
flex: 1;
margin-inline-end: var(--ha-space-4);
}
.addon-header .title .description {
@@ -1525,17 +1532,15 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
color: var(--error-color);
margin-bottom: var(--ha-space-4);
}
.description {
margin-bottom: var(--ha-space-4);
}
.description a {
color: var(--primary-color);
}
img.logo {
max-width: 100%;
max-height: 60px;
max-height: 40px;
display: block;
margin-bottom: var(--ha-space-2);
}
ha-assist-chip {
--md-sys-color-primary: var(--text-primary-color);
@@ -1,5 +1,5 @@
import "@home-assistant/webawesome/dist/components/tag/tag";
import { mdiHelpCircleOutline } from "@mdi/js";
import { mdiCheckCircle, mdiHelpCircleOutline } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
@@ -25,7 +25,9 @@ class SupervisorAppsCardContent extends LitElement {
@property() public stage: AddonStage = "stable";
@property() public state: AddonState = null;
@property() public state?: AddonState;
@property({ type: Boolean }) public installed = false;
@property() public description?: string;
@@ -77,13 +79,23 @@ class SupervisorAppsCardContent extends LitElement {
</div>
</div>
</div>
${this.tags?.length || this.state
${this.tags?.length || this.state !== undefined || this.installed
? html`
<div class="footer">
<supervisor-apps-state
.state=${this.state || "unknown"}
></supervisor-apps-state>
${this.state !== undefined
? html`<supervisor-apps-state
.state=${this.state || "unknown"}
></supervisor-apps-state>`
: this.installed
? html`<div class="installed">
<ha-svg-icon .path=${mdiCheckCircle}></ha-svg-icon>
<span
>${this.hass.localize(
"ui.panel.config.apps.state.installed"
)}</span
>
</div>`
: html`<span></span>`}
${this.tags?.length
? html`<div class="tags">
${this.tags.map(
@@ -159,6 +171,17 @@ class SupervisorAppsCardContent extends LitElement {
display: flex;
gap: var(--ha-space-2);
}
.installed {
display: inline-flex;
align-items: center;
gap: var(--ha-space-2);
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-m);
}
.installed ha-svg-icon {
--mdc-icon-size: 16px;
color: var(--ha-color-on-success-normal);
}
`;
}
@@ -1,7 +1,14 @@
import { mdiArrowUpBoldCircle, mdiPuzzle } from "@mdi/js";
import {
mdiAlertDecagramOutline,
mdiArrowUpBoldCircle,
mdiArrowUpBoldCircleOutline,
mdiFlask,
mdiPuzzle,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
@@ -10,6 +17,7 @@ import type { HassioAddonRepository } from "../../../data/hassio/addon";
import type { StoreAddon } from "../../../data/supervisor/store";
import type { HomeAssistant } from "../../../types";
import "./components/supervisor-apps-card-content";
import type { AppTag } from "./components/supervisor-apps-card-content";
import { filterAndSort } from "./components/supervisor-apps-filter";
import { supervisorAppsStyle } from "./resources/supervisor-apps-style";
@@ -54,21 +62,29 @@ export class SupervisorAppsRepositoryEl extends LitElement {
<div class="content">
<h1>${repo.name}</h1>
<div class="card-group">
${addons.map(
(addon) => html`
${addons.map((addon) => {
const tags = this._getAppTags(addon);
return html`
<ha-card
outlined
.addon=${addon}
class=${addon.available ? "" : "not_available"}
@click=${this._addonTapped}
>
<div class="card-content">
<div
class=${classMap({
"card-content": true,
"has-footer": tags.length > 0 || addon.installed,
})}
>
<supervisor-apps-card-content
.hass=${this.hass}
.title=${addon.name}
.stage=${addon.stage}
.description=${addon.description}
.available=${addon.available}
.installed=${addon.installed}
.tags=${tags}
.icon=${addon.installed && addon.update_available
? mdiArrowUpBoldCircle
: mdiPuzzle}
@@ -108,8 +124,8 @@ export class SupervisorAppsRepositoryEl extends LitElement {
></supervisor-apps-card-content>
</div>
</ha-card>
`
)}
`;
})}
</div>
</div>
`;
@@ -119,6 +135,32 @@ export class SupervisorAppsRepositoryEl extends LitElement {
navigate(`/config/app/${ev.currentTarget.addon.slug}/info?store=true`);
}
private _getAppTags(addon: StoreAddon): AppTag[] {
const labels: AppTag[] = [];
if (addon.installed && addon.update_available) {
labels.push({
label: this.hass.localize(
`ui.panel.config.apps.state.update_available`
),
variant: "brand",
iconPath: mdiArrowUpBoldCircleOutline,
});
}
if (addon.stage !== "stable") {
labels.push({
label: this.hass.localize(
`ui.panel.config.apps.dashboard.capability.stages.${addon.stage}`
),
variant: addon.stage === "experimental" ? "warning" : "danger",
iconPath:
addon.stage === "experimental" ? mdiFlask : mdiAlertDecagramOutline,
});
}
return labels;
}
static get styles(): CSSResultGroup {
return [
supervisorAppsStyle,
@@ -127,6 +169,12 @@ export class SupervisorAppsRepositoryEl extends LitElement {
cursor: pointer;
overflow: hidden;
}
ha-card:hover {
background-color: var(--ha-color-fill-neutral-quiet-resting);
}
.card-content.has-footer {
padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
}
.not_available {
opacity: 0.6;
}
+101 -108
View File
@@ -12,22 +12,32 @@ import {
import { computeAreaName } from "../../../common/entity/compute_area_name";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-adaptive-dialog";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon";
import {
areasContext,
internationalizationContext,
} from "../../../data/context";
import type { SceneEntities } from "../../../data/scene";
import { showSceneEditor } from "../../../data/scene";
import "../../../dialogs/add-to/ha-add-to-action-list";
import type {
AddToActionListActionSelectedEvent,
AddToActionListItem,
AddToActionListSection,
} from "../../../dialogs/add-to/ha-add-to-action-list";
import {
addToActionHandler,
type AddToActionKey,
} from "../../../dialogs/more-info/add-to";
createAddToSceneEntities,
type AddToAutomationScriptActionKey,
} from "../../../dialogs/add-to/add-to";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { AreaAddToDialogParams } from "./show-dialog-area-add-to";
type AreaAddToAction =
| (AddToActionListItem & {
type: "automation";
key: AddToAutomationScriptActionKey;
})
| (AddToActionListItem & { type: "scene" });
@customElement("dialog-area-add-to")
class DialogAreaAddTo extends LitElement {
@state()
@@ -65,7 +75,12 @@ class DialogAreaAddTo extends LitElement {
<ha-adaptive-dialog
.open=${this._open}
header-title=${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.title"
"ui.dialogs.more_info_control.add_to.title",
{
target:
computeAreaName(this._areas[this._params.areaId]) ||
this._params.areaId,
}
)}
@closed=${this._dialogClosed}
>
@@ -79,108 +94,96 @@ class DialogAreaAddTo extends LitElement {
return nothing;
}
const area = this._areas[this._params.areaId];
const areaName = computeAreaName(area) || this._params.areaId;
return html`
<h3 class="section-header">
${this._i18n.localize(
const sections: AddToActionListSection<AreaAddToAction>[] = [
{
title: this._i18n.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
</h3>
<ha-list>
${this._renderActionItem(
"automation_trigger",
mdiRobotOutline,
"ui.dialogs.more_info_control.add_to.actions.automation_trigger",
areaName
)}
${this._renderActionItem(
"automation_condition",
mdiPlaylistCheck,
"ui.dialogs.more_info_control.add_to.actions.automation_condition",
areaName
)}
${this._renderActionItem(
"automation_action",
mdiPlayCircleOutline,
"ui.dialogs.more_info_control.add_to.actions.automation_action",
areaName
)}
</ha-list>
<h3 class="section-header">
${this._i18n.localize("ui.panel.config.devices.script.scripts_heading")}
</h3>
<ha-list>
${this._renderActionItem(
"script_action",
mdiScriptTextOutline,
"ui.dialogs.more_info_control.add_to.actions.script_action",
areaName
)}
</ha-list>
${this._renderSceneSection(areaName)}
`;
}
),
actions: [
{
type: "automation",
key: "automation_trigger",
iconPath: mdiRobotOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_trigger"
),
},
{
type: "automation",
key: "automation_condition",
iconPath: mdiPlaylistCheck,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_condition"
),
},
{
type: "automation",
key: "automation_action",
iconPath: mdiPlayCircleOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_action"
),
},
],
},
{
title: this._i18n.localize(
"ui.panel.config.devices.script.scripts_heading"
),
actions: [
{
type: "automation",
key: "script_action",
iconPath: mdiScriptTextOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.script_action"
),
},
],
},
];
private _renderSceneSection(areaName: string) {
if (!this._params?.entityIds.length) {
return nothing;
if (this._params.canCreateScene && this._params.entityIds.length) {
sections.push({
title: this._i18n.localize(
"ui.panel.config.devices.scene.scenes_heading"
),
actions: [
{
type: "scene",
iconPath: mdiPalette,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.scene"
),
},
],
});
}
return html`
<h3 class="section-header">
${this._i18n.localize("ui.panel.config.devices.scene.scenes_heading")}
</h3>
<ha-list>
<ha-list-item
graphic="icon"
@click=${this._handleCreateScene}
data-dialog="close"
>
<ha-svg-icon slot="graphic" .path=${mdiPalette}></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.actions.scene",
{ target: areaName }
)}
</ha-list-item>
</ha-list>
<ha-add-to-action-list
.sections=${sections}
@add-to-list-action-selected=${this._handleActionSelected}
></ha-add-to-action-list>
`;
}
private _renderActionItem(
key: AddToActionKey,
path: string,
translationKey:
| "ui.dialogs.more_info_control.add_to.actions.automation_trigger"
| "ui.dialogs.more_info_control.add_to.actions.automation_condition"
| "ui.dialogs.more_info_control.add_to.actions.automation_action"
| "ui.dialogs.more_info_control.add_to.actions.script_action",
areaName: string
private _handleActionSelected(
ev: AddToActionListActionSelectedEvent<AreaAddToAction>
) {
return html`
<ha-list-item
graphic="icon"
data-type=${key}
@click=${this._handleAction}
data-dialog="close"
>
<ha-svg-icon slot="graphic" .path=${path}></ha-svg-icon>
${this._i18n.localize(translationKey, { target: areaName })}
</ha-list-item>
`;
}
private _handleAction(ev: Event) {
if (!this._params) {
return;
}
const key = (ev.currentTarget as HTMLElement).dataset
.type as AddToActionKey;
const { action } = ev.detail;
if (action.type === "scene") {
this._handleCreateScene();
return;
}
this.closeDialog();
addToActionHandler(key, { area_id: this._params.areaId });
addToActionHandler(action.key, { area_id: this._params.areaId });
}
private _handleCreateScene() {
@@ -188,13 +191,11 @@ class DialogAreaAddTo extends LitElement {
return;
}
const entities: SceneEntities = {};
for (const entityId of this._params.entityIds) {
entities[entityId] = "";
}
this.closeDialog();
showSceneEditor({ entities }, this._params.areaId);
showSceneEditor(
{ entities: createAddToSceneEntities(this._params.entityIds) },
this._params.areaId
);
}
static get styles(): CSSResultGroup {
@@ -205,14 +206,6 @@ class DialogAreaAddTo extends LitElement {
ha-adaptive-dialog {
--dialog-content-padding: 0;
}
.section-header {
padding: var(--ha-space-2) var(--ha-space-4) 0;
margin: 0;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
color: var(--secondary-text-color);
}
`,
];
}
@@ -48,8 +48,6 @@ import type { AreaRegistryDetailDialogParams } from "./show-dialog-area-registry
const cropOptions: CropOptions = {
round: false,
type: "image/jpeg",
quality: 0.75,
};
const SENSOR_DOMAINS = ["sensor"];
+11 -2
View File
@@ -60,6 +60,7 @@ import type { SceneEntity } from "../../../data/scene";
import type { ScriptEntity } from "../../../data/script";
import type { RelatedResult } from "../../../data/search";
import { findRelated } from "../../../data/search";
import { filterAddToSceneEntityIds } from "../../../dialogs/add-to/add-to";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import "../../../layouts/hass-error-screen";
@@ -439,7 +440,7 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
.path=${mdiPlus}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.title"
"ui.dialogs.more_info_control.add_to.item"
)}
</ha-button>`
: nothing}
@@ -781,9 +782,17 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
if (!area) {
return;
}
const sceneEntityIds = filterAddToSceneEntityIds(
this._areaEntityIds,
this._entityReg,
this.hass.states
);
showAreaAddToDialog(this, {
areaId: area.area_id,
entityIds: this._areaEntityIds,
entityIds: sceneEntityIds,
canCreateScene:
isComponentLoaded(this.hass.config, "scene") &&
sceneEntityIds.length > 0,
});
}
@@ -3,6 +3,7 @@ import { fireEvent } from "../../../common/dom/fire_event";
export interface AreaAddToDialogParams {
areaId: string;
entityIds: string[];
canCreateScene: boolean;
}
export const loadAreaAddToDialog = () => import("./dialog-area-add-to");
@@ -41,6 +41,7 @@ import { handleStructError } from "../../../../common/structs/handle-errors";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/automation/ha-automation-row";
import type { HaAutomationRow } from "../../../../components/automation/ha-automation-row";
import "../../../../components/automation/ha-automation-condition-live-test";
import "../../../../components/automation/ha-automation-row-event-chip";
import "../../../../components/ha-card";
import "../../../../components/ha-dropdown";
@@ -312,13 +313,27 @@ export default class HaAutomationActionRow extends LitElement {
.service=${this.action.action}
></ha-service-icon>
`
: html`
<ha-svg-icon
: type === "condition" &&
this.optionsInSidebar &&
(this.action as Condition).condition !== "trigger"
? html`<ha-automation-condition-live-test
id="condition-icon"
slot="leading-icon"
class="action-icon"
.path=${ACTION_ICONS[type!]}
></ha-svg-icon>
`}
.hass=${this.hass}
.condition=${this.action as Condition}
>
<ha-svg-icon
class="action-icon"
.path=${ACTION_ICONS[type]}
></ha-svg-icon>
</ha-automation-condition-live-test>`
: html`
<ha-svg-icon
slot="leading-icon"
class="action-icon"
.path=${ACTION_ICONS[type!]}
></ha-svg-icon>
`}
<h3 slot="header">
${capitalizeFirstLetter(
describeAction(
@@ -334,13 +349,15 @@ export default class HaAutomationActionRow extends LitElement {
? this._renderTargets(
target,
actionHasTarget && !this._isNew,
serviceTargetSpec
serviceTargetSpec,
type !== "device_id"
)
: nothing}
${noteTooltipText
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
@@ -721,13 +738,14 @@ export default class HaAutomationActionRow extends LitElement {
(
target?: HassServiceTarget,
targetRequired = false,
targetSpec?: TargetSelector["target"]
targetSpec?: TargetSelector["target"],
interactive = false
) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
.targetRequired=${targetRequired}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.interactive=${interactive}
></ha-automation-row-targets>`
);
@@ -30,10 +30,10 @@ import type {
LocalizeFunc,
LocalizeKeys,
} from "../../../common/translations/localize";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import { computeRTL } from "../../../common/util/compute_rtl";
import { debounce } from "../../../common/util/debounce";
import { deepEqual } from "../../../common/util/deep-equal";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import "../../../components/entity/state-badge";
import "../../../components/ha-bottom-sheet";
import "../../../components/ha-button";
@@ -134,8 +134,8 @@ import {
ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_QUERY_PARAM,
getAddAutomationElementTargetFromQuery,
PASTE_VALUE,
getAddAutomationElementTargetFromQuery,
} from "./show-add-automation-element-dialog";
import { getTargetText } from "./target/get_target_text";
@@ -795,37 +795,33 @@ class DialogAddAutomationElement
class="paste"
@click=${this._paste}
>
<div class="shortcut-label">
<div class="label">
<div>
${this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.paste`
)}
</div>
<div class="supporting-text">
${this.hass.localize(
// @ts-ignore
`ui.panel.config.automation.editor.${automationElementType}s.type.${this._params.clipboardItem}.label`
)}
</div>
</div>
${!this._narrow
? html`<span class="shortcut">
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
"ui.panel.config.automation.editor.ctrl"
)}</span
>
<span>+</span>
<span>V</span>
</span>`
: nothing}
<div slot="headline" class="label">
${this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.paste`
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
// @ts-ignore
`ui.panel.config.automation.editor.${automationElementType}s.type.${this._params.clipboardItem}.label`
)}
</div>
${!this._narrow
? html`<span slot="end" class="shortcut">
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
"ui.panel.config.automation.editor.ctrl"
)}</span
>
<span>+</span>
<span>V</span>
</span>`
: nothing}
<ha-svg-icon
slot="start"
.path=${mdiContentPaste}
@@ -2546,23 +2542,16 @@ class DialogAddAutomationElement
ha-svg-icon.plus {
color: var(--primary-color);
}
.shortcut-label {
display: flex;
gap: var(--ha-space-3);
justify-content: space-between;
}
.shortcut-label .supporting-text {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.shortcut-label .shortcut {
.shortcut {
--mdc-icon-size: var(--ha-space-3);
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 2px;
margin-right: var(--ha-space-4);
}
.shortcut-label .shortcut span {
.shortcut span {
font-size: var(--ha-font-size-s);
font-family: var(--ha-font-family-code);
color: var(--ha-color-text-secondary);
@@ -19,10 +19,7 @@ import {
mdiStopCircleOutline,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import type {
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { dump } from "js-yaml";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
@@ -38,12 +35,10 @@ import { capitalizeFirstLetter } from "../../../../common/string/capitalize-firs
import { truncateWithEllipsis } from "../../../../common/string/truncate-with-ellipsis";
import { handleStructError } from "../../../../common/structs/handle-errors";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/automation/ha-automation-row";
import type { HaAutomationRow } from "../../../../components/automation/ha-automation-row";
import "../../../../components/automation/ha-automation-condition-live-test";
import "../../../../components/automation/ha-automation-row-event-chip";
import "../../../../components/automation/ha-automation-row-live-test";
import type { LiveTestState } from "../../../../components/automation/ha-automation-row-live-test";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-condition-icon";
@@ -52,17 +47,14 @@ import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-tooltip";
import type {
AutomationClipboard,
Condition,
ConditionSidebarConfig,
PlatformCondition,
} from "../../../../data/automation";
import {
isCondition,
subscribeCondition,
testCondition,
} from "../../../../data/automation";
import { isCondition, testCondition } from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
import type { ConditionDescriptions } from "../../../../data/condition";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
@@ -154,11 +146,6 @@ export default class HaAutomationConditionRow extends LitElement {
@state() private _selected = false;
@state() private _liveTestResult: {
state: LiveTestState;
message?: string;
} = { state: "unknown" };
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -171,8 +158,6 @@ export default class HaAutomationConditionRow extends LitElement {
private _testingTimeout?: number;
private _conditionUnsub?: Promise<UnsubscribeFunc>;
get selected() {
return this._selected;
}
@@ -211,11 +196,28 @@ export default class HaAutomationConditionRow extends LitElement {
);
return html`
<ha-condition-icon
slot="leading-icon"
.hass=${this.hass}
.condition=${this.condition.condition}
></ha-condition-icon>
${this.optionsInSidebar && this.condition.condition !== "trigger"
? html`<ha-automation-condition-live-test
id="condition-icon"
slot="leading-icon"
.hass=${this.hass}
.condition=${this.condition}
>
<ha-condition-icon
.hass=${this.hass}
.condition=${this.condition.condition}
></ha-condition-icon>
</ha-automation-condition-live-test>`
: html`<div
id="condition-icon"
class="icon-badge-wrapper"
slot="leading-icon"
>
<ha-condition-icon
.hass=${this.hass}
.condition=${this.condition.condition}
></ha-condition-icon>
</div>`}
<h3 slot="header">
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
@@ -224,13 +226,15 @@ export default class HaAutomationConditionRow extends LitElement {
? this._renderTargets(
target,
descriptionHasTarget && !this._isNew,
conditionTargetSpec
conditionTargetSpec,
this.condition.condition !== "device"
)
: nothing}
${this.condition.note?.trim()
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
@@ -529,17 +533,7 @@ export default class HaAutomationConditionRow extends LitElement {
@click=${this._toggleSidebar}
@toggle-collapsed=${this._toggleCollapse}
>${this._renderRow()}
<ha-automation-row-live-test
slot="icons"
.state=${this.condition.condition !== "trigger"
? this._liveTestResult.state
: "unknown"}
.label=${this.hass.localize(
`ui.panel.config.automation.editor.conditions.live_test_state.${this.condition.condition !== "trigger" ? this._liveTestResult.state : "unknown"}`
)}
.message=${this._liveTestResult.message}
></ha-automation-row-live-test
></ha-automation-row>`
</ha-automation-row>`
: html`
<ha-expansion-panel
left-chevron
@@ -573,21 +567,17 @@ export default class HaAutomationConditionRow extends LitElement {
(
target?: HassServiceTarget,
targetRequired = false,
targetSpec?: TargetSelector["target"]
targetSpec?: TargetSelector["target"],
interactive = false
) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
.targetRequired=${targetRequired}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.interactive=${interactive}
></ha-automation-row-targets>`
);
public connectedCallback(): void {
super.connectedCallback();
this._subscribeCondition();
}
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
@@ -603,85 +593,11 @@ export default class HaAutomationConditionRow extends LitElement {
}
}
protected override updated(changedProps: PropertyValues<this>): void {
super.updated(changedProps);
if (
changedProps.has("condition") &&
changedProps.get("condition") !== undefined
) {
this._resetSubscription();
this._debounceSubscribeCondition();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._debounceSubscribeCondition.cancel();
if (this._testingTimeout !== undefined) {
clearTimeout(this._testingTimeout);
}
this._resetSubscription();
}
private _resetSubscription() {
this._liveTestResult = {
state: "unknown",
message: this.hass.localize(
"ui.panel.config.automation.editor.conditions.live_test_state.unknown"
),
};
if (this._conditionUnsub) {
this._conditionUnsub.then((unsub) => unsub());
this._conditionUnsub = undefined;
}
}
private _debounceSubscribeCondition = debounce(
() => this._subscribeCondition(),
500
);
private async _subscribeCondition() {
this._resetSubscription();
if (!this.condition) {
return;
}
const conditionUnsub = subscribeCondition(
this.hass.connection,
(result) => {
if (result.error) {
this._handleLiveTestError(result.error);
} else {
this._liveTestResult = {
state: result.result ? "pass" : "fail",
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.testing_${result.result ? "pass" : "error"}`
),
};
}
},
this.condition
);
conditionUnsub.catch((err: any) => {
this._handleLiveTestError(err);
if (this._conditionUnsub === conditionUnsub) {
this._conditionUnsub = undefined;
}
});
this._conditionUnsub = conditionUnsub;
}
private _handleLiveTestError(error: any) {
const invalid =
typeof error !== "string" && error.code === "invalid_format";
this._liveTestResult = {
state: invalid ? "invalid" : "unknown",
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.${invalid ? "invalid_condition" : "live_test_state.unknown"}`
),
};
}
private _onValueChange(event: CustomEvent) {
@@ -40,37 +40,6 @@ export class HaSunCondition extends LitElement implements ConditionElement {
private _schema = memoizeOne(
(localize: LocalizeFunc, formType: FormType) =>
[
...(["between", "before"].includes(formType)
? [
{
name: "before",
type: "select",
default: BEFORE_DEFAULT,
options: [
[
"sunrise",
localize(
"ui.panel.config.automation.editor.conditions.type.sun.sunrise"
),
],
[
"sunset",
localize(
"ui.panel.config.automation.editor.conditions.type.sun.sunset"
),
],
],
},
{
name: "before_offset",
selector: {
duration: {
allow_negative: true,
},
},
},
]
: []),
...(["between", "after"].includes(formType)
? [
{
@@ -102,6 +71,37 @@ export class HaSunCondition extends LitElement implements ConditionElement {
},
]
: []),
...(["between", "before"].includes(formType)
? [
{
name: "before",
type: "select",
default: BEFORE_DEFAULT,
options: [
[
"sunrise",
localize(
"ui.panel.config.automation.editor.conditions.type.sun.sunrise"
),
],
[
"sunset",
localize(
"ui.panel.config.automation.editor.conditions.type.sun.sunset"
),
],
],
},
{
name: "before_offset",
selector: {
duration: {
allow_negative: true,
},
},
},
]
: []),
] as const
);
@@ -161,6 +161,7 @@ export default class HaAutomationOptionRow extends LitElement {
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
+5
View File
@@ -53,6 +53,11 @@ export const rowStyles = css`
position: absolute;
}
.icon-badge-wrapper {
position: relative;
display: inline-flex;
}
.note-indicator {
color: var(--ha-color-on-neutral-normal);
}
@@ -60,6 +60,9 @@ export class HaAutomationRowTargets extends LitElement {
@property({ attribute: false })
public selector?: TargetSelector;
@property({ type: Boolean })
public interactive = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -89,7 +92,12 @@ export class HaAutomationRowTargets extends LitElement {
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
private _countCache = new Map<string, Promise<number | undefined>>();
private _countCache = new Map<
string,
Promise<number | undefined> | number | undefined
>();
private _rerenderCount = true;
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
@@ -98,10 +106,15 @@ export class HaAutomationRowTargets extends LitElement {
changedProps.has("selector") ||
changedProps.has("_registries")
) {
this._countCache.clear();
this._rerenderCount = true;
}
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
this._rerenderCount = false;
}
private _countMatchingEntities(referencedEntities: string[]): number {
const targetSelector = this.selector;
const hasEntityFilter = !!targetSelector?.target?.entity;
@@ -148,7 +161,11 @@ export class HaAutomationRowTargets extends LitElement {
targetId: string
) {
const key = `${targetType}:${targetId}`;
if (!this._countCache.has(key)) {
let fallback = " (-)";
if (!this._countCache.has(key) || this._rerenderCount) {
if (typeof this._countCache.get(key) === "number") {
fallback = ` (${this._countCache.get(key)})`;
}
this._countCache.set(
key,
extractFromTarget(
@@ -162,15 +179,30 @@ export class HaAutomationRowTargets extends LitElement {
.then((result) =>
this._countMatchingEntities(result.referenced_entities)
)
.catch(() => undefined)
.catch((err) => {
// eslint-disable-next-line no-console
console.error("Error counting target entities", err);
return undefined;
})
);
}
return until(
this._countCache
.get(key)!
.then((count) => (count === undefined ? nothing : html` (${count})`)),
"(-)"
);
if (this._countCache.get(key) instanceof Promise) {
return until(
(this._countCache.get(key) as Promise<number | undefined>)!.then(
(count) => {
this._countCache.set(key, count);
return count === undefined ? nothing : html` (${count})`;
}
),
fallback
);
}
if (typeof this._countCache.get(key) === "number") {
return ` (${this._countCache.get(key)})`;
}
return nothing;
}
protected render() {
@@ -249,8 +281,9 @@ export class HaAutomationRowTargets extends LitElement {
<ha-dropdown
@wa-select=${this._handleTargetSelect}
@click=${stopPropagation}
@keydown=${stopPropagation}
>
<span slot="trigger" class="target interactive">
<button slot="trigger" class="target">
<ha-svg-icon .path=${mdiFormatListBulleted}></ha-svg-icon>
<div class="label">
${this._i18n.localize(
@@ -261,7 +294,7 @@ export class HaAutomationRowTargets extends LitElement {
)}
</div>
<ha-svg-icon .path=${mdiMenuDown}></ha-svg-icon>
</span>
</button>
${rows.map(([targetType, targetId]) => {
const content = html`${lastTargetType !== null &&
lastTargetType !== targetType
@@ -316,21 +349,37 @@ export class HaAutomationRowTargets extends LitElement {
targetType?: string,
countTemplate: unknown = nothing
) {
return html`<div
if (!this.interactive || !targetId || !targetType) {
return html`<div
class=${classMap({
target: true,
warning,
error,
})}
.targetId=${targetId}
.targetType=${targetType}
.label=${label}
>
${icon}
<div class="label">${label}${countTemplate}</div>
</div>`;
}
return html`<button
class=${classMap({
target: true,
warning,
error,
interactive: targetId && targetType,
})}
.targetId=${targetId}
.targetType=${targetType}
.label=${label}
@click=${this._handleTargetClick}
@keydown=${this._handleTargetKeydown}
>
${icon}
<div class="label">${label}${countTemplate}</div>
</div>`;
</button>`;
}
private _renderTarget(
@@ -384,7 +433,7 @@ export class HaAutomationRowTargets extends LitElement {
targetId,
this._getLabel
);
if (targetType !== "entity") {
if (targetType !== "entity" && this.interactive) {
countTemplate = this._renderCount(targetType, targetId);
}
}
@@ -444,6 +493,13 @@ export class HaAutomationRowTargets extends LitElement {
this._showTargetInfo(target.targetId, target.targetType, target.label, ev);
}
private _handleTargetKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._handleTargetClick(ev);
}
}
private _handleTargetSelect(
ev: HaDropdownSelectEvent<{
targetId?: string;
@@ -533,10 +589,10 @@ export class HaAutomationRowTargets extends LitElement {
align-items: center;
}
.target.interactive {
button.target {
cursor: pointer;
}
.target.interactive:hover {
button.target:hover {
background: var(--ha-color-fill-neutral-normal-hover);
}
@@ -249,13 +249,15 @@ export default class HaAutomationTriggerRow extends LitElement {
? this._renderTargets(
target,
descriptionHasTarget && !this._isNew,
triggerTargetSpec
triggerTargetSpec,
type !== "device"
)
: nothing}
${type !== "list" &&
(this.trigger as Exclude<Trigger, TriggerList>).note?.trim()
? html`
<ha-svg-icon
tabindex="0"
id="note-icon"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
@@ -557,13 +559,14 @@ export default class HaAutomationTriggerRow extends LitElement {
(
target?: HassServiceTarget,
targetRequired = false,
targetSpec?: TargetSelector["target"]
targetSpec?: TargetSelector["target"],
interactive = false
) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
.targetRequired=${targetRequired}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.interactive=${interactive}
></ha-automation-row-targets>`
);
@@ -299,9 +299,10 @@ class DialogNewDashboard extends LitElement implements HassDialog {
const options: IFuseOptions<CustomStrategyEntry> = {
keys: ["type", "name", "description"],
isCaseSensitive: false,
threshold: 0.3,
ignoreLocation: true,
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.3,
ignoreDiacritics: true,
ignoreLocation: true,
};
const fuse = new Fuse(strategies, options);
return fuse.search(filter).map((result) => result.item);
@@ -12,8 +12,6 @@ import {
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-adaptive-dialog";
import "../../../../components/ha-list";
import "../../../../components/ha-list-item";
import "../../../../components/ha-spinner";
import type { AutomationConfig } from "../../../../data/automation";
import { showAutomationEditor } from "../../../../data/automation";
@@ -35,15 +33,38 @@ import {
} from "../../../../data/device/device_automation";
import type { ScriptConfig } from "../../../../data/script";
import { showScriptEditor } from "../../../../data/script";
import type { SceneEntities } from "../../../../data/scene";
import { showSceneEditor } from "../../../../data/scene";
import "../../../../dialogs/add-to/ha-add-to-action-list";
import type {
AddToActionListActionSelectedEvent,
AddToActionListItem,
AddToActionListSection,
} from "../../../../dialogs/add-to/ha-add-to-action-list";
import {
addToActionHandler,
type AddToActionKey,
} from "../../../../dialogs/more-info/add-to";
createAddToSceneEntities,
type AddToAutomationScriptActionKey,
} from "../../../../dialogs/add-to/add-to";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { DeviceAddToDialogParams } from "./show-dialog-device-add-to";
type DeviceLegacyAddToActionType =
| "trigger"
| "condition"
| "automation_action"
| "script_action";
type DeviceAddToAction =
| (AddToActionListItem & {
kind: "add-to";
key: AddToAutomationScriptActionKey;
})
| (AddToActionListItem & {
kind: "legacy";
legacyType: DeviceLegacyAddToActionType;
})
| (AddToActionListItem & { kind: "scene" });
@customElement("dialog-device-add-to")
export class DialogDeviceAddTo extends LitElement {
@state()
@@ -132,11 +153,18 @@ export class DialogDeviceAddTo extends LitElement {
return nothing;
}
const deviceName = computeDeviceNameDisplay(
this._params.device,
this._i18n.localize,
this._states
);
return html`
<ha-adaptive-dialog
.open=${this._open}
header-title=${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.title"
"ui.dialogs.more_info_control.add_to.title",
{ target: deviceName }
)}
@closed=${this._dialogClosed}
>
@@ -151,80 +179,62 @@ export class DialogDeviceAddTo extends LitElement {
if (!this._params) {
return nothing;
}
const deviceName = computeDeviceNameDisplay(
this._params.device,
this._i18n.localize,
this._states
);
const sections: AddToActionListSection<DeviceAddToAction>[] = [
{
title: this._i18n.localize(
"ui.panel.config.devices.automation.automations_heading"
),
actions: [
{
kind: "add-to",
key: "automation_trigger",
iconPath: mdiRobotOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_trigger"
),
},
{
kind: "add-to",
key: "automation_condition",
iconPath: mdiPlaylistCheck,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_condition"
),
},
{
kind: "add-to",
key: "automation_action",
iconPath: mdiPlayCircleOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_action"
),
},
],
},
{
title: this._i18n.localize(
"ui.panel.config.devices.script.scripts_heading"
),
actions: [
{
kind: "add-to",
key: "script_action",
iconPath: mdiScriptTextOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.script_action"
),
},
],
},
];
this._addSceneSection(sections);
return html`
<h3 class="section-header">
${this._i18n.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
</h3>
<ha-list>
<ha-list-item
graphic="icon"
data-type="automation_trigger"
@click=${this._handleNewAction}
data-dialog="close"
>
<ha-svg-icon slot="graphic" .path=${mdiRobotOutline}></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.actions.automation_trigger",
{ target: deviceName }
)}
</ha-list-item>
<ha-list-item
graphic="icon"
data-type="automation_condition"
@click=${this._handleNewAction}
data-dialog="close"
>
<ha-svg-icon slot="graphic" .path=${mdiPlaylistCheck}></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.actions.automation_condition",
{ target: deviceName }
)}
</ha-list-item>
<ha-list-item
graphic="icon"
data-type="automation_action"
@click=${this._handleNewAction}
data-dialog="close"
>
<ha-svg-icon
slot="graphic"
.path=${mdiPlayCircleOutline}
></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.actions.automation_action",
{ target: deviceName }
)}
</ha-list-item>
</ha-list>
<h3 class="section-header">
${this._i18n.localize("ui.panel.config.devices.script.scripts_heading")}
</h3>
<ha-list>
<ha-list-item
graphic="icon"
data-type="script_action"
@click=${this._handleNewAction}
data-dialog="close"
>
<ha-svg-icon
slot="graphic"
.path=${mdiScriptTextOutline}
></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.actions.script_action",
{ target: deviceName }
)}
</ha-list-item>
</ha-list>
${this._renderSceneSection(deviceName)}
<ha-add-to-action-list
.sections=${sections}
@add-to-list-action-selected=${this._handleActionSelected}
></ha-add-to-action-list>
`;
}
@@ -242,12 +252,6 @@ export class DialogDeviceAddTo extends LitElement {
return nothing;
}
const deviceName = computeDeviceNameDisplay(
this._params.device,
this._i18n.localize,
this._states
);
const hasTriggers = Boolean(this._triggers?.length);
const hasConditions = Boolean(this._conditions?.length);
const hasActions = Boolean(this._actions?.length);
@@ -263,165 +267,138 @@ export class DialogDeviceAddTo extends LitElement {
`;
}
return html`
<h3 class="section-header">
${this._i18n.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
</h3>
${hasTriggers || hasConditions || hasActions
? html`
<ha-list>
${hasTriggers
? html`
<ha-list-item
graphic="icon"
data-type="trigger"
@click=${this._handleLegacyAction}
data-dialog="close"
>
<ha-svg-icon
slot="graphic"
.path=${mdiRobotOutline}
></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.actions.automation_trigger",
{ target: deviceName }
)}
</ha-list-item>
`
: nothing}
${hasConditions
? html`
<ha-list-item
graphic="icon"
data-type="condition"
@click=${this._handleLegacyAction}
data-dialog="close"
>
<ha-svg-icon
slot="graphic"
.path=${mdiPlaylistCheck}
></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.actions.automation_condition",
{ target: deviceName }
)}
</ha-list-item>
`
: nothing}
${hasActions
? html`
<ha-list-item
graphic="icon"
data-type="automation_action"
@click=${this._handleLegacyAction}
data-dialog="close"
>
<ha-svg-icon
slot="graphic"
.path=${mdiPlayCircleOutline}
></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.actions.automation_action",
{ target: deviceName }
)}
</ha-list-item>
`
: nothing}
</ha-list>
`
: html`
<ha-list>
<ha-list-item noninteractive>
${this._i18n.localize(
"ui.panel.config.devices.automation.no_automations"
)}
</ha-list-item>
</ha-list>
`}
<h3 class="section-header">
${this._i18n.localize("ui.panel.config.devices.script.scripts_heading")}
</h3>
${hasActions
? html`
<ha-list>
<ha-list-item
graphic="icon"
data-type="script_action"
@click=${this._handleLegacyAction}
data-dialog="close"
>
<ha-svg-icon
slot="graphic"
.path=${mdiScriptTextOutline}
></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.actions.script_action",
{ target: deviceName }
)}
</ha-list-item>
</ha-list>
`
: html`
<ha-list>
<ha-list-item noninteractive>
${this._i18n.localize(
"ui.panel.config.devices.script.no_scripts"
)}
</ha-list-item>
</ha-list>
`}
${this._renderSceneSection(deviceName)}
`;
}
private _renderSceneSection(deviceName: string) {
if (!this._params?.entityIds.length) {
return nothing;
const automationActions: DeviceAddToAction[] = [];
if (hasTriggers) {
automationActions.push({
kind: "legacy",
legacyType: "trigger",
iconPath: mdiRobotOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_trigger"
),
});
}
if (hasConditions) {
automationActions.push({
kind: "legacy",
legacyType: "condition",
iconPath: mdiPlaylistCheck,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_condition"
),
});
}
if (hasActions) {
automationActions.push({
kind: "legacy",
legacyType: "automation_action",
iconPath: mdiPlayCircleOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_action"
),
});
}
const scriptActions: DeviceAddToAction[] = hasActions
? [
{
kind: "legacy",
legacyType: "script_action",
iconPath: mdiScriptTextOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.script_action"
),
},
]
: [];
const sections: AddToActionListSection<DeviceAddToAction>[] = [
{
title: this._i18n.localize(
"ui.panel.config.devices.automation.automations_heading"
),
actions: automationActions,
empty: automationActions.length
? undefined
: this._i18n.localize(
"ui.panel.config.devices.automation.no_automations"
),
},
{
title: this._i18n.localize(
"ui.panel.config.devices.script.scripts_heading"
),
actions: scriptActions,
empty: scriptActions.length
? undefined
: this._i18n.localize("ui.panel.config.devices.script.no_scripts"),
},
];
this._addSceneSection(sections);
return html`
<h3 class="section-header">
${this._i18n.localize("ui.panel.config.devices.scene.scenes_heading")}
</h3>
<ha-list>
<ha-list-item
graphic="icon"
@click=${this._handleCreateScene}
data-dialog="close"
>
<ha-svg-icon slot="graphic" .path=${mdiPalette}></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.actions.scene",
{ target: deviceName }
)}
</ha-list-item>
</ha-list>
<ha-add-to-action-list
.sections=${sections}
@add-to-list-action-selected=${this._handleActionSelected}
></ha-add-to-action-list>
`;
}
private _handleNewAction(ev: Event) {
private _addSceneSection(
sections: AddToActionListSection<DeviceAddToAction>[]
): void {
if (!this._params?.canCreateScene || !this._params.entityIds.length) {
return;
}
sections.push({
title: this._i18n.localize(
"ui.panel.config.devices.scene.scenes_heading"
),
actions: [
{
kind: "scene",
iconPath: mdiPalette,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.scene"
),
},
],
});
}
private _handleActionSelected(
ev: AddToActionListActionSelectedEvent<DeviceAddToAction>
) {
if (!this._params) {
return;
}
const key = (ev.currentTarget as HTMLElement).dataset
.type as AddToActionKey;
const { action } = ev.detail;
if (action.kind === "scene") {
this._handleCreateScene();
return;
}
if (action.kind === "add-to") {
this._handleAddToAction(action.key);
return;
}
this._handleLegacyAction(action.legacyType);
}
private _handleAddToAction(key: AddToAutomationScriptActionKey) {
if (!this._params) {
return;
}
this.closeDialog();
addToActionHandler(key, { device_id: this._params.device.id });
}
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
private _handleLegacyAction(ev: Event) {
if (!this._params) {
return;
}
const type = (ev.currentTarget as HTMLElement).dataset.type as
| "trigger"
| "condition"
| "automation_action"
| "script_action";
private _handleLegacyAction(type: DeviceLegacyAddToActionType) {
this.closeDialog();
if (type === "script_action") {
@@ -430,29 +407,28 @@ export class DialogDeviceAddTo extends LitElement {
newScript.sequence = [this._actions[0]];
}
showScriptEditor(newScript, true);
} else {
const newAutomation = {} as AutomationConfig;
if (type === "trigger" && this._triggers?.length) {
newAutomation.triggers = [this._triggers[0]];
} else if (type === "condition" && this._conditions?.length) {
newAutomation.conditions = [this._conditions[0]];
} else if (type === "automation_action" && this._actions?.length) {
newAutomation.actions = [this._actions[0]];
}
showAutomationEditor(newAutomation, true);
return;
}
const newAutomation = {} as AutomationConfig;
if (type === "trigger" && this._triggers?.length) {
newAutomation.triggers = [this._triggers[0]];
} else if (type === "condition" && this._conditions?.length) {
newAutomation.conditions = [this._conditions[0]];
} else if (type === "automation_action" && this._actions?.length) {
newAutomation.actions = [this._actions[0]];
}
showAutomationEditor(newAutomation, true);
}
private _handleCreateScene() {
if (!this._params) {
return;
}
const entities: SceneEntities = {};
for (const entityId of this._params.entityIds) {
entities[entityId] = "";
}
this.closeDialog();
showSceneEditor({ entities });
showSceneEditor({
entities: createAddToSceneEntities(this._params.entityIds),
});
}
static get styles(): CSSResultGroup {
@@ -469,14 +445,6 @@ export class DialogDeviceAddTo extends LitElement {
padding: var(--ha-space-4);
text-align: center;
}
.section-header {
padding: var(--ha-space-2) var(--ha-space-4) 0;
margin: 0;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
color: var(--secondary-text-color);
}
`,
];
}
@@ -5,6 +5,7 @@ export interface DeviceAddToDialogParams {
device: DeviceRegistryEntry;
newTriggersConditions: boolean;
entityIds: string[];
canCreateScene: boolean;
}
export const loadDeviceAddToDialog = () => import("./ha-device-add-to-dialog");
@@ -86,6 +86,7 @@ import { domainToName } from "../../../data/integration";
import { regenerateEntityIds } from "../../../data/regenerate_entity_ids";
import type { RelatedResult } from "../../../data/search";
import { findRelated } from "../../../data/search";
import { filterAddToSceneEntityIds } from "../../../dialogs/add-to/add-to";
import {
showAlertDialog,
showConfirmationDialog,
@@ -424,6 +425,11 @@ export class HaConfigDevicePage extends LitElement {
this._entityReg,
this.hass.devices
);
const sceneEntityIds = filterAddToSceneEntityIds(
this._entityIds(entities),
this._entityReg,
this.hass.states
);
const entitiesByCategory = this._entitiesByCategory(entities);
const quickLinkCounts = this._getQuickLinkCounts(entities, this._related);
const batteryEntity = this._batteryEntity(entities);
@@ -531,7 +537,7 @@ export class HaConfigDevicePage extends LitElement {
: this.hass.localize("ui.panel.config.devices.add_prompt_enabled");
const hasSceneSupport =
isComponentLoaded(this.hass.config, "scene") && entities.length;
isComponentLoaded(this.hass.config, "scene") && sceneEntityIds.length;
const relatedCard =
isComponentLoaded(this.hass.config, "automation") ||
@@ -551,7 +557,7 @@ export class HaConfigDevicePage extends LitElement {
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.title"
"ui.dialogs.more_info_control.add_to.item"
)}
</ha-button>
</h1>
@@ -1366,10 +1372,18 @@ export class HaConfigDevicePage extends LitElement {
this._entityReg,
this.hass.devices
).map((entity) => entity.entity_id);
const sceneEntityIds = filterAddToSceneEntityIds(
entityIds,
this._entityReg,
this.hass.states
);
showDeviceAddToDialog(this, {
device,
newTriggersConditions: this._newTriggersConditions,
entityIds,
entityIds: sceneEntityIds,
canCreateScene:
isComponentLoaded(this.hass.config, "scene") &&
sceneEntityIds.length > 0,
});
}
@@ -330,42 +330,48 @@ export class BluetoothNetworkVisualization extends LitElement {
return rssi > -33 ? 3 : rssi > -66 ? 2 : 1;
}
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
private _tooltipFormatter = (params: TopLevelFormatterParams) => {
const { dataType, data } = params as CallbackDataParams;
let tooltipText = "";
if (dataType === "edge") {
const { source, target, value } = data as any;
const sourceName = this._getBluetoothDeviceName(source);
const targetName = this._getBluetoothDeviceName(target);
tooltipText = `${sourceName} ${targetName}`;
if (source !== CORE_SOURCE_ID) {
tooltipText += ` <b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b> ${value}`;
}
} else {
const { id: address } = data as any;
const name = this._getBluetoothDeviceName(address);
const btDevice = this._data.find((d) => d.address === address);
if (btDevice) {
tooltipText = `<b>${name}</b><br><b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b> ${address}<br><b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b> ${btDevice.rssi}<br><b>${this.hass.localize("ui.panel.config.bluetooth.source")}:</b> ${btDevice.source}<br><b>${this.hass.localize("ui.panel.config.bluetooth.updated")}:</b> ${relativeTime(new Date(btDevice.time * 1000), this.hass.locale)}`;
const device = this._sourceDevices[address];
if (device) {
const area = getDeviceArea(device, this.hass.areas);
if (area) {
tooltipText += `<br><b>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b>${area.name}`;
}
}
} else {
const device = this._sourceDevices[address];
if (device) {
tooltipText = `<b>${name}</b><br><b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b> ${address}`;
const area = getDeviceArea(device, this.hass.areas);
if (area) {
tooltipText += `<br><b>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b>${area.name}`;
}
}
}
return html`${sourceName}
${targetName}${source !== CORE_SOURCE_ID
? html` <b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b>
${value}`
: nothing}`;
}
return tooltipText;
const { id: address } = data as any;
const name = this._getBluetoothDeviceName(address);
const btDevice = this._data.find((d) => d.address === address);
const device = this._sourceDevices[address];
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
const areaLine = area
? html`<br /><b
>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b
>${area.name}`
: nothing;
if (btDevice) {
return html`<b>${name}</b><br />
<b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b>
${address}<br />
<b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b>
${btDevice.rssi}<br />
<b>${this.hass.localize("ui.panel.config.bluetooth.source")}:</b>
${btDevice.source}<br />
<b>${this.hass.localize("ui.panel.config.bluetooth.updated")}:</b>
${relativeTime(
new Date(btDevice.time * 1000),
this.hass.locale
)}${areaLine}`;
}
if (device) {
return html`<b>${name}</b><br />
<b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b>
${address}${areaLine}`;
}
return nothing;
};
private _handleChartClick(e: CustomEvent): void {
@@ -5,7 +5,7 @@ import { dynamicElement } from "../../../../../common/dom/dynamic-element-direct
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import { computeDeviceName } from "../../../../../common/entity/compute_device_name";
import { navigate } from "../../../../../common/navigate";
import { navigate, setRefreshUrl } from "../../../../../common/navigate";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-icon-button-arrow-prev";
import "../../../../../components/ha-button";
@@ -25,6 +25,7 @@ import {
type ExtEntityRegistryEntry,
} from "../../../../../data/entity/entity_registry";
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
import { OVERRIDE_DEVICE_CLASSES } from "../../../entities/entity-registry-settings-editor";
import "./matter-add-device/matter-add-device-apple-home";
import "./matter-add-device/matter-add-device-existing";
import "./matter-add-device/matter-add-device-generic";
@@ -100,6 +101,8 @@ class DialogMatterAddDevice extends LitElement {
public showDialog(): void {
this._open = true;
this._unsub = watchForNewMatterDevice(this.hass, (device) => {
// make sure a refresh of the page will navigate to the device page, old iOS apps will refresh the webview when commissioning is done
setRefreshUrl(`/config/devices/device/${device.id}`);
this._newDevice = device;
this._step = "device_added";
this._fetchMainEntity();
@@ -137,15 +140,17 @@ class DialogMatterAddDevice extends LitElement {
entityIds
);
const mainEntry = Object.values(entries).find(
(e) => e.original_name === null
);
if (!mainEntry) return;
const domain = computeDomain(mainEntry.entity_id);
if (domain === "cover" || domain === "binary_sensor") {
this._mainEntity = mainEntry;
}
this._mainEntity = Object.values(entries).find((entry) => {
if (entry.entity_category) return false;
const domain = computeDomain(entry.entity_id);
const deviceClasses = OVERRIDE_DEVICE_CLASSES[domain];
if (!deviceClasses) return false;
const deviceClass = entry.device_class ?? entry.original_device_class;
if (!deviceClass) return false;
return deviceClasses.some(
(classes) => classes.length > 1 && classes.includes(deviceClass)
);
});
}
private _dialogClosed(): void {
@@ -131,7 +131,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
this._searchFilter = (ev.target as HaInputSearch).value ?? "";
}
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
private _tooltipFormatter = (params: TopLevelFormatterParams) => {
const { dataType, data, name } = params as CallbackDataParams;
if (dataType === "edge") {
const { source, target, value } = data as any;
@@ -141,40 +141,45 @@ export class ZHANetworkVisualizationPage extends LitElement {
const sourceName = this._networkData.nodes.find(
(node) => node.id === source
)!.name;
const tooltipText = `${sourceName}${targetName}${value ? ` <b>LQI:</b> ${value}` : ""}`;
const reverseValue = this._networkData.links.find(
(link) => link.source === source && link.target === target
)?.reverseValue;
if (reverseValue) {
return `${tooltipText}<br>${targetName}${sourceName} <b>LQI:</b> ${reverseValue}`;
}
return tooltipText;
return html`${sourceName}
${targetName}${value
? html` <b>LQI:</b> ${value}`
: nothing}${reverseValue
? html`<br />${targetName}${sourceName} <b>LQI:</b> ${reverseValue}`
: nothing}`;
}
const device = this._devices.find((d) => d.ieee === (data as any).id);
if (!device) {
return name;
}
let label = `<b>IEEE: </b>${device.ieee}`;
label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.device_type")}: </b>${device.device_type.replace("_", " ")}`;
if (device.nwk != null) {
label += `<br><b>NWK: </b>${formatAsPaddedHex(device.nwk)}`;
}
if (device.manufacturer != null && device.model != null) {
label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.device")}: </b>${device.manufacturer} ${device.model}`;
} else {
label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.device_not_in_db")}</b>`;
return html`${name}`;
}
const haDevice = this.hass.devices[device.device_reg_id] as
| DeviceRegistryEntry
| undefined;
if (haDevice) {
const area = getDeviceArea(haDevice, this.hass.areas);
if (area) {
label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.area")}: </b>${area.name}`;
}
}
return label;
const area = haDevice
? getDeviceArea(haDevice, this.hass.areas)
: undefined;
return html`<b>IEEE: </b>${device.ieee}<br /><b
>${this.hass.localize("ui.panel.config.zha.visualization.device_type")}: </b
>${device.device_type.replace("_", " ")}${device.nwk != null
? html`<br /><b>NWK: </b>${formatAsPaddedHex(device.nwk)}`
: nothing}${device.manufacturer != null && device.model != null
? html`<br /><b
>${this.hass.localize(
"ui.panel.config.zha.visualization.device"
)}: </b
>${device.manufacturer} ${device.model}`
: html`<br /><b
>${this.hass.localize(
"ui.panel.config.zha.visualization.device_not_in_db"
)}</b
>`}${area
? html`<br /><b
>${this.hass.localize("ui.panel.config.zha.visualization.area")}: </b
>${area.name}`
: nothing}`;
};
private async _refreshTopology(): Promise<void> {
@@ -9,6 +9,7 @@ import { getDeviceArea } from "../../../../../common/entity/context/get_device_c
import { navigate } from "../../../../../common/navigate";
import { debounce } from "../../../../../common/util/debounce";
import "../../../../../components/chart/ha-network-graph";
import "../../../../../components/chart/ha-chart-tooltip-marker";
import type {
NetworkData,
NetworkLink,
@@ -150,7 +151,7 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
this._searchFilter = (ev.target as HaInputSearch).value ?? "";
}
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
private _tooltipFormatter = (params: TopLevelFormatterParams) => {
const { dataType, data } = params as CallbackDataParams;
if (dataType === "edge") {
const { source, target, value } = data as any;
@@ -160,39 +161,66 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
sourceDevice?.name_by_user ?? sourceDevice?.name ?? source;
const targetName =
targetDevice?.name_by_user ?? targetDevice?.name ?? target;
let tip = `${sourceName}${targetName}`;
const route =
this._nodeStatistics[source]?.lwr || this._nodeStatistics[source]?.nlwr;
if (route?.protocol_data_rate) {
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.data_rate")}:</b> ${this.hass.localize(`ui.panel.config.zwave_js.protocol_data_rate.${route.protocol_data_rate}`)}`;
}
if (value) {
tip += `<br><b>RSSI:</b> ${value}`;
}
return tip;
return html`${sourceName}
${targetName}${route?.protocol_data_rate
? html`<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.data_rate"
)}:</b
>
${this.hass.localize(
`ui.panel.config.zwave_js.protocol_data_rate.${route.protocol_data_rate}` as any
)}`
: nothing}${value ? html`<br /><b>RSSI:</b> ${value}` : nothing}`;
}
const { id, name } = data as any;
const device = this._devices[id] as DeviceRegistryEntry | undefined;
const nodeStatus = this._nodeStatuses[id];
let tip = `${(params as any).marker} ${name}`;
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.node_id")}:</b> ${id}`;
if (device) {
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.manufacturer")}:</b> ${device.manufacturer || "-"}`;
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.model")}:</b> ${device.model || "-"}`;
}
if (nodeStatus) {
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.status")}:</b> ${this.hass.localize(`ui.panel.config.zwave_js.node_status.${nodeStatus.status}`)}`;
if (nodeStatus.zwave_plus_version) {
tip += `<br><b>Z-Wave Plus:</b> ${this.hass.localize("ui.panel.config.zwave_js.visualization.version")} ${nodeStatus.zwave_plus_version}`;
}
}
if (device) {
const area = getDeviceArea(device, this.hass.areas);
if (area) {
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.area")}:</b> ${area.name}`;
}
}
return tip;
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
return html`<ha-chart-tooltip-marker
.color=${String((params as CallbackDataParams).color ?? "")}
></ha-chart-tooltip-marker>
${name}<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.node_id"
)}:</b
>
${id}${device
? html`<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.manufacturer"
)}:</b
>
${device.manufacturer || "-"}<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.model"
)}:</b
>
${device.model || "-"}`
: nothing}${nodeStatus
? html`<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.status"
)}:</b
>
${this.hass.localize(
`ui.panel.config.zwave_js.node_status.${nodeStatus.status}` as any
)}${nodeStatus.zwave_plus_version
? html`<br /><b>Z-Wave Plus:</b> ${this.hass.localize(
"ui.panel.config.zwave_js.visualization.version"
)}
${nodeStatus.zwave_plus_version}`
: nothing}`
: nothing}${area
? html`<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.area"
)}:</b
>
${area.name}`
: nothing}`;
};
private _getNetworkData = memoizeOne(
@@ -158,6 +158,7 @@ export default class HaScriptFieldRow extends LitElement {
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
+16 -25
View File
@@ -14,7 +14,6 @@ import {
removeSearchParam,
} from "../../common/url/search-params";
import "../../components/date-picker/ha-date-range-picker";
import "../../components/entity/ha-entity-picker";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
@@ -305,24 +304,23 @@ export class HaPanelLogbook extends LitElement {
:host {
--ha-generic-picker-max-width: 400px;
}
ha-logbook {
.content {
display: flex;
flex-direction: column;
height: calc(
100vh -
168px - var(--safe-area-inset-top, 0px) - var(
--safe-area-inset-bottom,
100vh - var(--header-height, 0px) - var(
--safe-area-inset-top,
0px
)
) - var(--safe-area-inset-bottom, 0px)
);
overflow-x: hidden;
padding: 0 0 16px;
}
:host([narrow]) ha-logbook {
height: calc(
100vh -
250px - var(--safe-area-inset-top, 0px) - var(
--safe-area-inset-bottom,
0px
)
);
ha-logbook {
flex: 1;
min-height: 0;
}
ha-date-range-picker {
@@ -337,6 +335,10 @@ export class HaPanelLogbook extends LitElement {
ha-date-range-picker {
width: 100%;
}
.filters {
flex-direction: column;
}
}
:host([narrow]) ha-date-range-picker {
@@ -360,22 +362,11 @@ export class HaPanelLogbook extends LitElement {
flex-wrap: wrap;
}
ha-entity-picker {
display: inline-block;
flex-grow: 1;
max-width: 400px;
}
ha-target-picker {
flex: 1;
max-width: 100%;
min-width: 0;
}
:host([narrow]) ha-entity-picker {
max-width: none;
width: 100%;
}
`,
];
}
@@ -86,7 +86,6 @@ class HuiCounterActionsCardFeature
static getStubConfig(): CounterActionsCardFeatureConfig {
return {
type: "counter-actions",
actions: COUNTER_ACTIONS.map((action) => action),
};
}
@@ -108,10 +107,12 @@ class HuiCounterActionsCardFeature
return null;
}
const actions = this._config?.actions ?? COUNTER_ACTIONS;
return html`
<ha-control-button-group>
${this._config?.actions
?.filter((action) => COUNTER_ACTIONS.includes(action))
${actions
.filter((action) => COUNTER_ACTIONS.includes(action))
.map((action) => {
const button = COUNTER_ACTIONS_BUTTON[action](this._stateObj!);
return html`
@@ -110,20 +110,9 @@ class HuiLawnMowerCommandCardFeature
| undefined;
}
static getStubConfig(
hass: HomeAssistant,
context: LovelaceCardFeatureContext
): LawnMowerCommandsCardFeatureConfig {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
static getStubConfig(): LawnMowerCommandsCardFeatureConfig {
return {
type: "lawn-mower-commands",
commands: stateObj
? LAWN_MOWER_COMMANDS.filter((c) =>
supportsLawnMowerCommand(stateObj, c)
).slice(0, 3)
: [],
};
}
@@ -162,28 +151,28 @@ class HuiLawnMowerCommandCardFeature
const stateObj = this._stateObj as LawnMowerEntity;
const commands = this._config.commands ?? LAWN_MOWER_COMMANDS;
return html`
<ha-control-button-group>
${LAWN_MOWER_COMMANDS.filter(
(command) =>
supportsLawnMowerCommand(stateObj, command) &&
this._config?.commands?.includes(command)
).map((command) => {
const button = LAWN_MOWER_COMMANDS_BUTTONS[command](stateObj);
return html`
<ha-control-button
.entry=${button}
.label=${this.hass!.localize(
// @ts-ignore
`ui.dialogs.more_info_control.lawn_mower.${button.translationKey}`
)}
@click=${this._onCommandTap}
.disabled=${button.disabled || stateObj.state === UNAVAILABLE}
>
<ha-svg-icon .path=${button.icon}></ha-svg-icon>
</ha-control-button>
`;
})}
${commands
.filter((command) => supportsLawnMowerCommand(stateObj, command))
.map((command) => {
const button = LAWN_MOWER_COMMANDS_BUTTONS[command](stateObj);
return html`
<ha-control-button
.entry=${button}
.label=${this.hass!.localize(
// @ts-ignore
`ui.dialogs.more_info_control.lawn_mower.${button.translationKey}`
)}
@click=${this._onCommandTap}
.disabled=${button.disabled || stateObj.state === UNAVAILABLE}
>
<ha-svg-icon .path=${button.icon}></ha-svg-icon>
</ha-control-button>
`;
})}
</ha-control-button-group>
`;
}
@@ -52,6 +52,12 @@ export const VACUUM_COMMANDS_FEATURES: Record<
return_home: [VacuumEntityFeature.RETURN_HOME],
};
export const VACUUM_DEFAULT_COMMANDS: VacuumCommand[] = [
"start_pause",
"stop",
"return_home",
];
export const supportsVacuumCommand = (
stateObj: HassEntity,
command: VacuumCommand
@@ -154,20 +160,9 @@ class HuiVacuumCommandCardFeature
| undefined;
}
static getStubConfig(
hass: HomeAssistant,
context: LovelaceCardFeatureContext
): VacuumCommandsCardFeatureConfig {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
static getStubConfig(): VacuumCommandsCardFeatureConfig {
return {
type: "vacuum-commands",
commands: stateObj
? VACUUM_COMMANDS.filter((c) =>
supportsVacuumCommand(stateObj, c)
).slice(0, 3)
: [],
};
}
@@ -204,28 +199,28 @@ class HuiVacuumCommandCardFeature
const stateObj = this._stateObj as VacuumEntity;
const commands = this._config.commands ?? VACUUM_DEFAULT_COMMANDS;
return html`
<ha-control-button-group>
${VACUUM_COMMANDS.filter(
(command) =>
supportsVacuumCommand(stateObj, command) &&
this._config?.commands?.includes(command)
).map((command) => {
const button = VACUUM_COMMANDS_BUTTONS[command](stateObj);
return html`
<ha-control-button
.entry=${button}
.label=${this.hass!.localize(
// @ts-ignore
`ui.dialogs.more_info_control.vacuum.${button.translationKey}`
)}
@click=${this._onCommandTap}
.disabled=${button.disabled || stateObj.state === UNAVAILABLE}
>
<ha-svg-icon .path=${button.icon}></ha-svg-icon>
</ha-control-button>
`;
})}
${commands
.filter((command) => supportsVacuumCommand(stateObj, command))
.map((command) => {
const button = VACUUM_COMMANDS_BUTTONS[command](stateObj);
return html`
<ha-control-button
.entry=${button}
.label=${this.hass!.localize(
// @ts-ignore
`ui.dialogs.more_info_control.vacuum.${button.translationKey}`
)}
@click=${this._onCommandTap}
.disabled=${button.disabled || stateObj.state === UNAVAILABLE}
>
<ha-svg-icon .path=${button.icon}></ha-svg-icon>
</ha-control-button>
`;
})}
</ha-control-button-group>
`;
}
@@ -1,4 +1,6 @@
import type { HassConfig } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { html, nothing } from "lit";
import {
subHours,
differenceInDays,
@@ -31,10 +33,10 @@ import {
formatDateWeekdayVeryShortDate,
} from "../../../../../common/datetime/format_date";
import { formatTime } from "../../../../../common/datetime/format_time";
import type { ECOption } from "../../../../../resources/echarts/echarts";
import { filterXSS } from "../../../../../common/util/xss";
import type { HaECOption } from "../../../../../resources/echarts/echarts";
import type { StatisticPeriod } from "../../../../../data/recorder";
import { getPeriodicAxisLabelConfig } from "../../../../../components/chart/axis-label";
import "../../../../../components/chart/ha-chart-tooltip-marker";
import { getSuggestedPeriod } from "../../../../../data/energy";
export { fillDataGapsAndRoundCaps } from "../../../../../components/chart/round-caps";
@@ -110,7 +112,7 @@ export function getCommonOptions(
formatTotal?: (total: number) => string,
detailedDailyData = false,
yAxisFractionDigits = 1
): ECOption {
): HaECOption {
const suggestedPeriod = getSuggestedPeriod(start, end, detailedDailyData);
let suggestedMax = getSuggestedMax(suggestedPeriod, end, detailedDailyData);
@@ -134,7 +136,7 @@ export function getCommonOptions(
}
}
const monthTimeAxis: ECOption = {
const monthTimeAxis: HaECOption = {
xAxis: {
type: "time",
min: subDays(start, MONTH_TIME_AXIS_PADDING),
@@ -146,7 +148,7 @@ export function getCommonOptions(
splitNumber: Math.min(differenceInCalendarMonths(end, start), 5),
},
};
const normalTimeAxis: ECOption = {
const normalTimeAxis: HaECOption = {
xAxis: {
type: "time",
min: start,
@@ -154,7 +156,7 @@ export function getCommonOptions(
},
};
const options: ECOption = {
const options: HaECOption = {
...(suggestedPeriod === "month" ? monthTimeAxis : normalTimeAxis),
yAxis: {
type: "value",
@@ -179,7 +181,7 @@ export function getCommonOptions(
},
tooltip: {
trigger: "axis",
formatter: (params: TopLevelFormatterParams): string => {
formatter: (params: TopLevelFormatterParams) => {
// trigger: "axis" gives an array of params, but "item" gives a single param
if (Array.isArray(params)) {
const mainItems: CallbackDataParams[] = [];
@@ -191,7 +193,7 @@ export function getCommonOptions(
mainItems.push(param);
}
});
return [mainItems, compareItems]
const sections = [mainItems, compareItems]
.map((items) =>
formatTooltip(
items,
@@ -204,8 +206,12 @@ export function getCommonOptions(
formatTotal
)
)
.filter(Boolean)
.join("<br><br>");
.filter((s): s is TemplateResult => s !== nothing);
if (sections.length === 0) return nothing;
return html`${sections.map(
(section, i) =>
html`${i > 0 ? html`<br /><br />` : nothing}${section}`
)}`;
}
return formatTooltip(
[params],
@@ -232,9 +238,9 @@ function formatTooltip(
showCompareYear: boolean,
unit?: string,
formatTotal?: (total: number) => string
) {
): TemplateResult | typeof nothing {
if (!params[0]?.value) {
return "";
return nothing;
}
// displayX may be shifted from the period start (see EnergyDataPoint);
// originalStart has the real date for display. Gap-filled entries lack it.
@@ -258,43 +264,50 @@ function formatTooltip(
period += ` ${formatTime(addHours(date, 1), locale, config)}`;
}
}
const title = `<h4 style="text-align: center; margin: 0;">${period}</h4>`;
let sumPositive = 0;
let countPositive = 0;
let sumNegative = 0;
let countNegative = 0;
const values = params
.map((param) => {
const y = param.value?.[1] as number;
const value = formatNumber(
y,
locale,
y < 0.1 ? { maximumFractionDigits: 3 } : undefined
);
if (value === "0") {
return false;
const rows: TemplateResult[] = [];
for (const param of params) {
const y = param.value?.[1] as number;
const value = formatNumber(
y,
locale,
y < 0.1 ? { maximumFractionDigits: 3 } : undefined
);
if (value === "0") {
continue;
}
if (param.componentSubType === "bar") {
if (y > 0) {
sumPositive += y;
countPositive++;
} else {
sumNegative += y;
countNegative++;
}
if (param.componentSubType === "bar") {
if (y > 0) {
sumPositive += y;
countPositive++;
} else {
sumNegative += y;
countNegative++;
}
}
return `${param.marker} ${filterXSS(param.seriesName!)}: <div style="direction:ltr; display: inline;">${value} ${unit}</div>`;
})
.filter(Boolean);
let footer = "";
if (sumPositive !== 0 && countPositive > 1 && formatTotal) {
footer += `<br><b>${formatTotal(sumPositive)}</b>`;
}
rows.push(
html`<ha-chart-tooltip-marker
.color=${String(param.color ?? "")}
></ha-chart-tooltip-marker>
${param.seriesName}:
<div style="direction:ltr; display: inline;">${value} ${unit}</div>`
);
}
if (sumNegative !== 0 && countNegative > 1 && formatTotal) {
footer += `<br><b>${formatTotal(sumNegative)}</b>`;
if (rows.length === 0) {
return nothing;
}
return values.length > 0 ? `${title}${values.join("<br>")}${footer}` : "";
return html`<h4 style="text-align: center; margin: 0;">${period}</h4>
${rows.map(
(row, i) => html`${i > 0 ? html`<br />` : nothing}${row}`
)}${sumPositive !== 0 && countPositive > 1 && formatTotal
? html`<br /><b>${formatTotal(sumPositive)}</b>`
: nothing}${sumNegative !== 0 && countNegative > 1 && formatTotal
? html`<br /><b>${formatTotal(sumNegative)}</b>`
: nothing}`;
}
function getDatapointX(datapoint: NonNullable<LineSeriesOption["data"]>[0]) {
@@ -44,7 +44,7 @@ import {
getCompareTransform,
} from "./common/energy-chart-options";
import { storage } from "../../../../common/decorators/storage";
import type { ECOption } from "../../../../resources/echarts/echarts";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import { formatNumber } from "../../../../common/number/format_number";
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
@@ -161,7 +161,8 @@ export class HuiEnergyDevicesDetailGraphCard
UNIT,
this._compareStart,
this._compareEnd,
this._yAxisFractionDigits
this._yAxisFractionDigits,
this._legendData
)}
click-label-for-more-info
@dataset-hidden=${this._datasetHidden}
@@ -215,8 +216,9 @@ export class HuiEnergyDevicesDetailGraphCard
unit: string | undefined,
compareStart: Date | undefined,
compareEnd: Date | undefined,
yAxisFractionDigits: number
): ECOption => {
yAxisFractionDigits: number,
legendData: CustomLegendOption["data"]
): HaECOption => {
const commonOptions = getCommonOptions(
start,
end,
@@ -230,8 +232,8 @@ export class HuiEnergyDevicesDetailGraphCard
yAxisFractionDigits
);
const selected = this._legendData
? this._legendData
const selected = legendData
? legendData
.filter(
(d) =>
d.id && this._hiddenStats.includes(this._getStatIdFromId(d.id))
@@ -247,7 +249,7 @@ export class HuiEnergyDevicesDetailGraphCard
legend: {
show: true,
type: "custom",
data: this._legendData,
data: legendData,
selected,
},
grid: {
@@ -9,10 +9,10 @@ import type { BarSeriesOption, PieSeriesOption } from "echarts/charts";
import { PieChart } from "echarts/charts";
import type { ECElementEvent } from "echarts/types/dist/shared";
import type { PieDataItemOption } from "echarts/types/src/chart/pie/PieSeries";
import { filterXSS } from "../../../../common/util/xss";
import { getGraphColorByIndex } from "../../../../common/color/colors";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base";
import "../../../../components/chart/ha-chart-tooltip-marker";
import type { EnergyData } from "../../../../data/energy";
import {
computeConsumptionData,
@@ -30,8 +30,9 @@ import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { EnergyDevicesGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import type { ECOption } from "../../../../resources/echarts/echarts";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import "../../../../components/ha-card";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { measureTextWidth } from "../../../../util/text";
import "../../../../components/ha-icon-button";
@@ -189,33 +190,39 @@ export class HuiEnergyDevicesGraphCard
)}
.height=${`${Math.max(modes.includes("pie") ? 300 : 100, (this._legendData?.length || 0) * 28 + 50)}px`}
.extraComponents=${[PieChart]}
click-label-for-more-info
@chart-click=${this._handleChartClick}
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
@legend-label-click=${this._handleLegendLabelClick}
></ha-chart-base>
</div>
</ha-card>
`;
}
private _renderTooltip(params: any) {
const deviceName = filterXSS(this._getDeviceName(params.name));
const title = `<h4 style="text-align: center; margin: 0;">${deviceName}</h4>`;
private _renderTooltip = (params: any) => {
const deviceName = this._getDeviceName(params.name);
const value = `${formatNumber(
params.value[0] as number,
this.hass.locale,
params.value < 0.1 ? { maximumFractionDigits: 3 } : undefined
)} kWh ${params.percent ? `(${params.percent} %)` : ""}`;
return `${title}${params.marker} ${params.seriesName}: <div style="direction:ltr; display: inline;">${value}</div>`;
}
return html`<h4 style="text-align: center; margin: 0;">${deviceName}</h4>
<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${params.seriesName}:
<div style="direction:ltr; display: inline;">${value}</div>`;
};
private _createOptions = memoizeOne(
(
data: (BarSeriesOption | PieSeriesOption)[],
chartType: "bar" | "pie",
legendData: typeof this._legendData
): ECOption => {
const options: ECOption = {
): HaECOption => {
const options: HaECOption = {
grid: {
top: 5,
left: 5,
@@ -225,7 +232,7 @@ export class HuiEnergyDevicesGraphCard
},
tooltip: {
show: true,
formatter: this._renderTooltip.bind(this),
formatter: this._renderTooltip,
},
xAxis: { show: false },
yAxis: { show: false },
@@ -539,11 +546,20 @@ export class HuiEnergyDevicesGraphCard
chartData.splice(this._config.max_devices);
}
this._legendData = chartData.map((d) => ({
...d,
name: this._getDeviceName(d.name),
value: `${formatNumber(d.value[0], this.hass.locale)} kWh`,
}));
this._legendData = chartData.map((d) => {
const id = (d as any).id as string;
return {
...d,
name: this._getDeviceName(d.name),
value: `${formatNumber(d.value[0], this.hass.locale)} kWh`,
// Untracked is synthetic and external statistics aren't real entities,
// so their labels can't open more-info; fall back to toggling visibility.
noLabelClick:
id === "untracked" ||
isExternalStatistic(id) ||
!(id in this.hass.states),
};
});
// filter out hidden stats in place
for (let i = chartData.length - 1; i >= 0; i--) {
if (this._hiddenStats.includes((chartData[i] as any).id)) {
@@ -575,7 +591,11 @@ export class HuiEnergyDevicesGraphCard
e.detail.event?.target?.type === "tspan" // label
) {
const id = (e.detail.data as any).id as string;
if (id !== "untracked") {
if (
id !== "untracked" &&
!isExternalStatistic(id) &&
this.hass.states[id]
) {
fireEvent(this, "hass-more-info", {
entityId: id,
});
@@ -583,6 +603,16 @@ export class HuiEnergyDevicesGraphCard
}
}
private _handleLegendLabelClick(
ev: HASSDomEvent<HASSDomEvents["legend-label-click"]>
) {
const entityId = ev.detail.id;
if (isExternalStatistic(entityId) || !this.hass.states[entityId]) {
return;
}
fireEvent(this, "hass-more-info", { entityId });
}
private _handleChartTypeChange(): void {
if (!this._chartType) {
return;
@@ -35,7 +35,7 @@ import {
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts/echarts";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import "./common/hui-energy-graph-chip";
import "../../../../components/ha-tooltip";
@@ -177,7 +177,7 @@ export class HuiEnergyGasGraphCard
compareStart: Date | undefined,
compareEnd: Date | undefined,
yAxisFractionDigits: number
): ECOption =>
): HaECOption =>
getCommonOptions(
start,
end,
@@ -438,9 +438,7 @@ class HuiEnergySankeyCard
}
private _valueFormatter = (value: number) =>
`<div style="direction:ltr; display: inline;">
${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)}
kWh</div>`;
`${formatNumber(value, this.hass.locale, value < 0.1 ? { maximumFractionDigits: 3 } : undefined)} kWh`;
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
@@ -37,7 +37,7 @@ import {
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts/echarts";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import "./common/hui-energy-graph-chip";
import "../../../../components/ha-tooltip";
@@ -65,7 +65,7 @@ export class HuiEnergySolarGraphCard
};
}
@state() private _chartData: ECOption["series"][] = [];
@state() private _chartData: (BarSeriesOption | LineSeriesOption)[] = [];
@state() private _yAxisFractionDigits = 1;
@@ -175,7 +175,7 @@ export class HuiEnergySolarGraphCard
compareStart: Date | undefined,
compareEnd: Date | undefined,
yAxisFractionDigits: number
): ECOption =>
): HaECOption =>
getCommonOptions(
start,
end,
@@ -213,7 +213,7 @@ export class HuiEnergySolarGraphCard
}
}
const datasets: ECOption["series"] = [];
const datasets: (BarSeriesOption | LineSeriesOption)[] = [];
const computedStyles = getComputedStyle(this);
@@ -6,10 +6,7 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { BarSeriesOption } from "echarts/charts";
import type {
TooltipOption,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import type { TopLevelFormatterParams } from "echarts/types/dist/shared";
import { getEnergyColor } from "./common/color";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base";
@@ -43,7 +40,7 @@ import {
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts/echarts";
import type { HaECOption } from "../../../../resources/echarts/echarts";
const colorPropertyMap = {
to_grid: "--energy-grid-return-color",
@@ -196,7 +193,7 @@ export class HuiEnergyUsageGraphCard
compareStart: Date | undefined,
compareEnd: Date | undefined,
yAxisFractionDigits: number
): ECOption => {
): HaECOption => {
const commonOptions = getCommonOptions(
start,
end,
@@ -209,15 +206,22 @@ export class HuiEnergyUsageGraphCard
false,
yAxisFractionDigits
);
const options: ECOption = {
const tooltip = commonOptions.tooltip;
const baseFormatter =
tooltip &&
!Array.isArray(tooltip) &&
typeof tooltip.formatter === "function"
? tooltip.formatter
: undefined;
const options: HaECOption = {
...commonOptions,
tooltip: {
...commonOptions.tooltip,
formatter: (params: TopLevelFormatterParams): string => {
formatter: (params: TopLevelFormatterParams) => {
if (!Array.isArray(params)) {
return "";
return nothing;
}
params.sort((a, b) => {
const sorted = [...params].sort((a, b) => {
const aValue = (a.value as number[])?.[1];
const bValue = (b.value as number[])?.[1];
if (aValue > 0 && bValue < 0) {
@@ -231,9 +235,7 @@ export class HuiEnergyUsageGraphCard
}
return a.componentIndex - b.componentIndex;
});
return (
(commonOptions.tooltip as TooltipOption)?.formatter as any
)?.(params);
return baseFormatter ? baseFormatter(sorted) : nothing;
},
},
};
@@ -34,7 +34,7 @@ import {
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts/echarts";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import { formatNumber } from "../../../../common/number/format_number";
import "./common/hui-energy-graph-chip";
import "../../../../components/ha-tooltip";
@@ -177,7 +177,7 @@ export class HuiEnergyWaterGraphCard
compareStart: Date | undefined,
compareEnd: Date | undefined,
yAxisFractionDigits: number
): ECOption =>
): HaECOption =>
getCommonOptions(
start,
end,
@@ -580,9 +580,7 @@ class HuiPowerSankeyCard
}
private _valueFormatter = (value: number) =>
`<div style="direction:ltr; display: inline;">
${formatPowerShort(this.hass, value)}
</div>`;
formatPowerShort(this.hass, value);
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
@@ -24,7 +24,7 @@ import type { LovelaceCard } from "../../types";
import type { PowerSourcesGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import { getCommonOptions, fillLineGaps } from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts/echarts";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import { hex2rgb } from "../../../../common/color/convert-color";
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
@@ -148,7 +148,7 @@ export class HuiPowerSourcesGraphCard
compareEnd: Date | undefined,
legendData: CustomLegendOption["data"] | undefined,
yAxisFractionDigits: number
): ECOption => ({
): HaECOption => ({
...getCommonOptions(
start,
end,
@@ -511,9 +511,11 @@ class HuiWaterFlowSankeyCard
}
private _valueFormatter = (value: number) =>
`<div style="direction:ltr; display: inline;">
${formatFlowRateShort(this.hass.locale, this.hass.config.unit_system.length, value)}
</div>`;
formatFlowRateShort(
this.hass.locale,
this.hass.config.unit_system.length,
value
);
private _handleNodeClick(ev: CustomEvent<{ node: Node }>) {
const { node } = ev.detail;
@@ -155,7 +155,7 @@ export class HuiActionEditor extends LitElement {
this.defaultAction
? ` (${this.hass!.localize(
`ui.panel.lovelace.editor.action-editor.actions.${this.defaultAction}`
).toLowerCase()})`
)})`
: ""
}`,
};
@@ -2,7 +2,7 @@ import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } 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 { styleMap } from "lit/directives/style-map";
import { until } from "lit/directives/until";
@@ -66,10 +66,19 @@ export class HuiBadgePicker extends LitElement {
@state() private _height?: number;
@query("ha-input-search") private _searchInput?: HaInputSearch;
private _unusedEntities?: string[];
private _usedEntities?: string[];
public async focus(): Promise<void> {
await this.updateComplete;
// Wait for the input's inner wa-input to render so focus delegation works.
await this._searchInput?.updateComplete;
this._searchInput?.focus();
}
private _filterBadges = memoizeOne(
(badgeElements: BadgeElement[], filter?: string): BadgeElement[] => {
if (!filter) {
@@ -84,6 +93,7 @@ export class HuiBadgePicker extends LitElement {
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
ignoreDiacritics: true,
ignoreLocation: true,
};
const fuse = new Fuse(badges, options);
badges = fuse.search(filter).map((result) => result.item);
@@ -6,6 +6,7 @@ import {
} from "@mdi/js";
import type { FuseIndex } from "fuse.js";
import Fuse from "fuse.js";
import { getAreasFloorHierarchy } from "../../../../common/areas/areas-floor-hierarchy";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { computeDomain } from "../../../../common/entity/compute_domain";
@@ -13,7 +14,6 @@ import { computeEntityName } from "../../../../common/entity/compute_entity_name
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { stringCompare } from "../../../../common/string/compare";
import { entityComboBoxKeys } from "../../../../data/entity/entity_picker";
import { getFloorAreaLookup } from "../../../../data/floor_registry";
import { domainToName } from "../../../../data/integration";
import { multiTermSortedSearch } from "../../../../resources/fuseMultiTerm";
import type { HomeAssistant } from "../../../../types";
@@ -206,23 +206,24 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
return stringCompare(an, bn, language);
};
const buildDeviceNodes = (source: Map<string, string[]>): DeviceNode[] =>
[...source.entries()]
.map(([id, ids]) => {
const device = deviceReg[id];
return {
id,
name: (device ? computeDeviceName(device) : undefined) ?? id,
entityIds: ids.sort(sortByName),
};
})
.sort((a, b) => stringCompare(a.name, b.name, language));
const buildAreaNode = (areaId: string): AreaNode | undefined => {
const area = areaReg[areaId];
if (!area) return undefined;
const directIds = (areaDirectEntities.get(areaId) ?? []).sort(sortByName);
const byDevice = areaDeviceEntities.get(areaId);
const devices: DeviceNode[] = byDevice
? [...byDevice.entries()]
.map(([id, ids]) => {
const device = deviceReg[id];
return {
id,
name: (device ? computeDeviceName(device) : undefined) ?? id,
entityIds: ids.sort(sortByName),
};
})
.sort((a, b) => stringCompare(a.name, b.name, language))
: [];
const devices = byDevice ? buildDeviceNodes(byDevice) : [];
if (!directIds.length && !devices.length) return undefined;
return {
id: area.area_id,
@@ -235,14 +236,14 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
const areas = Object.values(areaReg);
const floors = Object.values(floorReg);
const floorAreaLookup = getFloorAreaLookup(areas);
const hierarchy = getAreasFloorHierarchy(floors, areas);
const floorNodes: FloorNode[] = floors
.map((floor) => {
const areaList = (floorAreaLookup[floor.floor_id] ?? [])
.map((a) => buildAreaNode(a.area_id))
.filter((a): a is AreaNode => !!a)
.sort((a, b) => stringCompare(a.name, b.name, language));
const floorNodes: FloorNode[] = hierarchy.floors
.map(({ id, areas: areaIds }) => {
const floor = floorReg[id];
const areaList = areaIds
.map((areaId) => buildAreaNode(areaId))
.filter((a): a is AreaNode => !!a);
if (!areaList.length) return undefined;
return {
id: floor.floor_id,
@@ -252,26 +253,11 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
areas: areaList,
};
})
.filter((f): f is FloorNode => !!f)
.sort((a, b) => stringCompare(a.name, b.name, language));
.filter((f): f is FloorNode => !!f);
const otherAreas = areas
.filter((a) => !a.floor_id || !floorReg[a.floor_id])
.map((a) => buildAreaNode(a.area_id))
.filter((a): a is AreaNode => !!a)
.sort((a, b) => stringCompare(a.name, b.name, language));
const buildDeviceNodes = (source: Map<string, string[]>): DeviceNode[] =>
[...source.entries()]
.map(([id, ids]) => {
const device = deviceReg[id];
return {
id,
name: (device ? computeDeviceName(device) : undefined) ?? id,
entityIds: ids.sort(sortByName),
};
})
.sort((a, b) => stringCompare(a.name, b.name, language));
const otherAreas = hierarchy.areas
.map((areaId) => buildAreaNode(areaId))
.filter((a): a is AreaNode => !!a);
const buildDomainGroups = (source: Map<string, string[]>): DomainGroup[] =>
[...source.entries()]
@@ -62,19 +62,17 @@ export class HuiCardPicker extends LitElement {
@state() private _filter = "";
@query("ha-input-search") private _searchInput?: HTMLElement;
@query("ha-input-search") private _searchInput?: HaInputSearch;
private _unusedEntities?: string[];
private _usedEntities?: string[];
public async focus(): Promise<void> {
if (this._searchInput) {
this._searchInput.focus();
} else {
await this.updateComplete;
this.focus();
}
await this.updateComplete;
// Wait for the input's inner wa-input to render so focus delegation works.
await this._searchInput?.updateComplete;
this._searchInput?.focus();
}
private _filterCards = memoizeOne(
@@ -91,6 +89,7 @@ export class HuiCardPicker extends LitElement {
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
ignoreDiacritics: true,
ignoreLocation: true,
};
const fuse = new Fuse(cards, options);
cards = fuse.search(filter).map((result) => result.item);
@@ -133,6 +133,7 @@ export class HuiCreateDialogCard
this._currTab === "entity"
? html`
<hui-suggestion-picker
?autofocus=${!this._narrow}
.hass=${this.hass}
.prioritizedCardTypes=${this._params.suggestedCards}
@suggestion-picked=${this._handleSuggestionPicked}
@@ -8,7 +8,7 @@ import {
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { transform } from "../../../../common/decorators/transform";
@@ -85,6 +85,15 @@ export class HuiSuggestionEntityTree extends LitElement {
@state() private _fuseIndex?: EntityFuseIndex;
@query("ha-input-search") private _searchInput?: HaInputSearch;
public async focus(): Promise<void> {
await this.updateComplete;
// Wait for the input's inner wa-input to render so focus delegation works.
await this._searchInput?.updateComplete;
this._searchInput?.focus();
}
public connectedCallback(): void {
super.connectedCallback();
this._loadDomainTranslations();
@@ -135,7 +144,7 @@ export class HuiSuggestionEntityTree extends LitElement {
}
protected render() {
if (!this.hass || !this._tree) return nothing;
if (!this.hass) return nothing;
return html`
<ha-input-search
@@ -146,11 +155,13 @@ export class HuiSuggestionEntityTree extends LitElement {
)}
@input=${this._handleFilterChange}
></ha-input-search>
${this._filter
? this._renderSearchResults()
: html`<div class="tree ha-scrollbar">
${this._renderTree(this._tree)}
</div>`}
${this._tree
? this._filter
? this._renderSearchResults()
: html`<div class="tree ha-scrollbar">
${this._renderTree(this._tree)}
</div>`
: nothing}
`;
}
@@ -1,7 +1,7 @@
import { mdiClose, mdiViewGridPlus } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } 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 { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
@@ -24,6 +24,7 @@ import {
import type { CardSuggestion } from "../../card-suggestions/types";
import "./hui-suggestion-card";
import "./hui-suggestion-entity-tree";
import type { HuiSuggestionEntityTree } from "./hui-suggestion-entity-tree";
@customElement("hui-suggestion-picker")
export class HuiSuggestionPicker extends LitElement {
@@ -38,6 +39,14 @@ export class HuiSuggestionPicker extends LitElement {
private _narrowMql?: MediaQueryList;
@query("hui-suggestion-entity-tree")
private _entityTree?: HuiSuggestionEntityTree;
public async focus(): Promise<void> {
await this.updateComplete;
await this._entityTree?.focus();
}
public connectedCallback(): void {
super.connectedCallback();
this._narrowMql = matchMedia("(max-width: 600px)");
@@ -30,6 +30,7 @@ import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import "../../../../components/ha-yaml-editor";
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../../resources/styles";
@@ -230,11 +231,28 @@ export class HaCardConditionEditor extends LitElement {
return html`
<div class="container">
<ha-expansion-panel left-chevron>
<ha-svg-icon
<div
id="condition-icon"
class="icon-badge-wrapper"
slot="leading-icon"
class="condition-icon"
.path=${ICON_CONDITION[condition.condition]}
></ha-svg-icon>
>
<ha-svg-icon
.path=${ICON_CONDITION[condition.condition]}
></ha-svg-icon>
${hideLiveTest
? nothing
: html`<ha-automation-row-live-test
.state=${this._liveTestResult.state}
.label=${this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.live_test_state.${this._liveTestResult.state}`
)}
></ha-automation-row-live-test>`}
</div>
${!hideLiveTest && this._liveTestResult.message
? html`<ha-tooltip for="condition-icon" slot="leading-icon"
>${this._liveTestResult.message}</ha-tooltip
>`
: nothing}
<h3 slot="header">
${this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.condition.${condition.condition}.label`
@@ -255,18 +273,6 @@ export class HaCardConditionEditor extends LitElement {
"ui.panel.lovelace.editor.condition-editor.testing_error"
)}
</ha-automation-row-event-chip>
${hideLiveTest
? nothing
: html`
<ha-automation-row-live-test
slot="icons"
.state=${this._liveTestResult.state}
.label=${this.hass.localize(
`ui.panel.lovelace.editor.condition-editor.live_test_state.${this._liveTestResult.state}`
)}
.message=${this._liveTestResult.message}
></ha-automation-row-live-test>
`}
<ha-dropdown
slot="icons"
@wa-select=${this._handleAction}
@@ -479,18 +485,11 @@ export class HaCardConditionEditor extends LitElement {
--expansion-panel-summary-padding: 0 0 0 8px;
--expansion-panel-content-padding: 0;
}
.condition-icon {
display: none;
}
@media (min-width: 870px) {
.condition-icon {
display: inline-block;
color: var(--secondary-text-color);
opacity: 0.9;
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
}
.icon-badge-wrapper {
display: inline-flex;
position: relative;
color: var(--secondary-text-color);
opacity: 0.9;
}
h3 {
margin: 0;
@@ -1,9 +1,10 @@
import { consume } from "@lit/context";
import { mdiAlertCircle, mdiEye, mdiEyeOff } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ConditionListenersController } from "../../../../common/controllers/condition-listeners-controller";
import "../../../../components/ha-alert";
import "../../../../components/ha-svg-icon";
import { HaRowItem } from "../../../../components/item/ha-row-item";
import type { HomeAssistant } from "../../../../types";
@@ -28,17 +29,15 @@ const STATE_ICONS: Record<VisibilityState, string> = {
/**
* @element ha-visibility-status
* @extends {HaRowItem}
*
* @summary
* Row-style banner that surfaces the live visibility result for a set of
* lovelace conditions. Replaces the static explanation alert at the top of
* card / section / badge / conditional-card visibility editors.
* Alert banner that surfaces the live visibility result for a set of
* lovelace conditions.
*
* @attr {"visible"|"hidden"|"invalid"} state - Computed visibility state (reflected for styling).
* @attr {"visible"|"hidden"|"invalid"} state - Computed visibility state
*/
@customElement("ha-visibility-status")
export class HaVisibilityStatus extends HaRowItem {
export class HaVisibilityStatus extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
@@ -48,7 +47,7 @@ export class HaVisibilityStatus extends HaRowItem {
@consume({ context: conditionsEntityContext, subscribe: true })
private _entityContext?: ConditionsEntityContext;
@property({ reflect: true })
@property()
public state: VisibilityState = "visible";
private _listeners = new ConditionListenersController(this);
@@ -71,23 +70,27 @@ export class HaVisibilityStatus extends HaRowItem {
}
}
protected override _renderInner(): TemplateResult {
public render() {
return html`
<div part="start" class="start">
<ha-svg-icon .path=${STATE_ICONS[this.state]}></ha-svg-icon>
</div>
<div part="content" class="content">
<div part="headline" class="headline">
<ha-alert
.alertType=${this.state === "visible"
? "success"
: this.state === "hidden"
? "warning"
: "error"}
>
<ha-svg-icon slot="icon" .path=${STATE_ICONS[this.state]}></ha-svg-icon>
<div class="headline">
${this.hass?.localize(
`ui.panel.lovelace.editor.condition-editor.visibility_status.${this.state}.headline`
)}
</div>
<div part="supporting-text" class="supporting">
<div class="supporting">
${this.hass?.localize(
`ui.panel.lovelace.editor.condition-editor.visibility_status.${this.state}.supporting${(this.conditions?.length ?? 0) === 0 ? "_empty" : ""}`
)}
</div>
</div>
</ha-alert>
`;
}
@@ -117,37 +120,13 @@ export class HaVisibilityStatus extends HaRowItem {
static styles: CSSResultGroup = [
HaRowItem.styles,
css`
:host {
ha-alert {
display: block;
border-radius: var(--ha-border-radius-xl);
transition: background-color var(--ha-animation-duration-normal)
ease-in-out;
}
.base {
padding: var(--ha-space-4);
}
:host([state="visible"]) {
background-color: var(--ha-color-fill-success-quiet-resting);
--visibility-status-color: var(--ha-color-on-success-normal);
}
:host([state="hidden"]) {
background-color: var(--ha-color-fill-warning-quiet-resting);
--visibility-status-color: var(--ha-color-on-warning-normal);
}
:host([state="invalid"]) {
background-color: var(--ha-color-fill-danger-quiet-resting);
--visibility-status-color: var(--ha-color-on-danger-normal);
}
.start {
align-self: start;
}
.start ha-svg-icon {
color: var(--visibility-status-color);
--mdc-icon-size: 24px;
}
.headline {
font-weight: var(--ha-font-weight-medium);
white-space: normal;
margin-bottom: var(--ha-space-1);
}
`,
];
@@ -0,0 +1,59 @@
import type { HaFormSchema } from "../../../../components/ha-form/types";
interface CustomizableListSchemaParams {
field: string;
customize: boolean;
options: { value: string; label: string }[];
}
export const customizableListSchema = ({
field,
customize,
options,
}: CustomizableListSchemaParams) =>
[
{
name: "customize",
selector: { boolean: {} },
},
...(customize
? ([
{
name: field,
selector: {
select: {
mode: "list",
reorder: true,
multiple: true,
options,
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
] as const satisfies readonly HaFormSchema[];
// `customize` is form-only and never stored in the config.
export const customizableListData = <T extends object>(
config: T,
field: string
): T & { customize: boolean } => ({
...config,
customize: (config as Record<string, unknown>)[field] !== undefined,
});
// Dropping the field lets the feature fall back to its own default.
export const processCustomizableListValue = <T extends object>(
value: T & { customize?: boolean },
field: string,
defaults: readonly string[]
): T => {
const { customize, ...rest } = value;
const config = rest as Record<string, unknown>;
if (customize && !config[field]) {
config[field] = [...defaults];
} else if (!customize) {
delete config[field];
}
return config as unknown as T;
};
@@ -2,16 +2,20 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-form/ha-form";
import type { HomeAssistant } from "../../../../types";
import {
COUNTER_ACTIONS,
type LovelaceCardFeatureContext,
type CounterActionsCardFeatureConfig,
import type {
CounterActionsCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import { COUNTER_ACTIONS } from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
import {
customizableListData,
customizableListSchema,
processCustomizableListValue,
} from "./customizable-list-feature";
@customElement("hui-counter-actions-card-feature-editor")
export class HuiCounterActionsCardFeatureEditor
@@ -28,26 +32,17 @@ export class HuiCounterActionsCardFeatureEditor
this._config = config;
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
{
name: "actions",
selector: {
select: {
multiple: true,
mode: "list",
reorder: true,
options: COUNTER_ACTIONS.map((action) => ({
value: action,
label: `${localize(
`ui.panel.lovelace.editor.features.types.counter-actions.actions.${action}`
)}`,
})),
},
},
},
] as const
private _schema = memoizeOne((customize: boolean) =>
customizableListSchema({
field: "actions",
customize,
options: COUNTER_ACTIONS.map((action) => ({
value: action,
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.counter-actions.actions_list.${action}`
),
})),
})
);
protected render() {
@@ -55,12 +50,13 @@ export class HuiCounterActionsCardFeatureEditor
return nothing;
}
const schema = this._schema(this.hass.localize);
const data = customizableListData(this._config, "actions");
const schema = this._schema(data.customize);
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
@@ -69,19 +65,21 @@ export class HuiCounterActionsCardFeatureEditor
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
const config =
processCustomizableListValue<CounterActionsCardFeatureConfig>(
ev.detail.value,
"actions",
COUNTER_ACTIONS
);
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.counter-actions.${schema.name}`
);
}
declare global {
@@ -3,7 +3,6 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-form/ha-form";
import type { HomeAssistant } from "../../../../types";
@@ -14,6 +13,11 @@ import type {
} from "../../card-features/types";
import { LAWN_MOWER_COMMANDS } from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
import {
customizableListData,
customizableListSchema,
processCustomizableListValue,
} from "./customizable-list-feature";
@customElement("hui-lawn-mower-commands-card-feature-editor")
export class HuiLawnMowerCommandsCardFeatureEditor
@@ -31,27 +35,19 @@ export class HuiLawnMowerCommandsCardFeatureEditor
}
private _schema = memoizeOne(
(localize: LocalizeFunc, stateObj?: HassEntity) =>
[
{
name: "commands",
selector: {
select: {
multiple: true,
mode: "list",
options: LAWN_MOWER_COMMANDS.filter(
(command) =>
stateObj && supportsLawnMowerCommand(stateObj, command)
).map((command) => ({
value: command,
label: `${localize(
`ui.panel.lovelace.editor.features.types.lawn-mower-commands.commands_list.${command}`
)}`,
})),
},
},
},
] as const
(stateObj: HassEntity | undefined, customize: boolean) =>
customizableListSchema({
field: "commands",
customize,
options: LAWN_MOWER_COMMANDS.filter(
(command) => stateObj && supportsLawnMowerCommand(stateObj, command)
).map((command) => ({
value: command,
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.lawn-mower-commands.commands_list.${command}`
),
})),
})
);
protected render() {
@@ -60,15 +56,16 @@ export class HuiLawnMowerCommandsCardFeatureEditor
}
const stateObj = this.context?.entity_id
? this.hass.states[this.context?.entity_id]
? this.hass.states[this.context.entity_id]
: undefined;
const schema = this._schema(this.hass.localize, stateObj);
const data = customizableListData(this._config, "commands");
const schema = this._schema(stateObj, data.customize);
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
@@ -77,23 +74,27 @@ export class HuiLawnMowerCommandsCardFeatureEditor
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
const stateObj = this.context?.entity_id
? this.hass!.states[this.context.entity_id]
: undefined;
const defaults = LAWN_MOWER_COMMANDS.filter(
(command) => stateObj && supportsLawnMowerCommand(stateObj, command)
);
const config =
processCustomizableListValue<LawnMowerCommandsCardFeatureConfig>(
ev.detail.value,
"commands",
defaults
);
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "commands":
return this.hass!.localize(
`ui.panel.lovelace.editor.features.types.lawn-mower-commands.${schema.name}`
);
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.lawn-mower-commands.${schema.name}`
);
}
declare global {

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