Compare commits

...

53 Commits

Author SHA1 Message Date
Aidan Timson e216258818 Review 2026-06-23 09:00:28 +01:00
Aidan Timson 2d082bdafe Allow opening Lovelace back targets in new tabs 2026-06-22 14:50:51 +01:00
Aidan Timson da14aa1bd1 Allow opening Lovelace views in new tabs 2026-06-22 14:49:58 +01:00
Aidan Timson fce1938f38 Add a debug to related context provider (#52793)
* Add a debug to related context provider

* context -> haContext
2026-06-22 14:34:34 +03:00
Bram Kragten 24821d6f1b Sign brand images in state-badge via connection context (#52797)
* Sign brand images in state-badge via connection context

state-badge only signed entity_picture URLs when a `hass` object was
passed, calling `hass.hassUrl()` to append the brands access token.
Components migrated to Lit contexts (e.g. ha-config-updates on the
Settings → Updates page) no longer pass `hass`, so brand icon URLs like
/api/brands/integration/<domain>/icon.png were fetched without a token,
returning 403 and triggering unauthenticated-request log entries in core.

Consume connectionContext to obtain `hassUrl` so the token is added even
when `hass` isn't provided, and skip brand URLs entirely when they can't
be signed yet so no unauthenticated request fires.

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

* Remove now-unused hass property from state-badge

With brand image signing handled via connectionContext, `hass` was only
used by state-badge to reach `hassUrl`. Drop the property entirely and
remove the `.hass` binding from all call sites; the connection context
provides `hassUrl` everywhere the component renders.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:33:24 +03:00
Petar Petrov 677d64c915 Use a picker with entity context for energy upstream device (#52788) 2026-06-22 12:12:20 +01:00
Petar Petrov fe06772a73 Pad chart Y-axis labels to consistent decimal precision (#52787) 2026-06-22 12:10:21 +01:00
Petar Petrov 170f8c371a Migrate action/service button rows to context instead of hass (#52789) 2026-06-22 12:09:32 +01:00
Petar Petrov 12841b5ff7 Migrate state-display leaves to context instead of hass (#52791) 2026-06-22 12:08:15 +01:00
Petar Petrov 5393b05636 Migrate filter components to context instead of hass (#52792) 2026-06-22 12:07:35 +01:00
Petar Petrov a384e2dbd6 Migrate UI/config leaf components to context instead of hass (#52790) 2026-06-22 12:04:20 +01:00
AlCalzone fd4936e547 Show circular progress when interviewing Z-Wave devices (#52795)
Use circular progress for interviewing Z-Wave devices
2026-06-22 13:35:40 +03:00
Bram Kragten 44d02420ae Add support for not triggered traces (#52708) 2026-06-22 11:59:39 +02:00
karwosts ebf80ecca0 Accept enter key to submit code dialog (#52784) 2026-06-22 08:11:46 +03:00
renovate[bot] bcfcc7bd5a Update tsparticles to v4.2.1 (#52786)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-22 08:05:56 +03:00
renovate[bot] f7933c31d7 Update dependency @rsdoctor/rspack-plugin to v1.5.15 (#52783)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-21 17:43:40 +02:00
HarvsG 3f4f4a5ead Clarify integration startup message in translations (#52772)
Updated the message for integration startup to clarify that not everything will be available until startup is finished.
2026-06-21 05:42:38 +00:00
renovate[bot] 44a269b87b Update tsparticles to v4.2.0 (#52776)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-21 08:34:48 +03:00
renovate[bot] 4f89056883 Update dependency @babel/helper-define-polyfill-provider to v1 (#52778)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-21 08:34:21 +03:00
Paulus Schoutsen 6a40f1965a Fix app panel flickering when waiting for app to finish starting up (#52781)
* Fix app panel flicker while waiting for app to start

Keep the loading screen up as a stable overlay while the ingress iframe
is still returning 502, and reload the iframe content in place instead
of unsetting the addon and rebuilding the whole panel each retry.

https://claude.ai/code/session_019fWWygHqYbM2H6FN9jWJu1

* Increase timeout

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-21 08:33:36 +03:00
renovate[bot] 7e836d6cca Update fullcalendar monorepo to v6.1.21 (#52779)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-21 08:32:46 +03:00
renovate[bot] 1fab54831f Update dependency @rsdoctor/rspack-plugin to v1.5.14 (#52771)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-20 14:34:13 +02:00
Petar Petrov 4638582c6f Refactor energy dashboard card visibility to a single source of truth (#52673)
Make energy dashboard card visibility declarative via the catalog

Route the five energy view strategies and the dashboard strategy through
the shared ENERGY_CARD_CATALOG instead of re-deriving each card's
applicability conditions inline. A new isEnergyCardVisible() helper makes
the catalog the single source of truth for whether a card is shown, so the
strategies and the customise dialog can no longer disagree.

Behavior-preserving; adds a contract test pinning isEnergyCardVisible to
the catalog for every entry.
2026-06-20 09:06:35 +02:00
Franck Nijhof e5721fb134 Use singular verb for state condition matched with any (#52609)
A state condition with match "any" joins its entities with "or", but the
summary kept a plural verb for multiple entities, reading "If A or B are
on". With "or" English uses singular agreement: "If A or B is on". The
match "all" case joins with "and" and correctly stays plural.

Nest a select on a new matchAny flag inside the multiple-entities plural
branch so the verb agrees with the join. Other languages keep their
count-based plural (the extra argument is ignored). Add a test that renders
the actual en.json string to lock in the grammar.
2026-06-20 09:00:38 +02:00
Petar Petrov bfd8cb54c9 Split negative untracked energy into a toggleable series (#52698)
Negative "untracked" values (tracked devices reporting more than total
consumption, usually a meter resolution mismatch) rendered as confusing
below-zero bars in the devices detail graph. Move them into their own
"Over-reported consumption" series with its own legend item so users can
toggle them off, and only add the series when negatives actually exist.
2026-06-20 09:56:49 +03:00
Franck Nijhof 89bd1058df Fix gauge card dropping negative and monetary values (#52751)
The gauge card reads its display value from the formatted state parts.
A monetary value is split into multiple value parts around the currency
symbol, so the minus sign lands in its own part. Taking only the first
value part meant a value like -182.95 GBP rendered as just "-".

Join all value parts so the full number is shown again.

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-06-20 08:45:28 +02:00
Paul Bottein 405727502f Fix negative monetary (#52766)
* Fix rendering of negative monetary values

* Fix tests
2026-06-20 08:34:47 +02:00
Marcin Bauer dae105531f Fix left column resizing in add automation element dialog (#52745)
The left list column used flex: 4 against a flex: 6 right panel. Because
flex items default to min-width: auto, the right panel's content (which
varies per group) could dictate the split, so the left column width
shifted while browsing groups.

Give the left column a fixed width (flex: 0 0 360px) and let the right
panel take the rest with min-width: 0 so its content shrinks instead of
pushing the left column around.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 08:09:11 +03:00
Franck Nijhof 5f790a4977 Offer category and floor names to the AI suggestion model (#52760)
The metadata suggestion schema sent the internal category and floor IDs as
the select options, so the model only saw opaque IDs and never picked one.
The result processor already maps the chosen value back to an ID by name, so
the two halves disagreed.

Offer the names as the option values instead, matching what the processor
expects, so the model can actually choose a category or floor.
2026-06-20 08:05:10 +03:00
Franck Nijhof d6c16e0736 Count not-ready Z-Wave devices separately from not included (#52757)
The network status on the Z-Wave dashboard added the not-ready nodes to
the provisioning entries and labeled the total as not included. Not-ready
nodes are included though, their interview just has not completed yet, so
this was confusing.

Report not-ready nodes as not ready and keep not included for the
provisioning entries that have not joined the network yet.
2026-06-20 08:04:11 +03:00
Franck Nijhof c562f58326 Group the time and duration input fields for screen readers (#52764)
The day, hour, minute, second, and millisecond inputs were rendered as
separate fields with a label that was not associated with them, so screen
readers announced them as unrelated inputs.

Wrap the fields in a role=group and label that group with the visible label,
so they are announced together as one labeled control.
2026-06-20 07:57:29 +03:00
Franck Nijhof ce5640d13a Fix time zone picker data gaps (UTC and Asia/Sakhalin) (#52754)
* Add UTC time zone to the time zone picker

The time zone list is built from google-timezones-json, which is missing
the bare "UTC" and "Etc/UTC" zones. Both are valid IANA identifiers and a
common server default, so an instance configured to UTC showed up as an
unknown time zone in the settings.

Add the two zones to the picker options, guarded against duplicates in
case the source list starts including them.

* Accept UTC time zone in the clock card config

The clock card validated its time_zone against the raw timezone list, so
it rejected UTC even though the picker now offers it. Validate against the
shared timezone options instead, keeping a single source.

* Correct the invalid Asia/Sakhalin time zone id

google-timezones-json ships Asia/Yuzhno-Sakhalinsk, which is not a valid
IANA identifier, so selecting it failed backend validation. Map it to the
correct Asia/Sakhalin id.
2026-06-20 07:56:15 +03:00
renovate[bot] 6ddcc83638 Update CodeMirror to v6.7.1 (#52767)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 22:54:11 +02:00
Franck Nijhof 396f495c9b Discard stale results in the template developer tool (#52762)
Subscribing to a template render is asynchronous and is triggered from a few
places, so two renders could overlap. The second subscription overwrote the
handle of the first, leaving it running, and its late results overwrote the
current ones. That is why the result window sometimes showed the output of a
previous template until the editor was nudged.

Track a render id and bail out (and ignore incoming results) once a newer
render has started.
2026-06-19 21:42:54 +02:00
Petar Petrov d994fd8928 Fix Assist chat freezing when thinking details are opened mid-stream (#52753)
Fix Assist chat freezing when thinking details opened mid-stream
2026-06-19 15:49:43 +02:00
Franck Nijhof 21d8fda76d Mask password values in object selector previews (#52748) 2026-06-19 14:30:57 +02:00
Paul Bottein 49716f4151 Replace until() in icon components with a shared async controller (#52746) 2026-06-19 14:15:54 +02:00
Aidan Timson 657bef6a75 Change dialog enter code to adaptive dialog (#52747) 2026-06-19 14:45:58 +03:00
Franck Nijhof 9edd330728 Fix inverted vertical sliders in RTL languages (#52750)
The control slider flipped its value mapping whenever the document
direction was right-to-left, including for vertical sliders. RTL only
mirrors the horizontal axis, so a vertical slider ended up upside down:
the light brightness and color temperature sliders in the more info
dialog reported the opposite of what they showed (1% gave the brightest
output, 100% the dimmest).

Only mirror for right-to-left when the slider is horizontal.
2026-06-19 14:39:53 +03:00
Petar Petrov 09e83b6450 Omit empty select fields from AI metadata suggestion task (#52749) 2026-06-19 12:40:28 +02:00
Franck Nijhof 9c3f3ed05d Stop icon components leaking memory on every state update (#52743) 2026-06-19 10:36:36 +02:00
Aidan Timson aec6c8c1e4 Effective dirty state, apply to card/badge editor (#52727)
* Effective dirty state

* Effective normalise function for those with defaults not undefined
2026-06-19 11:00:12 +03:00
Franck Nijhof 82f4ae1f08 Return to the device page from the Z-Wave node config view (#52735) 2026-06-19 10:56:21 +03:00
Franck Nijhof 2809091b44 Accept backup uploads by .tar extension, not just MIME type (#52744) 2026-06-19 07:52:05 +00:00
Simon Lamon b2dda0f739 Translate exceptions in hass api calls (#52718) 2026-06-19 07:44:06 +01:00
Franck Nijhof d64845f206 Support a list of entities in the zone trigger editor (#52738) 2026-06-19 07:36:05 +01:00
Franck Nijhof 44d929bf56 Label time trigger and condition days as days of the week (#52737) 2026-06-19 07:33:24 +01:00
Franck Nijhof 56cfff6922 Show real repeat iteration number in trace details (#52736) 2026-06-19 07:32:03 +01:00
Franck Nijhof be8782d928 Include diagnostic battery binary sensors in maintenance view (#52734) 2026-06-19 07:28:41 +01:00
renovate[bot] 2eba8425a7 Update typescript-eslint monorepo to v8.61.1 (#52740)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 07:23:14 +01:00
renovate[bot] 5ddc26df7a Update formatjs monorepo to v0.10.15 (#52739)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 07:17:11 +02:00
renovate[bot] 97516f5625 Lock file maintenance (#52741)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-19 07:16:44 +02:00
Aidan Timson e8c06b4220 Limit cover/valve card feature width to prevent overflow (#52730)
* Limit cover/valve card feature width to prevent overflow

* Remove comments and unnecessary getter
2026-06-18 17:05:27 +02:00
174 changed files with 8796 additions and 1855 deletions
@@ -0,0 +1,75 @@
import type { DemoTrace } from "./types";
export const notTriggeredTrace: DemoTrace = {
trace: {
last_step: "trigger/0",
run_id: "788767ce152d3d4475134bf1107986d4",
state: "stopped",
script_execution: "not_triggered",
not_triggered: true,
timestamp: {
start: "2021-03-25T04:36:51.223337+00:00",
finish: "2021-03-25T04:36:51.223341+00:00",
},
// Not-triggered traces have no trigger description.
trigger: null,
domain: "automation",
item_id: "1781703842452",
trace: {
"trigger/0": [
{
path: "trigger/0",
timestamp: "2021-03-25T04:36:51.223340+00:00",
changed_variables: {
trigger: {
id: "0",
idx: "0",
alias: null,
platform: "light.turned_on",
},
},
result: {
reason: "new_state_not_a_match",
data: {
entity_id: "light.bed_light",
to_state: "off",
},
},
},
],
},
config: {
id: "1781703842452",
alias: "Light Turned On Notification",
description: "Send a notification when a specific light is turned on.",
triggers: [
{
trigger: "light.turned_on",
target: {
floor_id: "test",
},
options: {
for: "00:00:00",
behavior: "each",
},
},
],
conditions: [],
actions: [
{
action: "notify.notify",
data: {
message: "A light was turned on.",
},
},
],
mode: "single",
},
context: {
id: "01KVAX7CG7XBDYGJYAGA4XJHGX",
parent_id: "01KVAX7CG631JRX4H3JS5JJ11Q",
user_id: null,
},
},
logbookEntries: [],
};
@@ -24,6 +24,33 @@ const traces: DemoTrace[] = [
error: 'Variable "beer" cannot be None',
}),
mockDemoTrace({ state: "stopped", script_execution: "cancelled" }),
mockDemoTrace({
state: "stopped",
script_execution: "not_triggered",
not_triggered: true,
// Not-triggered traces have no trigger description.
trigger: null,
trace: {
"trigger/0": [
{
path: "trigger/0",
changed_variables: {
trigger: {
id: "0",
idx: "0",
alias: null,
platform: "light.turned_on",
},
},
result: {
reason: "new_state_not_a_match",
data: { entity_id: "light.bed_light", to_state: "off" },
},
timestamp: "2021-03-25T04:36:51.223693+00:00",
},
],
},
}),
];
@customElement("demo-automation-trace-timeline")
+28 -8
View File
@@ -2,17 +2,20 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, queryAll, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/trace/ha-trace-path-details";
import type { HatScriptGraph } from "../../../../src/components/trace/hat-script-graph";
import "../../../../src/components/trace/hat-script-graph";
import "../../../../src/components/trace/hat-trace-timeline";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
import { basicTrace } from "../../data/traces/basic_trace";
import { motionLightTrace } from "../../data/traces/motion-light-trace";
import { notTriggeredTrace } from "../../data/traces/not-triggered-trace";
import type { DemoTrace } from "../../data/traces/types";
const traces: DemoTrace[] = [basicTrace, motionLightTrace];
const traces: DemoTrace[] = [basicTrace, motionLightTrace, notTriggeredTrace];
@customElement("demo-automation-trace")
export class DemoAutomationTrace extends LitElement {
@@ -20,18 +23,25 @@ export class DemoAutomationTrace extends LitElement {
@state() private _selected = {};
@queryAll("hat-script-graph") private _graphs!: NodeListOf<HatScriptGraph>;
protected render() {
if (!this.hass) {
return nothing;
}
return html`
${traces.map(
(trace, idx) => html`
${traces.map((trace, idx) => {
const graph = this._graphs?.[idx];
const selectedPath = this._selected[idx];
const selectedNode = selectedPath
? graph?.renderedNodes[selectedPath]
: undefined;
return html`
<ha-card .header=${trace.trace.config.alias}>
<div class="card-content">
<hat-script-graph
.trace=${trace.trace}
.selected=${this._selected[idx]}
.selected=${selectedPath}
@graph-node-selected=${this._handleGraphNodeSelected}
.sampleIdx=${idx}
></hat-script-graph>
@@ -40,15 +50,25 @@ export class DemoAutomationTrace extends LitElement {
.hass=${this.hass}
.trace=${trace.trace}
.logbookEntries=${trace.logbookEntries}
.selectedPath=${this._selected[idx]}
.selectedPath=${selectedPath}
@value-changed=${this._handleTimelineValueChanged}
.sampleIdx=${idx}
></hat-trace-timeline>
${selectedNode && graph
? html`<ha-trace-path-details
.hass=${this.hass}
.trace=${trace.trace}
.selected=${selectedNode}
.logbookEntries=${trace.logbookEntries}
.trackedNodes=${graph.trackedNodes}
.renderedNodes=${graph.renderedNodes}
></ha-trace-path-details>`
: nothing}
<button @click=${() => console.log(trace)}>Log trace</button>
</div>
</ha-card>
`
)}
`;
})}
`;
}
@@ -502,6 +502,10 @@ const SCHEMAS: {
},
},
},
password: {
label: "Password",
selector: { text: { type: "password" } },
},
},
},
},
-1
View File
@@ -353,7 +353,6 @@ export class DemoEntityState extends LitElement {
title: "Icon",
template: (entry) => html`
<state-badge
.hass=${hass}
.stateObj=${entry.stateObj}
.stateColor=${true}
></state-badge>
+15 -14
View File
@@ -36,26 +36,26 @@
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.0",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.1",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.9",
"@formatjs/intl-displaynames": "7.3.10",
"@formatjs/intl-durationformat": "0.10.14",
"@formatjs/intl-durationformat": "0.10.15",
"@formatjs/intl-getcanonicallocales": "3.2.10",
"@formatjs/intl-listformat": "8.3.10",
"@formatjs/intl-locale": "5.3.9",
"@formatjs/intl-numberformat": "9.3.11",
"@formatjs/intl-pluralrules": "6.3.10",
"@formatjs/intl-relativetimeformat": "12.3.10",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/luxon3": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
"@fullcalendar/list": "6.1.21",
"@fullcalendar/luxon3": "6.1.21",
"@fullcalendar/timegrid": "6.1.21",
"@home-assistant/webawesome": "3.7.0-ha.0",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
@@ -63,6 +63,7 @@
"@lit-labs/virtualizer": "2.1.1",
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/web": "2.4.1",
@@ -71,8 +72,8 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.1.3",
"@tsparticles/preset-links": "4.1.3",
"@tsparticles/engine": "4.2.1",
"@tsparticles/preset-links": "4.2.1",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
@@ -127,7 +128,7 @@
},
"devDependencies": {
"@babel/core": "7.29.7",
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "7.29.7",
"@babel/preset-env": "7.29.7",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
@@ -137,7 +138,7 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.13",
"@rsdoctor/rspack-plugin": "1.5.15",
"@rspack/core": "2.0.8",
"@rspack/dev-server": "2.0.3",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -195,7 +196,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.61.0",
"typescript-eslint": "8.61.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.9",
"webpack-stats-plugin": "1.1.3",
@@ -207,7 +208,7 @@
"lit-html": "3.3.3",
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.6.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
@@ -0,0 +1,29 @@
import { Task, type TaskConfig } from "@lit/task";
import type { ReactiveControllerHost } from "lit";
/**
* A `@lit/task` Task with a sticky `resolved` flag: false until the task has
* completed once, then true. Lets callers tell "still loading" apart from
* "resolved with an empty value" without a null sentinel, while keeping the
* previous value during a re-run.
*/
export class AsyncValueTask<T extends readonly unknown[], R> extends Task<
T,
R
> {
private _resolved = false;
constructor(host: ReactiveControllerHost, config: TaskConfig<T, R>) {
super(host, {
...config,
onComplete: (value) => {
this._resolved = true;
config.onComplete?.(value);
},
});
}
public get resolved(): boolean {
return this._resolved;
}
}
@@ -1,6 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { unitFromParts } from "./value_parts";
interface EntityUnitStubConfig {
entity: string;
@@ -40,5 +41,5 @@ export const computeEntityUnitDisplay = (
? hass.formatEntityAttributeValueToParts(stateObj, config.attribute)
: hass.formatEntityStateToParts(stateObj);
return parts.find((part) => part.type === "unit")?.value ?? "";
return unitFromParts(parts);
};
+2 -1
View File
@@ -160,7 +160,8 @@ const computeStateToPartsFromEntityAttributes = (
const type = MONETARY_TYPE_MAP[part.type];
if (!type) continue;
const last = valueParts[valueParts.length - 1];
// Merge consecutive value parts (e.g. "-" + "12" + "." + "00" → "-12.00")
// Merge consecutive value parts so the number stays a single part
// (e.g. "-" + "12" + "." + "00" → "-12.00")
if (type === "value" && last?.type === "value") {
last.value += part.value;
} else {
+29
View File
@@ -0,0 +1,29 @@
import type { ValuePart } from "../../types";
// Joins every part except the unit, keeping native order so the sign and
// grouping stay with the value (e.g. "-2,548.14").
export const valueFromParts = (parts: ValuePart[]): string =>
parts
.filter((part) => part.type !== "unit")
.map((part) => part.value)
.join("")
.trim();
export const unitFromParts = (parts: ValuePart[]): string =>
parts.find((part) => part.type === "unit")?.value ?? "";
export type UnitPosition = "before" | "after";
// Whether the unit sits before or after the value in the locale's native order
// (e.g. "$5" / "€ 5" → "before", "5 €" / "5 %" → "after").
export const unitPosition = (parts: ValuePart[]): UnitPosition => {
const unitIndex = parts.findIndex((part) => part.type === "unit");
if (unitIndex === -1) {
return "after";
}
const lastValueIndex = parts.reduceRight(
(acc, part, i) => (acc === -1 && part.type === "value" ? i : acc),
-1
);
return unitIndex < lastValueIndex ? "before" : "after";
};
+26
View File
@@ -0,0 +1,26 @@
/**
* Return a shallow copy of an object with every key removed whose value is
* `undefined` or equals that key's default, so a key left at its default
* (whether absent or explicit) does not count as a difference. A key's default
* comes from `defaults` when present, otherwise `false`.
*
* Non-plain-object values are returned unchanged; only top-level keys are
* compared.
*/
export const stripDefaults = <T>(
value: T,
defaults?: Record<string, unknown>
): T => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return value;
}
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
const defaultValue = defaults && key in defaults ? defaults[key] : false;
if (val === undefined || val === defaultValue) {
continue;
}
result[key] = val;
}
return result as T;
};
@@ -1,16 +1,18 @@
import { consume, type ContextType } from "@lit/context";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import "./ha-progress-button";
import type { HomeAssistant } from "../../types";
import { apiContext } from "../../data/context";
import { fireEvent } from "../../common/dom/fire_event";
import type { Appearance } from "../ha-button";
@customElement("ha-call-service-button")
class HaCallServiceButton extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ type: Boolean }) public disabled = false;
@@ -56,7 +58,7 @@ class HaCallServiceButton extends LitElement {
this.shadowRoot!.querySelector("ha-progress-button")!;
try {
await this.hass.callService(
await this._api.callService(
this.domain,
this.service,
this.data,
@@ -445,6 +445,7 @@ export class StateHistoryChartLine extends LitElement {
private _formatYAxisLabel = (value: number) => {
const label = formatNumber(value, this.hass.locale, {
minimumFractionDigits: value === 0 ? 0 : this._yAxisFractionDigits,
maximumFractionDigits: this._yAxisFractionDigits,
});
const width = measureTextWidth(label, 12) + 5;
+1
View File
@@ -552,6 +552,7 @@ export class StatisticsChart extends LitElement {
private _formatYAxisLabel = (value: number) =>
formatNumber(value, this.hass.locale, {
minimumFractionDigits: value === 0 ? 0 : this._yAxisFractionDigits,
maximumFractionDigits: this._yAxisFractionDigits,
});
+2 -11
View File
@@ -161,11 +161,7 @@ export class HaEntityPicker extends LitElement {
: undefined;
if (stateObj) {
return html`
<state-badge
slot="start"
.stateObj=${stateObj}
.hass=${this.hass}
></state-badge>
<state-badge slot="start" .stateObj=${stateObj}></state-badge>
`;
}
if (extraOption.icon_path) {
@@ -216,11 +212,7 @@ export class HaEntityPicker extends LitElement {
);
return html`
<state-badge
.hass=${this.hass}
.stateObj=${stateObj}
slot="start"
></state-badge>
<state-badge .stateObj=${stateObj} slot="start"></state-badge>
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
@@ -250,7 +242,6 @@ export class HaEntityPicker extends LitElement {
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`}
<span slot="headline">${item.primary}</span>
+6 -5
View File
@@ -1,3 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import { mdiFlash, mdiFlashOff } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
@@ -6,9 +7,9 @@ import { customElement, property, state } from "lit/decorators";
import { STATES_OFF } from "../../common/const";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { apiContext } from "../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { forwardHaptic } from "../../data/haptics";
import type { HomeAssistant } from "../../types";
import "../ha-formfield";
import "../ha-icon-button";
import "../ha-switch";
@@ -29,8 +30,8 @@ const isOn = (stateObj?: HassEntity) =>
@customElement("ha-entity-toggle")
export class HaEntityToggle extends LitElement {
// hass is not a property so that we only re-render on stateObj changes
public hass?: HomeAssistant;
@consume({ context: apiContext, subscribe: true })
private _api?: ContextType<typeof apiContext>;
@property({ attribute: false }) public stateObj?: HassEntity;
@@ -118,7 +119,7 @@ export class HaEntityToggle extends LitElement {
// result in the entity to be turned on. Since the state is not changing,
// the resync is not called automatic.
private async _callService(turnOn): Promise<void> {
if (!this.hass || !this.stateObj) {
if (!this._api || !this.stateObj) {
return;
}
forwardHaptic(this, "light");
@@ -149,7 +150,7 @@ export class HaEntityToggle extends LitElement {
this._isOn = turnOn;
try {
await this.hass.callService(serviceDomain, service, {
await this._api.callService(serviceDomain, service, {
entity_id: this.stateObj.entity_id,
});
} finally {
+39 -20
View File
@@ -1,3 +1,5 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { mdiAlert } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
@@ -6,13 +8,19 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { arrayLiteralIncludes } from "../../common/array/literal-includes";
import secondsToDuration from "../../common/datetime/seconds_to_duration";
import {
consumeEntityRegistryEntry,
consumeLocalize,
} from "../../common/decorators/consume-context-entry";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { unitFromParts, valueFromParts } from "../../common/entity/value_parts";
import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states";
import type { LocalizeFunc } from "../../common/translations/localize";
import { formattersContext } from "../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import { timerTimeRemaining } from "../../data/timer";
import type { HomeAssistant } from "../../types";
import "../ha-label-badge";
import "../ha-state-icon";
@@ -40,7 +48,15 @@ const getTruncatedKey = (domainKey: string, stateKey: string) => {
@customElement("ha-state-label-badge")
export class HaStateLabelBadge extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters?: ContextType<typeof formattersContext>;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@state()
@consumeEntityRegistryEntry({ entityIdPath: ["state", "entity_id"] })
private _entry?: EntityRegistryDisplayEntry;
@property({ attribute: false }) public state?: HassEntity;
@@ -77,10 +93,8 @@ export class HaStateLabelBadge extends LitElement {
return html`
<ha-label-badge
class="warning"
label=${this.hass!.localize("state_badge.default.error")}
description=${this.hass!.localize(
"state_badge.default.entity_not_found"
)}
label=${this._localize("state_badge.default.error")}
description=${this._localize("state_badge.default.entity_not_found")}
>
<ha-svg-icon .path=${mdiAlert}></ha-svg-icon>
</ha-label-badge>
@@ -94,7 +108,7 @@ export class HaStateLabelBadge extends LitElement {
// 4. Icon determined via entity state
// 5. Value string as fallback
const domain = computeStateDomain(entityState);
const entry = this.hass?.entities[entityState.entity_id];
const entry = this._entry;
const showIcon =
this.icon || this._computeShowIcon(domain, entityState, entry);
@@ -163,20 +177,23 @@ export class HaStateLabelBadge extends LitElement {
case "sun":
case "timer":
return null;
// @ts-expect-error we don't break and go to default
case "sensor":
if (entry?.platform === "moon") {
return null;
}
// eslint-disable-next-line: disable=no-fallthrough
break;
default:
return entityState.state === UNAVAILABLE ||
entityState.state === UNKNOWN
? "—"
: this.hass!.formatEntityStateToParts(entityState).find(
(part) => part.type === "value"
)?.value;
break;
}
if (entityState.state === UNAVAILABLE || entityState.state === UNKNOWN) {
return "—";
}
if (!this._formatters) {
return null;
}
return valueFromParts(
this._formatters.formatEntityStateToParts(entityState)
);
}
private _computeShowIcon(
@@ -211,11 +228,11 @@ export class HaStateLabelBadge extends LitElement {
) {
// For unavailable states or certain domains, use a special translation that is truncated to fit within the badge label
if (entityState.state === UNAVAILABLE || entityState.state === UNKNOWN) {
return this.hass!.localize(`state_badge.default.${entityState.state}`);
return this._localize(`state_badge.default.${entityState.state}`);
}
const domainStateKey = getTruncatedKey(domain, entityState.state);
if (domainStateKey) {
return this.hass!.localize(`state_badge.${domainStateKey}`);
return this._localize(`state_badge.${domainStateKey}`);
}
// Person and device tracker state can be zone name
if (domain === "person" || domain === "device_tracker") {
@@ -224,10 +241,12 @@ export class HaStateLabelBadge extends LitElement {
if (domain === "timer") {
return secondsToDuration(_timerTimeRemaining);
}
if (!this._formatters) {
return null;
}
return (
this.hass!.formatEntityStateToParts(entityState).find(
(part) => part.type === "unit"
)?.value || null
unitFromParts(this._formatters.formatEntityStateToParts(entityState)) ||
null
);
}
+1 -6
View File
@@ -343,11 +343,7 @@ export class HaStatisticPicker extends LitElement {
return html`
${item.stateObj
? html`
<state-badge
.hass=${this.hass}
.stateObj=${item.stateObj}
slot="start"
></state-badge>
<state-badge .stateObj=${item.stateObj} slot="start"></state-badge>
`
: item.icon_path
? html`
@@ -488,7 +484,6 @@ export class HaStatisticPicker extends LitElement {
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`
: nothing}
+28 -15
View File
@@ -1,3 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import { mdiAlert } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
@@ -14,13 +15,12 @@ import {
import { iconColorCSS } from "../../common/style/icon_color_css";
import { cameraUrlWithWidthHeight } from "../../data/camera";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import type { HomeAssistant } from "../../types";
import { connectionContext } from "../../data/context";
import { isBrandUrl } from "../../util/brands-url";
import "../ha-state-icon";
@customElement("state-badge")
export class StateBadge extends LitElement {
public hass?: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@property({ attribute: false }) public overrideIcon?: string;
@@ -36,6 +36,10 @@ export class StateBadge extends LitElement {
// @todo Consider reworking to eliminate need for attribute since it is manipulated internally
@property({ type: Boolean, reflect: true }) public icon = true;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
@state() private _iconStyle: Record<string, string | undefined> = {};
connectedCallback(): void {
@@ -106,14 +110,15 @@ export class StateBadge extends LitElement {
></ha-state-icon>`;
}
public willUpdate(changedProps: PropertyValues<this>) {
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (
!changedProps.has("stateObj") &&
!changedProps.has("overrideImage") &&
!changedProps.has("overrideIcon") &&
!changedProps.has("stateColor") &&
!changedProps.has("color")
!changedProps.has("color") &&
!changedProps.has("_connection")
) {
return;
}
@@ -133,12 +138,10 @@ export class StateBadge extends LitElement {
stateObj.attributes.entity_picture) &&
!this.overrideIcon
) {
let imageUrl =
let imageUrl = this._resolveImageUrl(
stateObj.attributes.entity_picture_local ||
stateObj.attributes.entity_picture;
if (this.hass) {
imageUrl = this.hass.hassUrl(imageUrl);
}
stateObj.attributes.entity_picture
);
if (domain === "camera") {
imageUrl = cameraUrlWithWidthHeight(imageUrl, 80, 80);
}
@@ -179,11 +182,7 @@ export class StateBadge extends LitElement {
}
}
} else if (this.overrideImage) {
let imageUrl = this.overrideImage;
if (this.hass) {
imageUrl = this.hass.hassUrl(imageUrl);
}
backgroundImage = `url(${imageUrl})`;
backgroundImage = `url(${this._resolveImageUrl(this.overrideImage)})`;
this.icon = false;
}
}
@@ -192,6 +191,20 @@ export class StateBadge extends LitElement {
this.style.backgroundImage = backgroundImage;
}
// Sign the image URL via the connection context so brand images
// (/api/brands/...) get their access token. Without a way to sign, a brands
// request would be rejected (and logged/blocked by core), so skip it until
// we can sign.
private _resolveImageUrl(url: string | undefined): string {
if (!url) {
return "";
}
if (this._connection) {
return this._connection.hassUrl(url);
}
return isBrandUrl(url) ? "" : url;
}
protected getClass() {
const cls = new Map(
["has-no-radius", "has-media-image", "has-image"].map((_cls) => [
-1
View File
@@ -24,7 +24,6 @@ class StateInfo extends LitElement {
const name = this.hass.formatEntityName(this.stateObj, { type: "entity" });
return html`<state-badge
.hass=${this.hass}
.stateObj=${this.stateObj}
.stateColor=${true}
.color=${this.color}
+6 -4
View File
@@ -400,10 +400,12 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
private _handleToggleThinking(ev: Event) {
const index = (ev.currentTarget as any).index;
this._conversation[index] = {
...this._conversation[index],
thinking_expanded: !this._conversation[index].thinking_expanded,
};
// Mutate the message in place rather than replacing it. The streaming
// processor keeps a reference to this same object and mutates it as deltas
// arrive; swapping in a new object would detach the in-flight message from
// the processor and freeze the chat (see #52501).
const message = this._conversation[index];
message.thinking_expanded = !message.thinking_expanded;
this.requestUpdate("_conversation");
}
+46 -16
View File
@@ -1,9 +1,10 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import {
configContext,
connectionContext,
@@ -35,6 +36,47 @@ export class HaAttributeIcon extends LitElement {
@consume({ context: entitiesContext, subscribe: true })
private _entities?: ContextType<typeof entitiesContext>;
private _iconTask = new AsyncValueTask(this, {
task: ([
icon,
config,
connection,
entities,
stateObj,
attribute,
attributeValue,
]) => {
if (
icon ||
!config ||
!connection ||
!entities ||
!stateObj ||
!attribute
) {
return initialState;
}
return attributeIcon(
config.config,
connection.connection,
entities,
stateObj,
attribute,
attributeValue
);
},
args: () =>
[
this.icon,
this._config,
this._connection,
this._entities,
this.stateObj,
this.attribute,
this.attributeValue,
] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -48,21 +90,9 @@ export class HaAttributeIcon extends LitElement {
return nothing;
}
const icon = attributeIcon(
this._config.config,
this._connection.connection,
this._entities,
this.stateObj,
this.attribute,
this.attributeValue
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return nothing;
});
return html`${until(icon)}`;
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: nothing;
}
}
+21 -12
View File
@@ -1,13 +1,19 @@
import { consume } from "@lit/context";
import type { ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { getValueAttribute } from "../common/entity/get_states";
import { valueFromParts } from "../common/entity/value_parts";
import { formattersContext } from "../data/context";
const isObjectValue = (value: unknown): boolean =>
(Array.isArray(value) && value.some((val) => val instanceof Object)) ||
(!Array.isArray(value) && value instanceof Object);
@customElement("ha-attribute-value")
class HaAttributeValue extends LitElement {
@state()
@@ -20,6 +26,17 @@ class HaAttributeValue extends LitElement {
@property({ type: Boolean, attribute: "hide-unit" }) public hideUnit = false;
private _yamlTask = new AsyncValueTask(this, {
task: async ([attributeValue]) => {
if (!isObjectValue(attributeValue)) {
return initialState;
}
const { dump } = await import("js-yaml");
return dump(attributeValue);
},
args: () => [this.stateObj?.attributes[this.attribute]] as const,
});
protected render() {
if (!this.stateObj) {
return nothing;
@@ -49,13 +66,8 @@ class HaAttributeValue extends LitElement {
}
}
if (
(Array.isArray(attributeValue) &&
attributeValue.some((val) => val instanceof Object)) ||
(!Array.isArray(attributeValue) && attributeValue instanceof Object)
) {
const yaml = import("js-yaml").then(({ dump }) => dump(attributeValue));
return html`<pre>${until(yaml, "")}</pre>`;
if (isObjectValue(attributeValue)) {
return html`<pre>${this._yamlTask.value ?? ""}</pre>`;
}
// Options-list attributes (effect_list, preset_modes, …) translated through
@@ -83,10 +95,7 @@ class HaAttributeValue extends LitElement {
this.stateObj!,
this.attribute
);
return parts
.filter((part) => part.type === "value")
.map((part) => part.value)
.join("");
return valueFromParts(parts);
}
return this._formatters!.formatEntityAttributeValue(
+8 -2
View File
@@ -153,10 +153,16 @@ export class HaBaseTimeInput extends LitElement {
protected render(): TemplateResult {
return html`
${this.label
? html`<label>${this.label}${this.required ? " *" : ""}</label>`
? html`<label id="label"
>${this.label}${this.required ? " *" : ""}</label
>`
: nothing}
<div class="time-input-wrap-wrap">
<div class="time-input-wrap">
<div
class="time-input-wrap"
role="group"
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
>
${this.enableDay
? html`
<ha-input
+19 -13
View File
@@ -12,10 +12,11 @@ import {
mdiWeatherSunny,
} from "@mdi/js";
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { HassConfig, Connection } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
@@ -57,6 +58,17 @@ export class HaConditionIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, condition]) => {
if (icon || !connection || !config || !condition) {
return initialState;
}
return conditionIcon(connection, config, condition);
},
args: () =>
[this.icon, this._connection, this._config, this.condition] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -70,18 +82,12 @@ export class HaConditionIcon extends LitElement {
return this._renderFallback();
}
const icon = conditionIcon(
this._connection,
this._config,
this.condition
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
+4 -1
View File
@@ -388,7 +388,10 @@ export class HaControlSlider extends LitElement {
private _isVisuallyInverted() {
let inverted = this.inverted;
if (mainWindow.document.dir === "rtl") {
// RTL only mirrors the horizontal axis. A vertical slider always fills
// bottom-to-top regardless of text direction, so it must not be flipped,
// otherwise its value mapping ends up upside down in RTL languages.
if (!this.vertical && mainWindow.document.dir === "rtl") {
inverted = !inverted;
}
+32 -16
View File
@@ -1,7 +1,8 @@
import { consume, type ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { configContext, connectionContext, uiContext } from "../data/context";
import {
DEFAULT_DOMAIN_ICON,
@@ -36,6 +37,30 @@ export class HaDomainIcon extends LitElement {
@consume({ context: uiContext, subscribe: true })
private _hassUi?: ContextType<typeof uiContext>;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, domain, deviceClass, domainState]) => {
if (icon || !connection || !config || !domain) {
return initialState;
}
return domainIcon(
connection.connection,
config.config,
domain,
deviceClass,
domainState
);
},
args: () =>
[
this.icon,
this._connection,
this._hassConfig,
this.domain,
this.deviceClass,
this.state,
] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -49,21 +74,12 @@ export class HaDomainIcon extends LitElement {
return this._renderFallback();
}
const icon = domainIcon(
this._connection.connection,
this._hassConfig.config,
this.domain,
this.deviceClass,
this.state
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
+18 -6
View File
@@ -1,13 +1,18 @@
import { consume } from "@lit/context";
import { mdiDelete, mdiFileUpload } from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ensureArray } from "../common/array/ensure-array";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { transform } from "../common/decorators/transform";
import { fireEvent } from "../common/dom/fire_event";
import { blankBeforePercent } from "../common/translations/blank_before_percent";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
import { internationalizationContext } from "../data/context";
import type { FrontendLocaleData } from "../data/translation";
import type { HomeAssistantInternationalization } from "../types";
import { bytesToString } from "../util/bytes-to-string";
import "./ha-button";
import "./ha-icon-button";
@@ -22,10 +27,17 @@ declare global {
@customElement("ha-file-upload")
export class HaFileUpload extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public localize?: LocalizeFunc;
@state() @consumeLocalize() private _localize?: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale?: FrontendLocaleData;
@property() public accept!: string;
@property() public icon?: string;
@@ -80,7 +92,7 @@ export class HaFileUpload extends LitElement {
}
public render(): TemplateResult {
const localize = this.localize || this.hass!.localize;
const localize = this.localize || this._localize!;
return html`
${this.uploading
? html`<div class="container">
@@ -95,8 +107,8 @@ export class HaFileUpload extends LitElement {
>
${this.progress
? html`<div class="progress">
${this.progress}${this.hass &&
blankBeforePercent(this.hass!.locale)}%
${this.progress}${this._locale &&
blankBeforePercent(this._locale)}%
</div>`
: nothing}
</div>
+57 -18
View File
@@ -1,15 +1,23 @@
import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { deepEqual } from "../common/util/deep-equal";
import {
apiContext,
devicesContext,
internationalizationContext,
statesContext,
} from "../data/context";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
@@ -24,7 +32,24 @@ interface HaFilterDevicesItem extends HaListVirtualizedItem {
@customElement("ha-filter-devices")
export class HaFilterDevices extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: devicesContext, subscribe: true })
@state()
private _devicesReg!: ContextType<typeof devicesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public value?: string[];
@@ -75,7 +100,7 @@ export class HaFilterDevices extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.devices.caption")}
${this._localize("ui.panel.config.devices.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
@@ -95,7 +120,13 @@ export class HaFilterDevices extends LitElement {
</ha-input-search>
<ha-list-selectable-virtualized
multi
.rows=${this._devices(this.hass.devices, this._filter || "")}
.rows=${this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
@@ -121,13 +152,24 @@ export class HaFilterDevices extends LitElement {
private _handleAdded(ev: CustomEvent<number>) {
this.value = [
...(this.value ?? []),
this._devices(this.hass.devices, this._filter || "")[ev.detail].id,
this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)[ev.detail].id,
];
}
private _handleRemoved(ev: CustomEvent<number>) {
const id = this._devices(this.hass.devices, this._filter || "")[ev.detail]
.id;
const id = this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)[ev.detail].id;
this.value = (this.value ?? []).filter((deviceId) => deviceId !== id);
}
@@ -153,27 +195,24 @@ export class HaFilterDevices extends LitElement {
private _devices = memoizeOne(
(
devices: HomeAssistant["devices"],
filter: string
devices: ContextType<typeof devicesContext>,
filter: string,
localize: LocalizeFunc,
states: ContextType<typeof statesContext>,
language: string | undefined
): HaFilterDevicesItem[] => {
const values = Object.values(devices);
return values
.map((device) => ({
id: device.id,
interactive: true,
name: computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
),
name: computeDeviceNameDisplay(device, localize, states),
}))
.filter(
({ name }) =>
!filter || name.toLowerCase().includes(filter.toLowerCase())
)
.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
);
.sort((a, b) => stringCompare(a.name, b.name, language));
}
);
@@ -194,7 +233,7 @@ export class HaFilterDevices extends LitElement {
for (const deviceId of this.value) {
value.push(deviceId);
if (this.type) {
relatedPromises.push(findRelated(this.hass, "device", deviceId));
relatedPromises.push(findRelated(this._api, "device", deviceId));
}
}
const results = await Promise.all(relatedPromises);
+58 -25
View File
@@ -1,3 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
@@ -5,12 +6,14 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, statesContext } from "../data/context";
import { domainToName } from "../data/integration";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-domain-icon";
import "./ha-expansion-panel";
@@ -20,7 +23,17 @@ import type { HaInputSearch } from "./input/ha-input-search";
@customElement("ha-filter-domains")
export class HaFilterDomains extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@property({ attribute: false }) public value?: string[];
@@ -43,7 +56,7 @@ export class HaFilterDomains extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.domains.caption")}
${this._localize("ui.panel.config.domains.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
@@ -65,7 +78,13 @@ export class HaFilterDomains extends LitElement {
multi
>
${repeat(
this._domains(this.hass.states, this._filter, this.value),
this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
),
(i) => i,
(domain) =>
html`<ha-check-list-item
@@ -78,7 +97,7 @@ export class HaFilterDomains extends LitElement {
.domain=${domain}
brand-fallback
></ha-domain-icon>
${domainToName(this.hass.localize, domain)}
${domainToName(this._localize, domain)}
</ha-check-list-item>`
)}
</ha-list> `
@@ -87,26 +106,34 @@ export class HaFilterDomains extends LitElement {
`;
}
private _domains = memoizeOne((states, filter, _value) => {
const domains = new Set<string>();
Object.keys(states).forEach((entityId) => {
domains.add(computeDomain(entityId));
});
private _domains = memoizeOne(
(
states: ContextType<typeof statesContext>,
localize: LocalizeFunc,
language: string | undefined,
filter: string | undefined,
_value
) => {
const domains = new Set<string>();
Object.keys(states).forEach((entityId) => {
domains.add(computeDomain(entityId));
});
return Array.from(domains.values())
.map((domain) => ({
domain,
name: domainToName(this.hass.localize, domain),
}))
.filter(
(entry) =>
!filter ||
entry.domain.toLowerCase().includes(filter) ||
entry.name.toLowerCase().includes(filter)
)
.sort((a, b) => stringCompare(a.name, b.name, this.hass.locale.language))
.map((entry) => entry.domain);
});
return Array.from(domains.values())
.map((domain) => ({
domain,
name: domainToName(localize, domain),
}))
.filter(
(entry) =>
!filter ||
entry.domain.toLowerCase().includes(filter) ||
entry.name.toLowerCase().includes(filter)
)
.sort((a, b) => stringCompare(a.name, b.name, language))
.map((entry) => entry.domain);
}
);
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
@@ -129,7 +156,13 @@ export class HaFilterDomains extends LitElement {
}
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const domains = this._domains(this.hass.states, this._filter, this.value);
const domains = this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
);
const visibleDomains = new Set(domains);
const preserved = (this.value || []).filter((d) => !visibleDomains.has(d));
+29 -11
View File
@@ -1,18 +1,25 @@
import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import { stringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { deepEqual } from "../common/util/deep-equal";
import {
apiContext,
internationalizationContext,
statesContext,
} from "../data/context";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-list";
@@ -22,7 +29,20 @@ import type { HaInputSearch } from "./input/ha-input-search";
@customElement("ha-filter-entities")
export class HaFilterEntities extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: statesContext, subscribe: true })
@state()
private _states!: ContextType<typeof statesContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public value?: string[];
@@ -62,7 +82,7 @@ export class HaFilterEntities extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.entities.caption")}
${this._localize("ui.panel.config.entities.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
@@ -82,9 +102,10 @@ export class HaFilterEntities extends LitElement {
<ha-list class="ha-scrollbar" multi>
<lit-virtualizer
.items=${this._entities(
this.hass.states,
this._states,
this.type,
this._filter || "",
this._i18n.locale.language,
this.value
)}
.keyFunction=${this._keyFunction}
@@ -163,9 +184,10 @@ export class HaFilterEntities extends LitElement {
private _entities = memoizeOne(
(
states: HomeAssistant["states"],
states: ContextType<typeof statesContext>,
type: this["type"],
filter: string,
language: string | undefined,
_value
) => {
const values = Object.values(states);
@@ -180,11 +202,7 @@ export class HaFilterEntities extends LitElement {
.includes(filter))
)
.sort((a, b) =>
stringCompare(
computeStateName(a),
computeStateName(b),
this.hass.locale.language
)
stringCompare(computeStateName(a), computeStateName(b), language)
);
}
);
@@ -203,7 +221,7 @@ export class HaFilterEntities extends LitElement {
for (const entityId of this.value) {
if (this.type) {
relatedPromises.push(findRelated(this.hass, "entity", entityId));
relatedPromises.push(findRelated(this._api, "entity", entityId));
}
}
+38 -12
View File
@@ -1,3 +1,4 @@
import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove, mdiTextureBox } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -5,14 +6,21 @@ 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";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeRTL } from "../common/util/compute_rtl";
import { deepEqual } from "../common/util/deep-equal";
import type { LocalizeFunc } from "../common/translations/localize";
import {
apiContext,
areasContext,
floorsContext,
internationalizationContext,
} from "../data/context";
import { getFloorAreaLookup } from "../data/floor_registry";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-floor-icon";
import "./ha-icon";
@@ -26,7 +34,24 @@ import type { HaListSelectable } from "./list/ha-list-selectable";
@customElement("ha-filter-floor-areas")
export class HaFilterFloorAreas extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: areasContext, subscribe: true })
@state()
private _areasReg!: ContextType<typeof areasContext>;
@consume({ context: floorsContext, subscribe: true })
@state()
private _floorsReg!: ContextType<typeof floorsContext>;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public value?: {
floors?: string[];
@@ -55,7 +80,7 @@ export class HaFilterFloorAreas extends LitElement {
}
protected render() {
const areas = this._areas(this.hass.areas, this.hass.floors);
const areas = this._areas(this._areasReg, this._floorsReg);
return html`
<ha-expansion-panel
@@ -65,7 +90,7 @@ export class HaFilterFloorAreas extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.areas.caption")}
${this._localize("ui.panel.config.areas.caption")}
${this.value?.areas?.length || this.value?.floors?.length
? html`<div class="badge">
${(this.value?.areas?.length || 0) +
@@ -85,9 +110,7 @@ export class HaFilterFloorAreas extends LitElement {
multi
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
aria-label=${this.hass.localize(
"ui.panel.config.areas.caption"
)}
aria-label=${this._localize("ui.panel.config.areas.caption")}
>
${repeat(
areas?.floors || [],
@@ -141,8 +164,8 @@ export class HaFilterFloorAreas extends LitElement {
.type=${"areas"}
class=${classMap({
rtl: computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
this._i18n.language,
this._i18n.translationMetadata.translations
),
floor: hasFloor,
})}
@@ -225,7 +248,10 @@ export class HaFilterFloorAreas extends LitElement {
}
private _areas = memoizeOne(
(areaReg: HomeAssistant["areas"], floorReg: HomeAssistant["floors"]) => {
(
areaReg: ContextType<typeof areasContext>,
floorReg: ContextType<typeof floorsContext>
) => {
const areas = Object.values(areaReg);
const floors = Object.values(floorReg);
const floorAreaLookup = getFloorAreaLookup(areas);
@@ -261,7 +287,7 @@ export class HaFilterFloorAreas extends LitElement {
if (this.value.areas) {
for (const areaId of this.value.areas) {
if (this.type) {
relatedPromises.push(findRelated(this.hass, "area", areaId));
relatedPromises.push(findRelated(this._api, "area", areaId));
}
}
}
@@ -269,7 +295,7 @@ export class HaFilterFloorAreas extends LitElement {
if (this.value.floors) {
for (const floorId of this.value.floors) {
if (this.type) {
relatedPromises.push(findRelated(this.hass, "floor", floorId));
relatedPromises.push(findRelated(this._api, "floor", floorId));
}
}
}
+23 -13
View File
@@ -1,4 +1,4 @@
import { consume } from "@lit/context";
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiCog, mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
@@ -6,13 +6,14 @@ import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { navigate } from "../common/navigate";
import { stringCompare } from "../common/string/compare";
import { labelsContext } from "../data/context";
import type { LocalizeFunc } from "../common/translations/localize";
import { internationalizationContext, labelsContext } from "../data/context";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
@@ -25,14 +26,20 @@ import type { HaInputSearch } from "./input/ha-input-search";
@customElement("ha-filter-labels")
export class HaFilterLabels extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@consume({ context: internationalizationContext, subscribe: true })
@state()
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: labelsContext, subscribe: true })
@state()
private _labels?: LabelRegistryEntry[];
@@ -45,7 +52,12 @@ export class HaFilterLabels extends LitElement {
private _filteredLabels = memoizeOne(
// `_value` used to recalculate the memoization when the selection changes
(labels: LabelRegistryEntry[], filter: string | undefined, _value) =>
(
labels: LabelRegistryEntry[],
filter: string | undefined,
language: string | undefined,
_value
) =>
labels
.filter(
(label) =>
@@ -54,11 +66,7 @@ export class HaFilterLabels extends LitElement {
label.label_id.toLowerCase().includes(filter)
)
.sort((a, b) =>
stringCompare(
a.name || a.label_id,
b.name || b.label_id,
this.hass.locale.language
)
stringCompare(a.name || a.label_id, b.name || b.label_id, language)
)
);
@@ -71,7 +79,7 @@ export class HaFilterLabels extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.labels.caption")}
${this._localize("ui.panel.config.labels.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
@@ -96,6 +104,7 @@ export class HaFilterLabels extends LitElement {
this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
),
(label) => label.label_id,
@@ -129,7 +138,7 @@ export class HaFilterLabels extends LitElement {
class="add"
>
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
${this.hass.localize("ui.panel.config.labels.manage_labels")}
${this._localize("ui.panel.config.labels.manage_labels")}
</ha-list-item>`
: nothing}
`;
@@ -169,6 +178,7 @@ export class HaFilterLabels extends LitElement {
const filteredLabels = this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
);
@@ -8,7 +8,6 @@ import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
@@ -22,8 +21,6 @@ import "../panels/config/voice-assistants/expose/expose-assistant-icon";
@customElement("ha-filter-voice-assistants")
export class HaFilterVoiceAssistants extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@@ -78,7 +75,6 @@ export class HaFilterVoiceAssistants extends LitElement {
<voice-assistant-brand-icon
slot="graphic"
.voiceAssistantId=${voiceAssistantId}
.hass=${this.hass}
>
</voice-assistant-brand-icon>
${voiceAssistants[voiceAssistantId].name}
+17 -8
View File
@@ -1,10 +1,13 @@
import { consume, type ContextType } from "@lit/context";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { supportsFeature } from "../common/entity/supports-feature";
import type { LocalizeFunc } from "../common/translations/localize";
import { apiContext, formattersContext } from "../data/context";
import "./ha-button";
import type { LawnMowerEntity, LawnMowerEntityState } from "../data/lawn_mower";
import { LawnMowerEntityFeature } from "../data/lawn_mower";
import type { HomeAssistant } from "../types";
interface LawnMowerAction {
action: string;
@@ -39,13 +42,19 @@ const LAWN_MOWER_ACTIONS: Partial<
@customElement("ha-lawn_mower-action-button")
class HaLawnMowerActionButton extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters?: ContextType<typeof formattersContext>;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public stateObj!: LawnMowerEntity;
public render() {
const state = this.stateObj.state;
const action = LAWN_MOWER_ACTIONS[state];
const action = LAWN_MOWER_ACTIONS[this.stateObj.state];
if (action && supportsFeature(this.stateObj, action.feature)) {
return html`
@@ -55,14 +64,14 @@ class HaLawnMowerActionButton extends LitElement {
.service=${action.service}
size="s"
>
${this.hass.localize(`ui.card.lawn_mower.actions.${action.action}`)}
${this._localize(`ui.card.lawn_mower.actions.${action.action}`)}
</ha-button>
`;
}
return html`
<ha-button appearance="plain" disabled>
${this.hass.formatEntityState(this.stateObj)}
${this._formatters?.formatEntityState(this.stateObj)}
</ha-button>
`;
}
@@ -71,7 +80,7 @@ class HaLawnMowerActionButton extends LitElement {
ev.stopPropagation();
const stateObj = this.stateObj;
const service = ev.target.service;
this.hass.callService("lawn_mower", service, {
this._api.callService("lawn_mower", service, {
entity_id: stateObj.entity_id,
});
}
-1
View File
@@ -78,7 +78,6 @@ export class HaPictureUpload extends LitElement {
return html`
<ha-file-upload
.hass=${this.hass}
.icon=${mdiImagePlus}
.label=${this.label ||
this.hass.localize("ui.components.picture-upload.label")}
@@ -37,7 +37,6 @@ export class HaFileSelector extends LitElement {
protected render() {
return html`
<ha-file-upload
.hass=${this.hass}
.accept=${this.selector.file?.accept}
.icon=${mdiFile}
.label=${this.label}
+36 -12
View File
@@ -1,6 +1,8 @@
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import type { HassEntity } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../../common/controllers/async-value-task";
import { fireEvent } from "../../common/dom/fire_event";
import { entityIcon } from "../../data/icons";
import type { IconSelector } from "../../data/selector";
@@ -28,23 +30,45 @@ export class HaIconSelector extends LitElement {
icon_entity?: string;
};
protected render() {
private get _stateObj(): HassEntity | undefined {
const iconEntity = this.context?.icon_entity;
return iconEntity ? this.hass.states[iconEntity] : undefined;
}
const stateObj = iconEntity ? this.hass.states[iconEntity] : undefined;
private _placeholderTask = new AsyncValueTask(this, {
task: ([
placeholder,
attributeIcon,
entities,
config,
connection,
stateObj,
]) => {
if (placeholder || attributeIcon || !stateObj) {
return initialState;
}
return entityIcon(entities, config, connection, stateObj);
},
args: () => {
const stateObj = this._stateObj;
return [
this.selector.icon?.placeholder,
stateObj?.attributes.icon,
this.hass.entities,
this.hass.config,
this.hass.connection,
stateObj,
] as const;
},
});
protected render() {
const stateObj = this._stateObj;
const placeholder =
this.selector.icon?.placeholder ||
stateObj?.attributes.icon ||
(stateObj &&
until(
entityIcon(
this.hass.entities,
this.hass.config,
this.hass.connection,
stateObj
)
));
(stateObj && this._placeholderTask.value);
return html`
<ha-icon-picker
@@ -23,7 +23,6 @@ export class HaThemeSelector extends LitElement {
protected render() {
return html`
<ha-theme-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
+19 -11
View File
@@ -1,8 +1,9 @@
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
@@ -34,6 +35,17 @@ export class HaServiceIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, service]) => {
if (icon || !connection || !config || !service) {
return initialState;
}
return serviceIcon(connection, config, service);
},
args: () =>
[this.icon, this._connection, this._config, this.service] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -47,16 +59,12 @@ export class HaServiceIcon extends LitElement {
return this._renderFallback();
}
const icon = serviceIcon(this._connection, this._config, this.service).then(
(icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
}
);
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
+25 -14
View File
@@ -1,8 +1,9 @@
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
import { serviceSectionIcon } from "../data/icons";
@@ -31,6 +32,23 @@ export class HaServiceSectionIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, service, section]) => {
if (icon || !connection || !config || !service || !section) {
return initialState;
}
return serviceSectionIcon(connection, config, service, section);
},
args: () =>
[
this.icon,
this._connection,
this._config,
this.service,
this.section,
] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -44,19 +62,12 @@ export class HaServiceSectionIcon extends LitElement {
return this._renderFallback();
}
const icon = serviceSectionIcon(
this._connection,
this._config,
this.service,
this.section
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
+47 -17
View File
@@ -1,8 +1,9 @@
import { consume, type ContextType } from "@lit/context";
import { initialState } from "@lit/task";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import {
configContext,
@@ -37,11 +38,47 @@ export class HaStateIcon extends LitElement {
@consume({ context: entitiesContext, subscribe: true })
protected _entities?: ContextType<typeof entitiesContext>;
protected render() {
const overrideIcon =
private get _overrideIcon(): string | undefined {
return (
this.icon ||
(this.stateObj && this._entities?.[this.stateObj.entity_id]?.icon) ||
this.stateObj?.attributes.icon;
this.stateObj?.attributes.icon
);
}
private _iconTask = new AsyncValueTask(this, {
task: ([
overrideIcon,
entities,
config,
connection,
stateObj,
stateValue,
]) => {
if (overrideIcon || !entities || !config || !connection || !stateObj) {
return initialState;
}
return entityIcon(
entities,
config.config,
connection.connection,
stateObj,
stateValue
);
},
args: () =>
[
this._overrideIcon,
this._entities,
this._config,
this._connection,
this.stateObj,
this.stateValue,
] as const,
});
protected render() {
const overrideIcon = this._overrideIcon;
if (overrideIcon) {
return html`<ha-icon .icon=${overrideIcon}></ha-icon>`;
}
@@ -51,19 +88,12 @@ export class HaStateIcon extends LitElement {
if (!this._config || !this._connection || !this._entities) {
return this._renderFallback();
}
const icon = entityIcon(
this._entities,
this._config.config,
this._connection.connection,
this.stateObj,
this.stateValue
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
-1
View File
@@ -1233,7 +1233,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
<state-badge
slot="start"
.stateObj=${(item as EntityComboBoxItem).stateObj}
.hass=${this.hass}
></state-badge>
`
: type === "device" && (item as DevicePickerItem).domain
+15 -7
View File
@@ -1,10 +1,12 @@
import { consume, type ContextType } from "@lit/context";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import { internationalizationContext, uiContext } from "../data/context";
import type { ValueChangedEvent } from "../types";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
@@ -23,7 +25,13 @@ export class HaThemePicker extends LitElement {
@property({ attribute: "include-default", type: Boolean })
public includeDefault = false;
@property({ attribute: false }) public hass?: HomeAssistant;
@state()
@consume({ context: uiContext, subscribe: true })
private _ui?: ContextType<typeof uiContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -56,8 +64,8 @@ export class HaThemePicker extends LitElement {
private _getItems = () =>
this._getThemeOptions(
this.hass?.themes.themes || {},
this.hass?.locale.language || "en",
this._ui?.themes.themes || {},
this._i18n?.locale.language || "en",
this.includeDefault
);
@@ -70,10 +78,10 @@ export class HaThemePicker extends LitElement {
return html`
<ha-generic-picker
.label=${this.label ??
this.hass?.localize("ui.components.theme-picker.theme") ??
this._i18n?.localize("ui.components.theme-picker.theme") ??
"Theme"}
.placeholder=${this.noThemeLabel ??
this.hass?.localize("ui.components.theme-picker.no_theme")}
this._i18n?.localize("ui.components.theme-picker.no_theme")}
.helper=${this.helper}
.value=${this.value}
.valueRenderer=${this._valueRenderer}
-1
View File
@@ -73,7 +73,6 @@ export class HaThemeSettings extends LitElement {
${this.showThemePicker
? html`
<ha-theme-picker
.hass=${this.hass}
.label=${this.labels?.theme}
.noThemeLabel=${this.labels?.noTheme}
.value=${themeSettings?.theme || undefined}
+34 -6
View File
@@ -13,12 +13,40 @@ const SEARCH_KEYS = [
{ name: "secondary", weight: 8 },
];
export const getTimezoneOptions = (): PickerComboBoxItem[] =>
Object.entries(timezones as Record<string, string>).map(([key, value]) => ({
id: key,
primary: value,
secondary: key,
}));
// google-timezones-json is missing the bare "UTC" and "Etc/UTC" zones, even
// though both are valid IANA identifiers and common server defaults. Without
// them a "UTC" configuration shows up as an unknown time zone. Add them back.
const ADDITIONAL_TIMEZONES: PickerComboBoxItem[] = [
{ id: "UTC", primary: "(GMT+00:00) UTC", secondary: "UTC" },
{ id: "Etc/UTC", primary: "(GMT+00:00) UTC", secondary: "Etc/UTC" },
];
// google-timezones-json also ships an invalid IANA identifier. Correct it so
// the zone can be selected (the backend rejects the invalid id).
const TIMEZONE_ID_CORRECTIONS: Record<string, string> = {
"Asia/Yuzhno-Sakhalinsk": "Asia/Sakhalin",
};
export const getTimezoneOptions = (): PickerComboBoxItem[] => {
const options: PickerComboBoxItem[] = Object.entries(
timezones as Record<string, string>
).map(([key, value]) => {
const id = TIMEZONE_ID_CORRECTIONS[key] ?? key;
return {
id,
primary: value,
secondary: id,
};
});
for (const timezone of ADDITIONAL_TIMEZONES) {
if (!options.some((option) => option.id === timezone.id)) {
options.push(timezone);
}
}
return options;
};
@customElement("ha-timezone-picker")
export class HaTimeZonePicker extends LitElement {
+6
View File
@@ -2,6 +2,7 @@ import {
mdiAlertCircleOutline,
mdiCheckCircleOutline,
mdiChevronDown,
mdiCircleOffOutline,
mdiHelpCircleOutline,
mdiProgressClock,
mdiProgressWrench,
@@ -84,6 +85,11 @@ class HaTracePicker extends LitElement {
"ui.panel.config.automation.trace.picker.debugged"
);
item.icon_path = mdiProgressWrench;
} else if (trace.not_triggered) {
item.secondary = this.hass.localize(
"ui.panel.config.automation.trace.picker.not_triggered"
);
item.icon_path = mdiCircleOffOutline;
} else if (trace.script_execution === "finished") {
item.secondary = this.hass.localize(
"ui.panel.config.automation.trace.picker.finished",
+19 -11
View File
@@ -18,10 +18,11 @@ import {
mdiWebhook,
} from "@mdi/js";
import { consume } from "@lit/context";
import { initialState } from "@lit/task";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import { AsyncValueTask } from "../common/controllers/async-value-task";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
@@ -71,6 +72,17 @@ export class HaTriggerIcon extends LitElement {
})
private _connection?: Connection;
private _iconTask = new AsyncValueTask(this, {
task: ([icon, connection, config, trigger]) => {
if (icon || !connection || !config || !trigger) {
return initialState;
}
return triggerIcon(connection, config, trigger);
},
args: () =>
[this.icon, this._connection, this._config, this.trigger] as const,
});
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -84,16 +96,12 @@ export class HaTriggerIcon extends LitElement {
return this._renderFallback();
}
const icon = triggerIcon(this._connection, this._config, this.trigger).then(
(icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
}
);
return html`${until(icon)}`;
if (!this._iconTask.resolved) {
return nothing;
}
return this._iconTask.value
? html`<ha-icon .icon=${this._iconTask.value}></ha-icon>`
: this._renderFallback();
}
private _renderFallback() {
+17 -11
View File
@@ -1,9 +1,12 @@
import { consume, type ContextType } from "@lit/context";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import type { HassEntity } from "home-assistant-js-websocket";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { apiContext } from "../data/context";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-button";
const STATES_INTERCEPTABLE: Record<
@@ -46,7 +49,10 @@ const STATES_INTERCEPTABLE: Record<
@customElement("ha-vacuum-state")
export class HaVacuumState extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public stateObj!: HassEntity;
@@ -68,19 +74,19 @@ export class HaVacuumState extends LitElement {
}
private _computeInterceptable(
state: string,
stateString: string,
supportedFeatures: number | undefined
) {
return state in STATES_INTERCEPTABLE && supportedFeatures !== 0;
return stateString in STATES_INTERCEPTABLE && supportedFeatures !== 0;
}
private _computeLabel(state: string, interceptable: boolean) {
private _computeLabel(stateString: string, interceptable: boolean) {
return interceptable
? this.hass.localize(
`ui.card.vacuum.actions.${STATES_INTERCEPTABLE[state].action}`
? this._localize(
`ui.card.vacuum.actions.${STATES_INTERCEPTABLE[stateString].action}`
)
: this.hass.localize(
`component.vacuum.entity_component._.state.${state}`
: this._localize(
`component.vacuum.entity_component._.state.${stateString}`
);
}
@@ -88,7 +94,7 @@ export class HaVacuumState extends LitElement {
ev.stopPropagation();
const stateObj = this.stateObj;
const service = STATES_INTERCEPTABLE[stateObj.state].service;
await this.hass.callService("vacuum", service, {
await this._api.callService("vacuum", service, {
entity_id: stateObj.entity_id,
});
}
+39 -15
View File
@@ -1,14 +1,39 @@
import { customElement, property } from "lit/decorators";
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import type { HassEntity } from "home-assistant-js-websocket";
import { customElement, property, state } from "lit/decorators";
import { transform } from "../common/decorators/transform";
import { formatNumber } from "../common/number/format_number";
import {
configContext,
formattersContext,
internationalizationContext,
} from "../data/context";
import type { FrontendLocaleData } from "../data/translation";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import type {
HomeAssistantConfig,
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../types";
@customElement("ha-water_heater-state")
export class HaWaterHeaterState extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters?: HomeAssistantFormatters;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale?: FrontendLocaleData;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig?: HomeAssistantConfig;
@property({ attribute: false }) public stateObj!: HassEntity;
@@ -16,17 +41,16 @@ export class HaWaterHeaterState extends LitElement {
return html`
<div class="target">
<span class="state-label label">
${this.hass.formatEntityState(this.stateObj)}
${this._formatters?.formatEntityState(this.stateObj)}
</span>
<span class="label"
>${this._computeTarget(this.hass, this.stateObj)}</span
>
<span class="label">${this._computeTarget()}</span>
</div>
`;
}
private _computeTarget(hass: HomeAssistant, stateObj: HassEntity) {
if (!hass || !stateObj) return null;
private _computeTarget() {
if (!this._locale || !this._hassConfig || !this.stateObj) return null;
const stateObj = this.stateObj;
// We're using "!= null" on purpose so that we match both null and undefined.
if (
@@ -35,17 +59,17 @@ export class HaWaterHeaterState extends LitElement {
) {
return `${formatNumber(
stateObj.attributes.target_temp_low,
this.hass.locale
this._locale
)} ${formatNumber(
stateObj.attributes.target_temp_high,
this.hass.locale
)} ${hass.config.unit_system.temperature}`;
this._locale
)} ${this._hassConfig.config.unit_system.temperature}`;
}
if (stateObj.attributes.temperature != null) {
return `${formatNumber(
stateObj.attributes.temperature,
this.hass.locale
)} ${hass.config.unit_system.temperature}`;
this._locale
)} ${this._hassConfig.config.unit_system.temperature}`;
}
return "";
@@ -101,7 +101,6 @@ class DialogMediaPlayerBrowse extends LitElement {
</span>
<ha-media-manage-button
slot="actionItems"
.hass=${this.hass}
.currentItem=${this._currentItem}
@media-refresh=${this._refreshMedia}
></ha-media-manage-button>
@@ -1,13 +1,16 @@
import { consume, type ContextType } from "@lit/context";
import { mdiFolderEdit } from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import { configContext } from "../../data/context";
import type { MediaPlayerItem } from "../../data/media-player";
import {
isLocalMediaSourceContentId,
isImageUploadMediaSourceContentId,
} from "../../data/media_source";
import type { HomeAssistant } from "../../types";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../ha-svg-icon";
import "../ha-button";
import { showMediaManageDialog } from "./show-media-manage-dialog";
@@ -20,7 +23,13 @@ declare global {
@customElement("ha-media-manage-button")
class MediaManageButton 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 }) currentItem?: MediaPlayerItem;
@@ -31,7 +40,7 @@ class MediaManageButton extends LitElement {
!this.currentItem ||
!(
isLocalMediaSourceContentId(this.currentItem.media_content_id || "") ||
(this.hass!.user?.is_admin &&
(this._config.user?.is_admin &&
isImageUploadMediaSourceContentId(this.currentItem.media_content_id))
)
) {
@@ -40,9 +49,7 @@ class MediaManageButton extends LitElement {
return html`
<ha-button appearance="filled" size="s" @click=${this._manage}>
<ha-svg-icon .path=${mdiFolderEdit} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.components.media-browser.file_management.manage"
)}
${this._localize("ui.components.media-browser.file_management.manage")}
</ha-button>
`;
}
+27 -4
View File
@@ -17,9 +17,10 @@ import type {
ChooseActionTraceStep,
TraceExtended,
} from "../../data/trace";
import { getDataFromPath } from "../../data/trace";
import { getDataFromPath, isTriggerPath } from "../../data/trace";
import "../../panels/logbook/ha-logbook-renderer";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
import "../ha-code-editor";
import "../ha-icon-button";
import "../ha-tab-group";
@@ -33,6 +34,12 @@ const TRACE_PATH_TABS = [
"logbook",
] as const;
// A repeat keeps only its last iterations, so the array index is not the real
// one. Use the recorded repeat.index when we have it.
const iterationNumber = (trace: ActionTraceStep, index: number): number =>
(trace.changed_variables?.repeat as { index?: number } | undefined)?.index ??
index + 1;
@customElement("ha-trace-path-details")
export class HaTracePathDetails extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -63,7 +70,7 @@ export class HaTracePathDetails extends LitElement {
protected render(): TemplateResult {
return html`
<div class="padded-box trace-info">
${this._renderSelectedTraceInfo()}
${this._renderNotTriggeredNotice()} ${this._renderSelectedTraceInfo()}
</div>
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
@@ -89,6 +96,22 @@ export class HaTracePathDetails extends LitElement {
`;
}
private _renderNotTriggeredNotice() {
if (
!this.trace.not_triggered ||
!this.selected?.path ||
!isTriggerPath(this.selected.path) ||
!(this.selected.path in this.trace.trace)
) {
return nothing;
}
return html`<ha-alert alert-type="info">
${this.hass!.localize(
"ui.panel.config.automation.trace.path.not_triggered"
)}
</ha-alert>`;
}
private _renderSelectedTraceInfo() {
const paths = this.trace.trace;
@@ -214,7 +237,7 @@ export class HaTracePathDetails extends LitElement {
: html`<h3>
${this.hass!.localize(
"ui.panel.config.automation.trace.path.iteration",
{ number: idx + 1 }
{ number: iterationNumber(trace, idx) }
)}
</h3>`}
${curPath
@@ -318,7 +341,7 @@ export class HaTracePathDetails extends LitElement {
? html`<p>
${this.hass!.localize(
"ui.panel.config.automation.trace.path.iteration",
{ number: idx + 1 }
{ number: iterationNumber(trace, idx) }
)}
</p>`
: ""}
+6
View File
@@ -20,6 +20,9 @@ export class HatGraphNode extends LitElement {
@property({ attribute: "not-enabled", reflect: true, type: Boolean })
notEnabled = false;
@property({ attribute: "not-triggered", reflect: true, type: Boolean })
notTriggered = false;
@property({ attribute: "graph-start", reflect: true, type: Boolean })
graphStart = false;
@@ -127,6 +130,9 @@ export class HatGraphNode extends LitElement {
--stroke-clr: var(--hover-clr);
--icon-clr: var(--default-icon-clr);
}
:host([not-triggered]) circle {
stroke-dasharray: 4 3;
}
:host([not-enabled]) circle {
--stroke-clr: var(--disabled-clr);
}
+9 -3
View File
@@ -90,21 +90,27 @@ export class HatScriptGraph extends LitElement {
private _renderTrigger(config: Trigger, i: number) {
const path = `trigger/${i}`;
const track = this.trace && path in this.trace.trace;
const tracked = this.trace && path in this.trace.trace;
// A not-triggered trace records the trigger that evaluated a change but
// decided not to fire. It is still selectable (to view the reason), but
// must not be shown as the path that ran.
const notTriggered = !!(tracked && this.trace.not_triggered);
const track = tracked && !notTriggered;
this.renderedNodes[path] = { config, path, type: "trigger" };
if (track) {
if (tracked) {
this.trackedNodes[path] = this.renderedNodes[path];
}
return html`
<hat-graph-node
graph-start
?track=${track}
?not-triggered=${notTriggered}
@focus=${this._selectNode(config, path, "trigger")}
?active=${this.selected === path}
.iconPath=${mdiAsterisk}
.notEnabled=${"enabled" in config && config.enabled === false}
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
tabindex=${track ? "0" : "-1"}
tabindex=${tracked ? "0" : "-1"}
></hat-graph-node>
`;
}
@@ -2,6 +2,7 @@ import { consume } from "@lit/context";
import {
mdiAlertCircle,
mdiCircle,
mdiCircleOffOutline,
mdiCircleOutline,
mdiProgressClock,
mdiProgressWrench,
@@ -323,6 +324,23 @@ class ActionRenderer {
}
private _handleTrigger(index: number, triggerStep: TriggerTraceStep): number {
if (this.trace.not_triggered) {
this._renderEntry(
triggerStep.path,
this.hass.localize(
"ui.panel.config.automation.trace.messages.evaluated_not_triggered",
{
time: formatDateTimeWithSeconds(
new Date(triggerStep.timestamp),
this.hass.locale,
this.hass.config
),
}
),
mdiCircleOffOutline
);
return index + 1;
}
this._renderEntry(
triggerStep.path,
this.hass.localize(
@@ -725,6 +743,16 @@ export class HaAutomationTracer extends LitElement {
),
icon: mdiProgressWrench,
};
} else if (this.trace.not_triggered) {
entry = {
description: this.hass.localize(
"ui.panel.config.automation.trace.messages.not_triggered",
{
time: renderFinishedAt(),
}
),
icon: mdiCircleOffOutline,
};
} else if (this.trace.script_execution === "finished") {
entry = {
description: this.hass.localize(
+12 -5
View File
@@ -1,14 +1,21 @@
import { customElement, property } from "lit/decorators";
import { consume, type ContextType } from "@lit/context";
import { customElement, property, state } from "lit/decorators";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html } from "lit";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import { configContext, uiContext } from "../data/context";
import { voiceAssistants } from "../data/expose";
import { brandsUrl } from "../util/brands-url";
@customElement("voice-assistant-brand-icon")
export class VoiceAssistantBrandicon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: uiContext, subscribe: true })
private _ui!: ContextType<typeof uiContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
@property({ attribute: false }) public voiceAssistantId!: string;
@@ -21,9 +28,9 @@ export class VoiceAssistantBrandicon extends LitElement {
{
domain: voiceAssistants[this.voiceAssistantId].domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
darkOptimized: this._ui.themes?.darkMode,
},
this.hass.auth.data.hassUrl
this._config.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
+1 -1
View File
@@ -180,7 +180,7 @@ export interface PersistentNotificationTrigger extends BaseTrigger {
export interface ZoneTrigger extends BaseTrigger {
trigger: "zone";
entity_id: string;
entity_id: string | string[];
zone: string;
event: "enter" | "leave";
}
+3
View File
@@ -1124,6 +1124,9 @@ const describeLegacyCondition = (
hasAttribute: attribute !== "" ? "true" : "false",
attribute: attribute,
numberOfEntities: entities.length,
// With "any", entities are joined with "or", which takes a singular
// verb in English even for multiple entities ("A or B is ...").
matchAny: condition.match === "any" ? "true" : "false",
entities:
condition.match === "any"
? formatListWithOrs(hass.locale, entities)
+6
View File
@@ -486,6 +486,12 @@ export const getFormattedBackupTime = memoizeOne(
export const SUPPORTED_UPLOAD_FORMAT = "application/x-tar";
// Browsers report the MIME type of a .tar inconsistently (Firefox on Windows
// gives an empty or different type), so accept it by extension as well.
export const isSupportedBackupFile = (file: File): boolean =>
file.type === SUPPORTED_UPLOAD_FORMAT ||
file.name.toLowerCase().endsWith(".tar");
export interface BackupUploadFileFormData {
file?: File;
}
+7
View File
@@ -10,6 +10,13 @@ export interface DirtyStateContext<
> {
/** Whether any contributor's current slice differs from its initial snapshot */
isDirty: boolean;
/**
* Like `isDirty`, but treats `false` and `undefined`/absent object keys as
* the same value, so a toggle that ends at its off-default (e.g.
* `show_entity_picture: false`) reads as clean and does not warn on a scrim
* close. `isDirty` still reports the raw change so save can stay enabled.
*/
isEffectiveDirty: boolean;
/**
* Push a state slice. The first push for a slice sets its baseline.
* Subsequent pushes are compared against that baseline using the provider's
+6 -2
View File
@@ -8,9 +8,13 @@ export const uploadFile = async (hass: HomeAssistant, file: File) => {
body: fd,
});
if (resp.status === 413) {
throw new Error(`Uploaded file is too large (${file.name})`);
throw new Error(
hass.localize("ui.common.upload_file_too_large", {
name: file.name,
})
);
} else if (resp.status !== 200) {
throw new Error("Unknown error");
throw new Error(hass.localize("ui.common.unknown_error"));
}
const data = await resp.json();
return data.file_id;
+6 -2
View File
@@ -57,9 +57,13 @@ export const createImage = async (
body: fd,
});
if (resp.status === 413) {
throw new Error(`Uploaded image is too large (${file.name})`);
throw new Error(
hass.localize("ui.common.upload_image_too_large", {
name: file.name,
})
);
} else if (resp.status !== 200) {
throw new Error("Unknown error");
throw new Error(hass.localize("ui.common.unknown_error"));
}
return resp.json();
};
+4 -1
View File
@@ -184,8 +184,11 @@ export const createHistoricState = (
// translate the bare description here.
export const localizeTriggerSource = (
localize: LocalizeFunc,
source: string
source: string | null
) => {
if (!source) {
return "";
}
for (const key of Object.keys(triggerPhrases) as TriggerPhraseKey[]) {
const phrase = triggerPhrases[key];
if (source.startsWith(phrase)) {
+6 -2
View File
@@ -54,9 +54,13 @@ export const uploadLocalMedia = async (
}
);
if (resp.status === 413) {
throw new Error(`Uploaded file is too large (${file.name})`);
throw new Error(
hass.localize("ui.common.upload_file_too_large", {
name: file.name,
})
);
} else if (resp.status !== 200) {
throw new Error("Unknown error");
throw new Error(hass.localize("ui.common.unknown_error"));
}
return resp.json();
};
+7 -1
View File
@@ -18,9 +18,15 @@ export const formatSelectorValue = (
}
if ("text" in selector) {
const { prefix, suffix } = selector.text || {};
const { prefix, suffix, type } = selector.text || {};
const texts = ensureArray(value);
// Never reveal secret values in a read-only preview.
if (type === "password") {
return texts.map(() => "••••••••").join(", ");
}
return texts
.map((text) => `${prefix || ""}${text}${suffix || ""}`)
.join(", ");
+17 -4
View File
@@ -18,12 +18,17 @@ interface BaseTraceStep {
export interface TriggerTraceStep extends BaseTraceStep {
changed_variables: {
trigger: {
alias?: string;
description: string;
alias?: string | null;
// Absent on not-triggered traces, which have no trigger description.
description?: string;
[key: string]: unknown;
};
[key: string]: unknown;
};
// Present on not-triggered traces: a machine-readable reason code explaining
// why the trigger evaluated a relevant change but decided not to fire, plus
// optional diagnostic context.
result?: { reason: string; data?: Record<string, unknown> };
}
export interface ConditionTraceStep extends BaseTraceStep {
@@ -61,6 +66,7 @@ export interface ChooseChoiceActionTraceStep extends BaseTraceStep {
export type ActionTraceStep =
| BaseTraceStep
| TriggerTraceStep
| ConditionTraceStep
| CallServiceActionTraceStep
| ChooseActionTraceStep
@@ -73,6 +79,9 @@ interface BaseTrace {
last_step: string | null;
run_id: string;
state: "running" | "stopped" | "debugged";
// True for traces recording that a trigger evaluated a relevant change but
// did not fire. These are counted separately from actual runs.
not_triggered?: boolean;
timestamp: {
start: string;
finish: string | null;
@@ -93,7 +102,10 @@ interface BaseTrace {
| "error"
// The exception is in the trace itself or in the last element of the trace
// Script execution stopped by async_stop called on the script run because home assistant is shutting down, script mode is SCRIPT_MODE_RESTART etc:
| "cancelled";
| "cancelled"
// No action was executed because a trigger evaluated a relevant change but
// decided not to fire; the reason is in the trigger step of the trace
| "not_triggered";
}
interface BaseTraceExtended {
@@ -103,7 +115,8 @@ interface BaseTraceExtended {
export interface AutomationTrace extends BaseTrace {
domain: "automation";
trigger: string;
// `null` for not-triggered traces, which have no trigger description.
trigger: string | null;
}
export interface AutomationTraceExtended
+1
View File
@@ -322,6 +322,7 @@ export interface ZWaveJSDataCollectionStatus {
export interface ZWaveJSRefreshNodeStatusMessage {
event: string;
stage?: string;
progress?: number;
}
export interface ZWaveJSRebuildRoutesStatusMessage {
@@ -39,11 +39,7 @@ class EntityPreviewRow extends LitElement {
return nothing;
}
const stateObj = this.stateObj;
return html`<state-badge
.hass=${this.hass}
.stateObj=${stateObj}
stateColor
></state-badge>
return html`<state-badge .stateObj=${stateObj} stateColor></state-badge>
<div class="name" .title=${computeStateName(stateObj)}>
${computeStateName(stateObj)}
</div>
@@ -201,12 +197,7 @@ class EntityPreviewRow extends LitElement {
stateObj.state === "on" || stateObj.state === "off" || noValue;
return html`
${showToggle
? html`
<ha-entity-toggle
.hass=${this.hass}
.stateObj=${stateObj}
></ha-entity-toggle>
`
? html` <ha-entity-toggle .stateObj=${stateObj}></ha-entity-toggle> `
: this.hass.formatEntityState(stateObj)}
`;
}
+15 -6
View File
@@ -5,7 +5,7 @@ import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-control-button";
import "../../components/ha-dialog";
import "../../components/ha-adaptive-dialog";
import "../../components/ha-dialog-footer";
import "../../components/input/ha-input";
import type { HaInput } from "../../components/input/ha-input";
@@ -102,6 +102,13 @@ export class DialogEnterCode
this._showClearButton = !!val;
}
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Enter") {
ev.preventDefault();
this._submit();
}
}
protected render() {
if (!this._dialogParams || !this.hass) {
return nothing;
@@ -111,7 +118,7 @@ export class DialogEnterCode
if (isText) {
return html`
<ha-dialog
<ha-adaptive-dialog
.open=${this._open}
header-title=${this._dialogParams.title ??
this.hass.localize("ui.dialogs.enter_code.title")}
@@ -127,6 +134,7 @@ export class DialogEnterCode
autoValidate
validateOnInitialRender
pattern=${ifDefined(this._dialogParams.codePattern)}
@keydown=${this._handleKeyDown}
inputmode="text"
></ha-input>
<ha-dialog-footer slot="footer">
@@ -143,12 +151,12 @@ export class DialogEnterCode
this.hass.localize("ui.common.submit")}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
</ha-adaptive-dialog>
`;
}
return html`
<ha-dialog
<ha-adaptive-dialog
.open=${this._open}
header-title=${this._dialogParams.title ?? "Enter code"}
width="small"
@@ -163,6 +171,7 @@ export class DialogEnterCode
inputmode="numeric"
?autofocus=${!this._narrow}
password-toggle
@keydown=${this._handleKeyDown}
></ha-input>
<div class="keypad">
${BUTTONS.map((value) =>
@@ -202,12 +211,12 @@ export class DialogEnterCode
)}
</div>
</div>
</ha-dialog>
</ha-adaptive-dialog>
`;
}
static styles = css`
ha-dialog {
ha-adaptive-dialog {
/* Place above other dialogs */
--dialog-z-index: 104;
}
-1
View File
@@ -310,7 +310,6 @@ export class QuickBar extends LitElement {
<state-badge
slot="start"
.stateObj=${(item as EntityComboBoxItem).stateObj}
.hass=${this.hass}
></state-badge>
`
: "domain" in item && item.domain
@@ -64,13 +64,9 @@ export class CloudStepIntro extends LitElement {
<div class="logos">
<voice-assistant-brand-icon
.voiceAssistantId=${"cloud.google_assistant"}
.hass=${this.hass}
>
</voice-assistant-brand-icon>
<voice-assistant-brand-icon
.voiceAssistantId=${"cloud.alexa"}
.hass=${this.hass}
>
<voice-assistant-brand-icon .voiceAssistantId=${"cloud.alexa"}>
</voice-assistant-brand-icon>
</div>
<h2>
+53 -5
View File
@@ -26,6 +26,15 @@ export type CompareStrategy<State> =
* so independent contributors (e.g. a helper form alongside the entity
* registry editor) can coexist without overwriting each other.
*
* `isEffectiveDirty` runs the same comparison, but first passes each slice's
* initial and current value through the optional `effectiveNormalize` function
* given to `_initDirtyTracking`. Provide a normalizer that collapses values you
* consider equivalent (e.g. a config with a toggle left at its default vs the
* key being absent) so they do not read as dirty. Without a normalizer it is
* identical to `isDirty`. Use `isEffectiveDirtyState` to decide whether closing
* needs a "discard changes?" prompt, and `isDirtyState` to decide whether save
* is enabled.
*
* @example Eager init for the provider's own slice:
* ```ts
* class MyDialog extends DirtyStateProviderMixin<MyDialogState>()(LitElement) {
@@ -63,23 +72,39 @@ export const DirtyStateProviderMixin =
class DirtyStateProviderMixinClass extends superClass {
private _dirtySlices = new Map<
Key | DefaultDirtyStateKey,
{ initial: State; current: State }
{ initial: State; current: State; normalizedInitial: State }
>();
private _dirtyCompareFn: (a: State, b: State) => boolean = deepEqual;
private _dirtyCloneFn: (value: State) => State = (value) => value;
private _effectiveNormalize?: (value: State) => State;
@provide({ context: dirtyStateContext })
@state()
private _dirtyStateContext: DirtyStateContext<State, Key> =
this._buildContextValue();
private _normalizeEffective(value: State): State {
return this._effectiveNormalize
? this._effectiveNormalize(value)
: value;
}
private _buildContextValue(): DirtyStateContext<State, Key> {
const slices = Array.from(this._dirtySlices.values());
return {
isDirty: Array.from(this._dirtySlices.values()).some(
isDirty: slices.some(
({ initial, current }) => !this._dirtyCompareFn(initial, current)
),
isEffectiveDirty: slices.some(
({ normalizedInitial, current }) =>
!this._dirtyCompareFn(
normalizedInitial,
this._normalizeEffective(current)
)
),
setState: (value: State, key: Key) => {
this._writeSlice(key, value);
},
@@ -97,9 +122,11 @@ export const DirtyStateProviderMixin =
const slice = this._dirtySlices.get(key);
if (!slice) {
// First push for this key becomes the baseline.
const initial = this._dirtyCloneFn(value);
this._dirtySlices.set(key, {
initial: this._dirtyCloneFn(value),
initial,
current: value,
normalizedInitial: this._normalizeEffective(initial),
});
this._publishContext();
return;
@@ -119,12 +146,19 @@ export const DirtyStateProviderMixin =
* push for any key (via the provider helper or a consumer's `setState`)
* becomes that key's baseline.
*
* `effectiveNormalize` transforms a slice value before the
* `isEffectiveDirty` comparison, letting the caller treat values it
* considers equivalent as clean (e.g. a config with a toggle at its
* default vs the key being absent). It does not affect `isDirty`.
*
* Call again to reset (e.g. when the underlying entity changes).
*/
protected _initDirtyTracking(
strategy: CompareStrategy<State>,
initialState?: State
initialState?: State,
effectiveNormalize?: (value: State) => State
): void {
this._effectiveNormalize = effectiveNormalize;
switch (strategy.type) {
case "deep":
this._dirtyCompareFn = (a, b) => deepEqual(a, b);
@@ -140,9 +174,11 @@ export const DirtyStateProviderMixin =
}
this._dirtySlices.clear();
if (initialState !== undefined) {
const initial = this._dirtyCloneFn(initialState);
this._dirtySlices.set(DEFAULT_DIRTY_STATE_KEY, {
initial: this._dirtyCloneFn(initialState),
initial,
current: initialState,
normalizedInitial: this._normalizeEffective(initial),
});
}
this._publishContext();
@@ -164,6 +200,7 @@ export const DirtyStateProviderMixin =
protected _markDirtyStateClean(): void {
for (const slice of this._dirtySlices.values()) {
slice.initial = this._dirtyCloneFn(slice.current);
slice.normalizedInitial = this._normalizeEffective(slice.initial);
}
this._publishContext();
}
@@ -185,6 +222,17 @@ export const DirtyStateProviderMixin =
public get isDirtyState(): boolean {
return this._dirtyStateContext.isDirty;
}
/**
* Like `isDirtyState`, but compares values after the `effectiveNormalize`
* function passed to `_initDirtyTracking`, so values the caller treats as
* equivalent (e.g. a toggle left at its default) do not read as dirty. Use
* it to decide whether closing needs a "discard changes?" prompt, while
* `isDirtyState` decides whether save is enabled.
*/
public get isEffectiveDirtyState(): boolean {
return this._dirtyStateContext.isEffectiveDirty;
}
}
return DirtyStateProviderMixinClass;
};
@@ -9,6 +9,7 @@ import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import {
CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT,
isSupportedBackupFile,
SUPPORTED_UPLOAD_FORMAT,
} from "../../data/backup";
import type { LocalizeFunc } from "../../common/translations/localize";
@@ -76,7 +77,7 @@ class OnboardingRestoreBackupUpload extends LitElement {
this._error = undefined;
const file = ev.detail.files[0];
if (!file || file.type !== SUPPORTED_UPLOAD_FORMAT) {
if (!file || !isSupportedBackupFile(file)) {
showAlertDialog(this, {
title: this.localize(
"ui.panel.page-onboarding.restore.unsupported.title"
+38 -16
View File
@@ -32,7 +32,7 @@ interface AppPanelConfig {
}
// Time to wait for app to start before we ask the user if we should try again
const START_WAIT_TIME = 20000; // ms
const START_WAIT_TIME = 30000; // ms
const RETRY_START_WAIT_TIME = 5000; // ms
@customElement("ha-panel-app")
@@ -140,6 +140,12 @@ class HaPanelApp extends LitElement {
${ref(this._iframeRef)}
>
</iframe>
${!this._iframeLoaded
? html`<hass-loading-screen
class="loading-overlay"
.message=${this._loadingMessage}
></hass-loading-screen>`
: nothing}
`;
}
@@ -284,8 +290,6 @@ class HaPanelApp extends LitElement {
return;
}
this._loadingMessage = undefined;
if (this._fetchDataTimeout) {
clearTimeout(this._fetchDataTimeout);
this._fetchDataTimeout = undefined;
@@ -327,28 +331,32 @@ class HaPanelApp extends LitElement {
private async _checkLoaded(ev: Event): Promise<void> {
const iframe = ev.target as HTMLIFrameElement;
this._iframeLoaded = true;
if (
!this._addon ||
iframe.contentDocument?.body.textContent !== "502: Bad Gateway"
) {
return;
}
const is502 =
!!this._addon &&
iframe.contentDocument?.body.textContent === "502: Bad Gateway";
// Auto-retry if within the retry window
if (this._autoRetryUntil && Date.now() < this._autoRetryUntil) {
// While the app is still starting, reload the iframe silently behind the
// loading screen instead of revealing the error page and tearing down
// the panel.
if (is502 && this._autoRetryUntil && Date.now() < this._autoRetryUntil) {
this._reloadIframe();
return;
}
// Clear auto-retry window and show dialog
this._iframeLoaded = true;
if (!is502) {
return;
}
// Retry window elapsed, ask the user whether to keep waiting.
this._autoRetryUntil = undefined;
await this.updateComplete;
showConfirmationDialog(this, {
text: this.hass.localize("ui.panel.app.error_app_not_ready"),
title: this._addon.name,
title: this._addon!.name,
confirmText: this.hass.localize("ui.panel.app.retry"),
dismissText: this.hass.localize("ui.common.no"),
confirm: () => {
@@ -362,7 +370,7 @@ class HaPanelApp extends LitElement {
private async _reloadIframe(): Promise<void> {
const addonSlug = this._addon!.slug;
this._iframeLoaded = false;
this._addon = undefined;
this._loadingMessage = this.hass.localize("ui.panel.app.app_starting");
await Promise.all([
this.updateComplete,
new Promise((resolve) => {
@@ -370,7 +378,15 @@ class HaPanelApp extends LitElement {
}),
]);
// Guard for user navigating away during the delay
if (this._getAddonSlug() === addonSlug) {
if (this._getAddonSlug() !== addonSlug) {
return;
}
// Reload the iframe content in place so the loading screen stays up
// without rebuilding the panel.
const iframeWindow = this._iframeRef.value?.contentWindow;
if (iframeWindow) {
iframeWindow.location.reload();
} else {
this._fetchData(addonSlug);
}
}
@@ -434,6 +450,12 @@ class HaPanelApp extends LitElement {
:host {
display: block;
height: 100%;
position: relative;
}
hass-loading-screen.loading-overlay {
position: absolute;
inset: 0;
}
iframe {
@@ -2464,7 +2464,9 @@ class DialogAddAutomationElement
ha-automation-add-from-target,
.groups {
overflow: auto;
flex: 4;
/* Fixed-width left column so it does not resize as the right
panel's content width changes between groups. */
flex: 0 0 360px;
margin-inline-end: 0;
}
@@ -2500,7 +2502,8 @@ class DialogAddAutomationElement
}
ha-automation-add-items {
flex: 6;
flex: 1;
min-width: 0;
}
.content.column ha-automation-add-from-target,
@@ -375,7 +375,6 @@ export class HaAutomationAddSearch extends LitElement {
<state-badge
slot="start"
.stateObj=${(item as EntityComboBoxItem).stateObj}
.hass=${this.hass}
></state-badge>
`
: type === "device" && (item as DevicePickerItem).domain
@@ -493,7 +493,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
@click=${this._showHelp}
></ha-icon-button>
<ha-filter-floor-areas
.hass=${this.hass}
.type=${"automation"}
.value=${this._filters["ha-filter-floor-areas"]?.value}
@data-table-filter-changed=${this._filterChanged}
@@ -503,7 +502,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
@expanded-changed=${this._filterExpanded}
></ha-filter-floor-areas>
<ha-filter-devices
.hass=${this.hass}
.type=${"automation"}
.value=${this._filters["ha-filter-devices"]?.value}
@data-table-filter-changed=${this._filterChanged}
@@ -513,7 +511,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
@expanded-changed=${this._filterExpanded}
></ha-filter-devices>
<ha-filter-entities
.hass=${this.hass}
.type=${"automation"}
.value=${this._filters["ha-filter-entities"]?.value}
@data-table-filter-changed=${this._filterChanged}
@@ -523,7 +520,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
@expanded-changed=${this._filterExpanded}
></ha-filter-entities>
<ha-filter-labels
.hass=${this.hass}
.value=${this._filters["ha-filter-labels"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
@@ -542,7 +538,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
@expanded-changed=${this._filterExpanded}
></ha-filter-categories>
<ha-filter-voice-assistants
.hass=${this.hass}
.value=${this._filters["ha-filter-voice-assistants"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
@@ -320,6 +320,9 @@ export class HaAutomationTrace extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("triggers");
this.hass.loadBackendTranslation("conditions");
if (!this.automationId) {
return;
}
@@ -1,8 +1,10 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeStateDomain } from "../../../../../common/entity/compute_state_domain";
import { hasLocation } from "../../../../../common/entity/has_location";
import "../../../../../components/entity/ha-entities-picker";
import "../../../../../components/entity/ha-entity-picker";
import "../../../../../components/radio/ha-radio-group";
import type { HaRadioGroup } from "../../../../../components/radio/ha-radio-group";
@@ -27,7 +29,7 @@ export class HaZoneTrigger extends LitElement {
public static get defaultConfig(): ZoneTrigger {
return {
trigger: "zone",
entity_id: "",
entity_id: [],
zone: "",
event: "enter" as ZoneTrigger["event"],
};
@@ -36,16 +38,16 @@ export class HaZoneTrigger extends LitElement {
protected render() {
const { entity_id, zone, event } = this.trigger;
return html`
<ha-entity-picker
<ha-entities-picker
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.zone.entity"
)}
.value=${entity_id}
.value=${entity_id ? ensureArray(entity_id) : []}
.disabled=${this.disabled}
@value-changed=${this._entityPicked}
.hass=${this.hass}
.entityFilter=${zoneAndLocationFilter}
></ha-entity-picker>
></ha-entities-picker>
<ha-entity-picker
.label=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.zone.zone"
@@ -81,7 +83,7 @@ export class HaZoneTrigger extends LitElement {
`;
}
private _entityPicked(ev: ValueChangedEvent<string>) {
private _entityPicked(ev: ValueChangedEvent<string[]>) {
ev.stopPropagation();
fireEvent(this, "value-changed", {
value: { ...this.trigger, entity_id: ev.detail.value },
@@ -16,6 +16,7 @@ import {
CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT,
INITIAL_UPLOAD_FORM_DATA,
isSupportedBackupFile,
SUPPORTED_UPLOAD_FORMAT,
uploadBackup,
type BackupUploadFileFormData,
@@ -85,7 +86,6 @@ export class DialogUploadBackup
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-file-upload
.hass=${this.hass}
.uploading=${this._uploading}
.icon=${mdiFolderUpload}
.accept=${SUPPORTED_UPLOAD_FORMAT}
@@ -141,7 +141,7 @@ export class DialogUploadBackup
private async _upload() {
const { file } = this._formData!;
if (!file || file.type !== SUPPORTED_UPLOAD_FORMAT) {
if (!file || !isSupportedBackupFile(file)) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.backup.dialogs.upload.unsupported.title"
+44 -31
View File
@@ -69,6 +69,27 @@ export async function generateMetadataSuggestionTask<T>(
include.floor ? fetchFloors(connection) : Promise.resolve(undefined),
]);
// Offer the names (not the internal IDs) to the model. The model has no idea
// what an ID means, and processMetadataSuggestion maps the chosen name back
// to its ID.
const categoryOptions = categories
? Object.values(categories).map((name) => ({
value: name,
label: name,
}))
: [];
const floorOptions = floors
? Object.values(floors).map((floor) => ({
value: floor.name,
label: floor.name,
}))
: [];
// Only offer the select fields when there is at least one option. Some AI
// providers reject a select/enum schema with an empty options list.
const includeCategories = categoryOptions.length > 0;
const includeFloor = floorOptions.length > 0;
const structure: AITaskStructure = {
...(include.name && {
name: {
@@ -99,50 +120,42 @@ export async function generateMetadataSuggestionTask<T>(
},
},
}),
...(include.categories &&
categories && {
category: {
description: `The category of the ${domain}`,
required: false,
selector: {
select: {
options: Object.entries(categories).map(([id, name]) => ({
value: id,
label: name,
})),
},
...(includeCategories && {
category: {
description: `The category of the ${domain}`,
required: false,
selector: {
select: {
options: categoryOptions,
},
},
}),
...(include.floor &&
floors && {
floor: {
description: `The floor of the ${domain}`,
required: false,
selector: {
select: {
options: Object.values(floors).map((floor) => ({
value: floor.floor_id,
label: floor.name,
})),
},
},
}),
...(includeFloor && {
floor: {
description: `The floor of the ${domain}`,
required: false,
selector: {
select: {
options: floorOptions,
},
},
}),
},
}),
};
const requestedParts = [
include.name ? "a name" : null,
include.description ? "a description" : null,
include.categories ? "a category" : null,
includeCategories ? "a category" : null,
include.labels ? "labels" : null,
include.floor ? "a floor" : null,
includeFloor ? "a floor" : null,
].filter((entry): entry is string => entry !== null);
const categoryLabels: string[] = [
include.categories ? "category" : null,
includeCategories ? "category" : null,
include.labels ? "labels" : null,
include.floor ? "floor" : null,
includeFloor ? "floor" : null,
].filter((entry): entry is string => entry !== null);
const categoryLabelsText = PROMPT_LIST_FORMAT.format(categoryLabels);
@@ -168,7 +181,7 @@ export async function generateMetadataSuggestionTask<T>(
`The name should be in same style and sentence capitalization as existing ${domain}s.`,
]
: []),
...(include.categories || include.labels || include.floor
...(includeCategories || include.labels || includeFloor
? [
`Suggest ${categoryLabelsText} if relevant to the ${domain}'s purpose.`,
`Only suggest ${categoryLabelsText} that are already used by existing ${domain}s.`,
@@ -64,6 +64,10 @@ class HaPanelDevTemplate extends LitElement {
private _inited = false;
// Bumped on every (re)subscribe so a superseded render can be detected and
// its late-arriving results discarded.
private _subscribeRequestId = 0;
private _tipResizeObserver?: ResizeObserver;
public connectedCallback() {
@@ -502,8 +506,14 @@ ${type === "object"
}
private async _subscribeTemplate() {
const requestId = ++this._subscribeRequestId;
this._rendering = true;
await this._unsubscribeTemplate();
// A newer render started while we were unsubscribing; let it win so we do
// not leave a stale subscription running that overwrites the result.
if (requestId !== this._subscribeRequestId) {
return;
}
this._error = undefined;
this._errorLevel = undefined;
this._templateResult = undefined;
@@ -511,6 +521,10 @@ ${type === "object"
this._unsubRenderTemplate = subscribeRenderTemplate(
this.hass.connection,
(result) => {
// Ignore results from a render that has since been superseded.
if (requestId !== this._subscribeRequestId) {
return;
}
if ("error" in result) {
// We show the latest error, or a warning if there are no errors
if (result.level === "ERROR" || this._errorLevel !== "ERROR") {
@@ -171,10 +171,7 @@ export class DeveloperYamlConfig extends LitElement {
)}
</div>
<div class="card-actions">
<ha-call-service-button
.hass=${this.hass}
domain="homeassistant"
service="reload_all"
<ha-call-service-button domain="homeassistant" service="reload_all"
>${this.hass.localize(
"ui.panel.config.developer-tools.tabs.yaml.section.reloading.all"
)}
@@ -182,7 +179,6 @@ export class DeveloperYamlConfig extends LitElement {
</div>
<div class="card-actions">
<ha-call-service-button
.hass=${this.hass}
domain="homeassistant"
service="reload_core_config"
>${this.hass.localize(
@@ -194,7 +190,6 @@ export class DeveloperYamlConfig extends LitElement {
(reloadable) => html`
<div class="card-actions">
<ha-call-service-button
.hass=${this.hass}
.domain=${reloadable.domain}
service="reload"
>${reloadable.name}
@@ -842,7 +842,6 @@ export class HaConfigDeviceDashboard extends LitElement {
</ha-alert>`
: nothing}
<ha-filter-floor-areas
.hass=${this.hass}
type="device"
.value=${this._filters["ha-filter-floor-areas"]?.value}
@data-table-filter-changed=${this._filterChanged}
@@ -871,7 +870,6 @@ export class HaConfigDeviceDashboard extends LitElement {
@expanded-changed=${this._filterExpanded}
></ha-filter-states>
<ha-filter-labels
.hass=${this.hass}
.value=${this._filters["ha-filter-labels"]?.value}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
@@ -7,9 +7,8 @@ import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-select";
import type { HaSelectSelectEvent } from "../../../../components/ha-select";
import "../../../../components/input/ha-input";
import "./ha-energy-upstream-device-picker";
import type { HaInput } from "../../../../components/input/ha-input";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { energyStatisticHelpUrl } from "../../../../data/energy";
@@ -123,27 +122,6 @@ export class DialogEnergyDeviceSettingsWater
const pickableUnit = this._volume_units?.join(", ") || "";
const includedInDeviceOptions = !this._possibleParents.length
? [
{
value: "-",
disabled: true,
label: this.hass.localize(
"ui.panel.config.energy.device_consumption_water.dialog.no_upstream_devices"
),
},
]
: this._possibleParents.map((stat) => ({
value: stat.stat_consumption,
label:
stat.name ||
getStatisticLabel(
this.hass,
stat.stat_consumption,
this._params?.statsMetadata?.[stat.stat_consumption]
),
}));
return html`
<ha-dialog
.open=${this._open}
@@ -205,20 +183,23 @@ export class DialogEnergyDeviceSettingsWater
>
</ha-input>
<ha-select
<ha-energy-upstream-device-picker
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.energy.device_consumption_water.dialog.included_in_device"
)}
.value=${this._device?.included_in_stat || ""}
.helper=${this.hass.localize(
"ui.panel.config.energy.device_consumption_water.dialog.included_in_device_helper"
)}
.value=${this._device?.included_in_stat}
.possibleParents=${this._possibleParents}
.statsMetadata=${this._params.statsMetadata}
.emptyLabel=${this.hass.localize(
"ui.panel.config.energy.device_consumption_water.dialog.no_upstream_devices"
)}
.disabled=${!this._device}
@selected=${this._parentSelected}
clearable
.options=${includedInDeviceOptions}
>
</ha-select>
@value-changed=${this._parentChanged}
></ha-energy-upstream-device-picker>
<ha-dialog-footer slot="footer">
<ha-button
@@ -293,7 +274,7 @@ export class DialogEnergyDeviceSettingsWater
this._updateDirtyState(this._device);
}
private _parentSelected(ev: HaSelectSelectEvent<string, true>) {
private _parentChanged(ev: ValueChangedEvent<string>) {
const newDevice = {
...this._device!,
included_in_stat: ev.detail.value,
@@ -324,7 +305,7 @@ export class DialogEnergyDeviceSettingsWater
width: 100%;
margin-bottom: var(--ha-space-4);
}
ha-select {
ha-energy-upstream-device-picker {
display: block;
margin-top: var(--ha-space-4);
width: 100%;
@@ -7,12 +7,8 @@ import "../../../../components/entity/ha-statistic-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-select";
import type {
HaSelectOption,
HaSelectSelectEvent,
} from "../../../../components/ha-select";
import "../../../../components/input/ha-input";
import "./ha-energy-upstream-device-picker";
import type { HaInput } from "../../../../components/input/ha-input";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { energyStatisticHelpUrl } from "../../../../data/energy";
@@ -123,28 +119,6 @@ export class DialogEnergyDeviceSettings
return nothing;
}
const includedInDeviceOptions: HaSelectOption[] = this._possibleParents
.length
? this._possibleParents.map((stat) => ({
value: stat.stat_consumption,
label:
stat.name ||
getStatisticLabel(
this.hass,
stat.stat_consumption,
this._params?.statsMetadata?.[stat.stat_consumption]
),
}))
: [
{
value: "-",
disabled: true,
label: this.hass.localize(
"ui.panel.config.energy.device_consumption.dialog.no_upstream_devices"
),
},
];
return html`
<ha-dialog
.open=${this._open}
@@ -205,20 +179,23 @@ export class DialogEnergyDeviceSettings
>
</ha-input>
<ha-select
<ha-energy-upstream-device-picker
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.energy.device_consumption.dialog.included_in_device"
)}
.value=${this._device?.included_in_stat || ""}
.helper=${this.hass.localize(
"ui.panel.config.energy.device_consumption.dialog.included_in_device_helper"
)}
.value=${this._device?.included_in_stat}
.possibleParents=${this._possibleParents}
.statsMetadata=${this._params.statsMetadata}
.emptyLabel=${this.hass.localize(
"ui.panel.config.energy.device_consumption.dialog.no_upstream_devices"
)}
.disabled=${!this._device}
@selected=${this._parentSelected}
clearable
.options=${includedInDeviceOptions}
>
</ha-select>
@value-changed=${this._parentChanged}
></ha-energy-upstream-device-picker>
<ha-dialog-footer slot="footer">
<ha-button
@@ -293,7 +270,7 @@ export class DialogEnergyDeviceSettings
this._updateDirtyState(this._device);
}
private _parentSelected(ev: HaSelectSelectEvent<string, true>) {
private _parentChanged(ev: ValueChangedEvent<string>) {
const newDevice = {
...this._device!,
included_in_stat: ev.detail.value,
@@ -326,7 +303,7 @@ export class DialogEnergyDeviceSettings
ha-statistic-picker {
width: 100%;
}
ha-select {
ha-energy-upstream-device-picker {
display: block;
margin-top: var(--ha-space-4);
width: 100%;
@@ -0,0 +1,229 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiChartLine, mdiShape } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeRTL } from "../../../../common/util/compute_rtl";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-combo-box-item";
import "../../../../components/ha-generic-picker";
import type { PickerComboBoxItem } from "../../../../components/ha-picker-combo-box";
import type { PickerValueRenderer } from "../../../../components/ha-picker-field";
import "../../../../components/ha-svg-icon";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { domainToName } from "../../../../data/integration";
import {
getStatisticLabel,
type StatisticsMetaData,
} from "../../../../data/recorder";
import type { HomeAssistant, ValueChangedEvent } from "../../../../types";
interface UpstreamDeviceComboBoxItem extends PickerComboBoxItem {
stateObj?: HassEntity;
}
const SEARCH_KEYS = [
{ name: "primary", weight: 10 },
{ name: "search_labels.entityName", weight: 10 },
{ name: "search_labels.friendlyName", weight: 9 },
{ name: "search_labels.deviceName", weight: 8 },
{ name: "search_labels.areaName", weight: 6 },
{ name: "search_labels.domainName", weight: 4 },
{ name: "id", weight: 2 },
];
@customElement("ha-energy-upstream-device-picker")
export class HaEnergyUpstreamDevicePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public value?: string;
@property() public label?: string;
@property() public helper?: string;
@property({ attribute: "empty-label" }) public emptyLabel?: string;
@property({ type: Boolean }) public disabled = false;
@property({ attribute: false })
public possibleParents: DeviceConsumptionEnergyPreference[] = [];
@property({ attribute: false })
public statsMetadata?: Record<string, StatisticsMetaData>;
private _computeItem = (statisticId: string): UpstreamDeviceComboBoxItem => {
// Use the energy config's custom display name when the user has set one.
const name = this.possibleParents.find(
(parent) => parent.stat_consumption === statisticId
)?.name;
const stateObj = this.hass.states[statisticId];
if (stateObj) {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const friendlyName = computeStateName(stateObj); // Keep this for search
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return {
id: statisticId,
primary: name || entityName || deviceName || statisticId,
secondary,
stateObj,
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
areaName: areaName || null,
friendlyName,
},
};
}
const label = getStatisticLabel(
this.hass,
statisticId,
this.statsMetadata?.[statisticId]
);
const isExternal = statisticId.includes(":") && !statisticId.includes(".");
if (isExternal) {
const domainName = domainToName(
this.hass.localize,
statisticId.split(":")[0]
);
return {
id: statisticId,
primary: name || label,
secondary: domainName,
icon_path: mdiChartLine,
search_labels: { label, domainName },
};
}
return {
id: statisticId,
primary: name || label,
secondary: this.hass.localize("ui.components.statistic-picker.no_state"),
icon_path: mdiShape,
search_labels: { label },
};
};
private _items = memoizeOne(
(
_hass: HomeAssistant,
_value: string | undefined,
_possibleParents: DeviceConsumptionEnergyPreference[],
_statsMetadata?: Record<string, StatisticsMetaData>
): UpstreamDeviceComboBoxItem[] => {
const items = this.possibleParents.map((parent) =>
this._computeItem(parent.stat_consumption)
);
// Make sure the current value is selectable even if it is no longer
// part of the possible parents, so it doesn't render as unknown.
if (this.value && !items.some((item) => item.id === this.value)) {
items.unshift(this._computeItem(this.value));
}
return items;
}
);
private _getItems = () =>
this._items(
this.hass,
this.value,
this.possibleParents,
this.statsMetadata
);
private _renderItem = (item: UpstreamDeviceComboBoxItem) => html`
${item.stateObj
? html`
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`
: item.icon_path
? html`
<ha-svg-icon
style="margin: 0 4px"
slot="start"
.path=${item.icon_path}
></ha-svg-icon>
`
: nothing}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
`;
private _rowRenderer: RenderItemFunction<UpstreamDeviceComboBoxItem> = (
item,
index
) => html`
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
${this._renderItem(item)}
</ha-combo-box-item>
`;
private _valueRenderer: PickerValueRenderer = (value) =>
this._renderItem(
this._getItems().find((item) => item.id === value) ??
this._computeItem(value)
);
protected render() {
return html`
<ha-generic-picker
.hass=${this.hass}
use-top-label
.label=${this.label}
.helper=${this.helper}
.value=${this.value}
.disabled=${this.disabled}
.emptyLabel=${this.emptyLabel}
.getItems=${this._getItems}
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer}
.searchKeys=${SEARCH_KEYS}
@value-changed=${this._valueChanged}
></ha-generic-picker>
`;
}
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const value = ev.detail.value;
this.value = value;
fireEvent(this, "value-changed", { value });
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-energy-upstream-device-picker": HaEnergyUpstreamDevicePicker;
}
}
@@ -985,7 +985,6 @@ export class HaConfigEntities extends LitElement {
</ha-alert>`
: nothing}
<ha-filter-floor-areas
.hass=${this.hass}
type="entity"
.value=${this._filters["ha-filter-floor-areas"]}
@data-table-filter-changed=${this._filterChanged}
@@ -995,7 +994,6 @@ export class HaConfigEntities extends LitElement {
@expanded-changed=${this._filterExpanded}
></ha-filter-floor-areas>
<ha-filter-devices
.hass=${this.hass}
.type=${"entity"}
.value=${this._filters["ha-filter-devices"]}
@data-table-filter-changed=${this._filterChanged}
@@ -1005,7 +1003,6 @@ export class HaConfigEntities extends LitElement {
@expanded-changed=${this._filterExpanded}
></ha-filter-devices>
<ha-filter-domains
.hass=${this.hass}
.value=${this._filters["ha-filter-domains"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
@@ -1035,7 +1032,6 @@ export class HaConfigEntities extends LitElement {
@expanded-changed=${this._filterExpanded}
></ha-filter-states>
<ha-filter-labels
.hass=${this.hass}
.value=${this._filters["ha-filter-labels"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
@@ -1044,7 +1040,6 @@ export class HaConfigEntities extends LitElement {
@expanded-changed=${this._filterExpanded}
></ha-filter-labels>
<ha-filter-voice-assistants
.hass=${this.hass}
.value=${this._filters["ha-filter-voice-assistants"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
@@ -669,7 +669,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
class=${this.narrow ? "narrow" : ""}
>
<ha-filter-floor-areas
.hass=${this.hass}
.type=${"entity"}
.value=${this._filters["ha-filter-floor-areas"]}
@data-table-filter-changed=${this._filterChanged}
@@ -679,7 +678,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
@expanded-changed=${this._filterExpanded}
></ha-filter-floor-areas>
<ha-filter-devices
.hass=${this.hass}
.type=${"entity"}
.value=${this._filters["ha-filter-devices"]}
@data-table-filter-changed=${this._filterChanged}
@@ -689,7 +687,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
@expanded-changed=${this._filterExpanded}
></ha-filter-devices>
<ha-filter-labels
.hass=${this.hass}
.value=${this._filters["ha-filter-labels"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
@@ -708,7 +705,6 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
@expanded-changed=${this._filterExpanded}
></ha-filter-categories>
<ha-filter-voice-assistants
.hass=${this.hass}
.value=${this._filters["ha-filter-voice-assistants"]}
@data-table-filter-changed=${this._filterChanged}
slot="filter-pane"
@@ -109,7 +109,6 @@ export class ZHAClusterAttributes extends LitElement {
</div>
<div class="card-actions">
<ha-call-service-button
.hass=${this.hass}
domain="zha"
service="set_zigbee_cluster_attribute"
.data=${this._setAttributeServiceData}
@@ -96,7 +96,6 @@ export class ZHAClusterCommands extends LitElement {
</div>
<div class="card-actions">
<ha-call-service-button
.hass=${this.hass}
domain="zha"
service="issue_zigbee_cluster_command"
.data=${this._issueClusterCommandServiceData}
@@ -91,7 +91,6 @@ class ZHADeviceCard extends SubscribeMixin(LitElement) {
<state-badge
@click=${this._openMoreInfo}
.title=${entity.stateName!}
.hass=${this.hass}
.stateObj=${this.hass!.states[entity.entity_id]}
slot="item-icon"
></state-badge>
@@ -100,6 +100,8 @@ class DialogZWaveJSAddNode extends LitElement {
@state() private _lowSecurityReason?: number;
@state() private _interviewProgress?: number;
@state() private _device?: ZWaveJSAddNodeDevice;
@state() private _deviceOptions?: ZWaveJSAddNodeSmartStartOptions;
@@ -339,6 +341,10 @@ class DialogZWaveJSAddNode extends LitElement {
) {
return html`
<zwave-js-add-node-loading
.hass=${this.hass}
.progress=${this._step === "interviewing"
? this._interviewProgress
: undefined}
.description=${this.hass.localize(
`ui.panel.config.zwave_js.add_node.${this._step !== "rename_device" ? "getting_device_information" : "saving_device"}`
)}
@@ -379,6 +385,7 @@ class DialogZWaveJSAddNode extends LitElement {
}
return html`<zwave-js-add-node-loading
.hass=${this.hass}
.delay=${1000}
></zwave-js-add-node-loading>`;
}
@@ -703,9 +710,13 @@ class DialogZWaveJSAddNode extends LitElement {
break;
case "node added":
this._step = "interviewing";
this._interviewProgress = undefined;
this._lowSecurity = message.node.low_security;
this._lowSecurityReason = message.node.low_security_reason;
break;
case "interview progress":
this._interviewProgress = message.progress;
break;
case "interview completed":
this._unsubscribeAddZwaveNode();
this._step = "configure_device";
@@ -1081,6 +1092,7 @@ class DialogZWaveJSAddNode extends LitElement {
this._dskPin = "";
this._lowSecurity = false;
this._lowSecurityReason = undefined;
this._interviewProgress = undefined;
this._inclusionStrategy = undefined;
if (this._addNodeTimeoutHandle) {
@@ -1,21 +1,36 @@
import { customElement, property } from "lit/decorators";
import { css, html, LitElement, nothing } from "lit";
import { blankBeforePercent } from "../../../../../../common/translations/blank_before_percent";
import "../../../../../../components/animation/ha-fade-in";
import "../../../../../../components/ha-spinner";
import "../../../../../../components/progress/ha-progress-ring";
import { WakeLockMixin } from "../../../../../../mixins/wakelock-mixin";
import type { HomeAssistant } from "../../../../../../types";
@customElement("zwave-js-add-node-loading")
export class ZWaveJsAddNodeLoading extends WakeLockMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public description?: string;
@property({ type: Number }) public progress?: number;
@property({ type: Number }) public delay = 0;
render() {
return html`
<ha-fade-in .delay=${this.delay}>
<div class="loading">
<ha-spinner size="large"></ha-spinner>
${this.progress !== undefined
? html`
<ha-progress-ring size="large" .value=${this.progress}>
${Math.round(this.progress)}${blankBeforePercent(
this.hass.locale
)}%
</ha-progress-ring>
`
: html`<ha-spinner size="large"></ha-spinner>`}
</div>
${this.description ? html`<p>${this.description}</p>` : nothing}
</ha-fade-in>
@@ -4,10 +4,12 @@ import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { blankBeforePercent } from "../../../../../common/translations/blank_before_percent";
import "../../../../../components/ha-spinner";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-dialog";
import "../../../../../components/progress/ha-progress-ring";
import { reinterviewZwaveNode } from "../../../../../data/zwave_js";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
@@ -21,8 +23,12 @@ class DialogZWaveJSReinterviewNode extends LitElement {
@state() private _status?: string;
// Completed interview stages for rendering checklists
// Can be removed once min schema is bumped to 50
@state() private _stages?: string[];
@state() private _progress?: number;
@state() private _open = false;
private _subscribed?: Promise<UnsubscribeFunc>;
@@ -31,6 +37,7 @@ class DialogZWaveJSReinterviewNode extends LitElement {
params: ZWaveJSReinterviewNodeDialogParams
): Promise<void> {
this._stages = undefined;
this._progress = undefined;
this.device_id = params.device_id;
this._open = true;
}
@@ -67,7 +74,21 @@ class DialogZWaveJSReinterviewNode extends LitElement {
${this._status === "started"
? html`
<div class="flex-container">
<ha-spinner></ha-spinner>
${this._progress !== undefined
? html`
<ha-progress-ring
size="large"
.value=${this._progress}
aria-label=${this.hass.localize(
"ui.panel.config.zwave_js.reinterview_node.in_progress"
)}
>
${Math.round(this._progress)}${blankBeforePercent(
this.hass.locale
)}%
</ha-progress-ring>
`
: html`<ha-spinner></ha-spinner>`}
<div class="status">
<p>
<b>
@@ -119,7 +140,7 @@ class DialogZWaveJSReinterviewNode extends LitElement {
</div>
`
: ``}
${this._stages
${this._progress === undefined && this._stages
? html`
<div class="stages">
${this._stages.map(
@@ -173,7 +194,16 @@ class DialogZWaveJSReinterviewNode extends LitElement {
if (message.event === "interview started") {
this._status = "started";
}
if (message.event === "interview stage completed") {
if (message.event === "interview progress") {
this._status = "started";
this._progress = message.progress;
}
// If upstream supports granular progress reporting,
// ignore the legacy per-stage events that drive the stage checklist.
if (
message.event === "interview stage completed" &&
this._progress === undefined
) {
if (this._stages === undefined) {
this._stages = [message.stage];
} else {
@@ -205,6 +235,7 @@ class DialogZWaveJSReinterviewNode extends LitElement {
this.device_id = undefined;
this._status = undefined;
this._stages = undefined;
this._progress = undefined;
this._unsubscribe();
@@ -246,6 +277,7 @@ class DialogZWaveJSReinterviewNode extends LitElement {
}
.flex-container ha-spinner,
.flex-container ha-progress-ring,
.flex-container ha-svg-icon {
margin-right: 20px;
margin-inline-end: 20px;
@@ -130,7 +130,6 @@ class DialogZWaveJSUpdateFirmwareNode extends DirtyStateProviderMixin<FirmwareFo
}
const beginFirmwareUpdateHTML = html`<ha-file-upload
.hass=${this.hass}
.uploading=${this._uploading}
.icon=${mdiFileUpload}
.label=${this.hass.localize(
@@ -135,9 +135,11 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
const offlineDevices = nodes.filter(
(node) => node.status === NodeStatus.Dead
).length;
const notReadyDevices =
nodes.filter((node) => !node.ready && node.status !== NodeStatus.Dead)
.length + provisioningDevices;
// Not-ready nodes are included but their interview has not completed yet.
// They are distinct from the provisioning entries, which are not included.
const notReadyDevices = nodes.filter(
(node) => !node.ready && node.status !== NodeStatus.Dead
).length;
return html`
<hass-subpage
@@ -201,11 +203,18 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
}
if (notReadyDevices > 0) {
statusParts.push(
this.hass.localize("ui.panel.config.zwave_js.dashboard.not_included", {
this.hass.localize("ui.panel.config.zwave_js.dashboard.not_ready", {
count: notReadyDevices,
})
);
}
if (provisioningDevices > 0) {
statusParts.push(
this.hass.localize("ui.panel.config.zwave_js.dashboard.not_included", {
count: provisioningDevices,
})
);
}
return html`
<ha-card class="content network-status">
<div class="card-content">
@@ -121,8 +121,7 @@ class ZWaveJSNodeConfig extends LitElement {
.header=${this.hass.localize(
"ui.panel.config.zwave_js.node_config.header"
)}
back-path="/config/zwave_js/dashboard?config_entry=${this
.configEntryId}"
back-path="/config/devices/device/${this.deviceId}"
>
<ha-config-section
.narrow=${this.narrow}
+1 -4
View File
@@ -190,10 +190,7 @@ export class SystemLogCard extends LitElement {
>`}
<div class="card-actions">
<ha-call-service-button
.hass=${this.hass}
domain="system_log"
service="clear"
<ha-call-service-button domain="system_log" service="clear"
>${this.hass.localize(
"ui.panel.config.logs.clear"
)}</ha-call-service-button

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