Compare commits

...

84 Commits

Author SHA1 Message Date
Aidan Timson 85e7a7c7af Switch to lazy context and simplify rendering, reduce unnessasary memoisation 2026-04-28 15:08:56 +01:00
Aidan Timson fa7341a473 Merge reused locale logic 2026-04-28 14:02:35 +01:00
Aidan Timson b3d935bde2 Typing 2026-04-28 13:55:06 +01:00
Aidan Timson 929fd51f47 Merge remote-tracking branch 'origin/dev' into clock-date-2
# Conflicts:
#	src/panels/lovelace/cards/clock/hui-clock-card-analog.ts
#	src/panels/lovelace/cards/clock/hui-clock-card-digital.ts
2026-04-28 13:50:33 +01:00
Aidan Timson 36031c7365 Fix capitalisation of integration on system log detail (#51762) 2026-04-28 13:27:18 +02:00
Paulus Schoutsen f99d95232a Show all the power buttons when media player is in assumed state (#51740) 2026-04-28 11:26:06 +02:00
Petar Petrov 5b4c08ad04 Add color and current temperature options to forecast features (#51761) 2026-04-28 11:20:31 +02:00
Petar Petrov b0b2d84287 Add precipitation visualization to forecast tile features (#51733) 2026-04-28 08:28:15 +01:00
Wendelin 0a43c29fea Simplify and fix target entity count (#51739)
* Simplify and fix target entity count

* Review

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
2026-04-28 06:31:36 +00:00
arcsur 61a8df1fa9 Modified the chart legend for climate temperature data sets (#51719)
* Modified the chart legend for climate temperature data sets to display the temperature value, not the overall entity state (previously hvac_mode).

* Use the helper computeAttributeValueDisplay in src/common/entity/compute_attribute_display.ts to format the temperature attribute values.

Co-authored-by: Copilot <copilot@github.com>

* Update src/components/chart/state-history-chart-line.ts

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-28 06:29:45 +00:00
mwolter805 ba3b335f47 Fix ha-panel-custom not restoring after suspendWhenHidden disconnect (#51727)
* fix: rebuild ha-panel-custom on reconnect after suspendWhenHidden

Why:
HA's PartialPanelResolver._onHidden() starts a 5-minute timer when a tab
is backgrounded. When it fires, non-iframe / non-app custom panels
(component_name="custom" without embed_iframe) are removed from the DOM,
which triggers ha-panel-custom.disconnectedCallback() -> _cleanupPanel().
That nulls _setProperties and destroys the child custom-panel element.

When the user returns, _onVisible() re-appends the same <ha-panel-custom>
element. ReactiveElement's base connectedCallback schedules an update,
but update() only calls _createPanel(this.panel) when
changedProps.has("panel") && !deepEqual(oldPanel, this.panel) -- the
panel reference hasn't changed, so nothing happens. _setProperties is
also undefined, so the property-forwarding branch exits early too.
Result: <ha-panel-custom> is present in the DOM but empty -- the user
sees a blank panel until they hard-reload.

Add a connectedCallback() override that rebuilds when the element was
previously cleaned up. Three guards keep it scoped to the recovery path:
  - !this._setProperties: sentinel for "_cleanupPanel ran".
    _setProperties is set inside _createPanel's success paths and only
    nulled in _cleanupPanel.
  - !this.hasChildNodes(): defends against the async window inside
    loadCustomPanel(config).then(...) for non-iframe panels, where a
    rapid detach->attach cycle could otherwise call _createPanel twice
    and append duplicate elements.
  - this.panel: skips first-mount, when the router hasn't assigned a
    panel yet. The existing update() path handles initial _createPanel
    via the changedProps.has("panel") branch.

Mirrors the existing disconnectedCallback override for symmetry.
10 lines + comment, no other file changes.

Affects every non-iframe sidebar custom panel in the HACS ecosystem
(Alarmo, Browser Mod, Homematic(IP) Local, MeshCore, others -- ~60k
combined HA-analytics installs). Reopens dormant home-assistant/frontend
issue #14510 (filed 2022-12-02, stale-bot closed without a fix).

Tests:
- yarn build succeeds locally; the affected chunk is captured for the
  pre-PR local-test on a real HA host.
- Manual repro on user's HA host (10.10.21.221) with the MeshCore
  custom panel: baseline (no patch) reproduces the blank-panel symptom
  after a 6-minute backgrounded tab. Patched chunk: panel rebuilds
  within ~2 seconds of returning. Exemption paths (iframe panel, app
  panel, custom panel with embed_iframe=true) verified to remain
  unaffected. Original chunk restored after testing.
- yarn lint and yarn test pass before PR submission (run before
  Phase 9).

* chore: trigger CLA recheck after author-email link
2026-04-28 08:34:00 +03:00
Wendelin 5449f31162 Fix target picker secondary entities support (#51729)
Add support for secondary entity filtering in target picker
2026-04-28 08:25:41 +03:00
Aidan Timson 1a992aa5f7 Change media browser player to generic picker component (#51734)
* Change media browser player to generic picker component

* Add disabled item support

* Use disabled items

* Sort as original

* Fix divider styling

* Cleanup

Co-authored-by: Copilot <copilot@github.com>

* Remove memo

Co-authored-by: Copilot <copilot@github.com>

* Typing

Co-authored-by: Copilot <copilot@github.com>

* Dont allow disabled items

---------

Co-authored-by: Copilot <copilot@github.com>
2026-04-28 08:24:29 +03:00
ildar170975 6303affe17 Dialogs: call "enlarge" on a wider area (#51750)
* increase an active "enlarge" area

Added a class to make the title span enlargeable.

* increase an active "enlarge" area

* increase an active "enlarge" area
2026-04-28 08:18:30 +03:00
karwosts fc0ac85223 More detailed trace selector via generic-picker (#51752) 2026-04-28 08:14:32 +03:00
dependabot[bot] 2efc1a658f Bump brace-expansion from 1.1.12 to 1.1.14 (#51753)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.12 to 1.1.14.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.14)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 08:13:32 +03:00
renovate[bot] 371642ef3f Update formatjs monorepo (#51747)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-28 06:54:17 +02:00
Wendelin aff9ec4345 Improve debug callWs logs (#51741) 2026-04-27 17:36:58 +02:00
Paul Bottein 80e37e7136 Use current entity in state card conditions (#51708) 2026-04-27 14:59:05 +01:00
Yosi Levy 54a234debd Toast location RTL fix (#51735) 2026-04-27 14:04:33 +01:00
Wendelin eeb0fb3e4d Automation editor: Fix no target set in some actions (#51642)
Refactor target extraction in HaAutomationActionRow for improved legacy support
2026-04-27 14:45:34 +03:00
JLo d5dc40fa1f Add entity context to media browser player picker (#51732)
* Add entity context to media browser player picker

Show area and device as a secondary line in both the active player pill
and the player picker dropdown, so users can identify speakers when
multiple players share similar names.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Update src/panels/media-browser/ha-bar-media-player.ts

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-04-27 10:32:39 +00:00
Paul Bottein df12232e93 Add attribute support for numeric state and state (#51706) 2026-04-27 10:11:25 +01:00
Wendelin 1a2e63a5ba Add floor/area: Replace popover with dropdown (#51730) 2026-04-27 08:43:05 +01:00
karwosts 581ba23f4e Stabilize more-info group rendering (#51725) 2026-04-27 08:20:23 +03:00
renovate[bot] 5dda82a8ac Update dependency terser-webpack-plugin to v5.5.0 (#51728)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-27 05:53:31 +02:00
Petar Petrov 5a296fadec Minimize Sankey flow crossings with barycenter sort (#51682)
* Minimize Sankey flow crossings with barycenter sort

* Cache section id-index maps across barycenter sweeps

* Thread graph edges through to sortNodesInSections
2026-04-26 21:42:26 +02:00
dependabot[bot] 5dc99a3dbd Bump actions/setup-node from 6.3.0 to 6.4.0 (#51722)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.3.0 to 6.4.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/53b83947a5a98c8d113130e565377fae1a50d02f...48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 13:53:02 +02:00
dependabot[bot] 2f36c64a21 Bump github/codeql-action from 4.35.1 to 4.35.2 (#51721)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.1 to 4.35.2.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/c10b8064de6f491fea524254123dbe5e09572f13...95e58e9a2cdfd71adc6e0353d5c52f41a045d225)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 13:52:59 +02:00
dependabot[bot] 59cfc82e7a Bump actions/cache from 5.0.4 to 5.0.5 (#51720)
Bumps [actions/cache](https://github.com/actions/cache) from 5.0.4 to 5.0.5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/668228422ae6a00e4ad889ee87cd7109ec5666a7...27d5ce7f107fe9357f9df03efb73ab90386fccae)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: 5.0.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-26 13:52:56 +02:00
Paul Bottein edc36b28a6 Refactor home panel editor (#51701) 2026-04-25 15:48:12 +02:00
Logan Rosen 7b58162f81 Drop eslint-config-airbnb-base and cherry-pick rules (#51627)
Remove the unmaintained eslint-config-airbnb-base dependency (last
updated Nov 2021, no flat config support) along with its FlatCompat
shim infrastructure.

Replace with js.configs.recommended as the base config and explicitly
cherry-pick ~40 high-value safety and style rules from airbnb-base
that aren't already covered by other configs.

Remove 27 rule disables that only existed to suppress airbnb opinions,
and 5 dead TypeScript rule disables for rules no longer in the config.

Fix 4 real bugs caught by the newly added no-constant-binary-expression
rule where template literals were always truthy, making fallback values
unreachable.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-25 14:16:53 +03:00
dependabot[bot] 9e9f247c79 Bump vite from 8.0.2 to 8.0.9 (#51707)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-04-24 21:16:28 +02:00
Paulus Schoutsen 8139b60248 Add radio_frequency domain entity platform (#51693)
Co-authored-by: balloob <1444314+balloob@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-04-24 15:34:08 +01:00
Wendelin 386372ad00 Use control switch for entity toggle (#51654) 2026-04-24 16:12:33 +02:00
Petar Petrov 9151b200a1 Add energy grid balance card (#51480)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-04-24 14:04:15 +02:00
renovate[bot] f449c6c1c1 Update dependency @codemirror/search to v6.7.0 (#51704)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-24 14:47:13 +03:00
renovate[bot] d1eb3fd162 Update vitest monorepo to v4.1.5 (#51703)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-24 14:46:32 +03:00
Aidan Timson b5d61d4041 Format 2026-03-24 10:50:30 +00:00
Aidan Timson 79780b111c Remove unnecessary copy
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-24 10:50:30 +00:00
Aidan Timson a592a5f222 Dont force ltr 2026-03-24 10:50:30 +00:00
Aidan Timson 2ac8bf9179 Cache date formatting 2026-03-24 10:50:30 +00:00
Aidan Timson fd21dd2fd4 Reflect property for style usage 2026-03-24 10:50:30 +00:00
Aidan Timson 370ccd95da Remove JS tick for analog clock 2026-03-24 10:50:30 +00:00
Aidan Timson ed2161effd Inline 2026-03-24 10:50:30 +00:00
Aidan Timson 19d902afc6 Improve typing 2026-03-24 10:50:30 +00:00
Aidan Timson 672d235c3e Remove modern/legacy support diff 2026-03-24 10:50:30 +00:00
Aidan Timson 5246ce3f72 Fix 2026-03-24 10:50:29 +00:00
Aidan Timson c83924efa7 Refactor 2026-03-24 10:50:29 +00:00
Aidan Timson bdbcec4d90 Update description 2026-03-24 10:50:29 +00:00
Aidan Timson a074c80ec3 Fix value render bug 2026-03-24 10:50:29 +00:00
Aidan Timson 3795ad1253 Cleanup 2026-03-24 10:50:29 +00:00
Aidan Timson 7bb466a75b Remove conditional render on seperators 2026-03-24 10:50:29 +00:00
Aidan Timson bff2514eed Format 2026-03-24 10:50:29 +00:00
Aidan Timson 602d41b31d Move section 2026-03-24 10:50:29 +00:00
Aidan Timson 85d10cf982 Fix 1st value as array 2026-03-24 10:50:29 +00:00
Aidan Timson a3ff3346db Split date and clock to avoid layout shifts 2026-03-24 10:50:29 +00:00
Aidan Timson 38a314ced4 Margin 2026-03-24 10:50:29 +00:00
Aidan Timson 2cf7452ed1 Reduce line height 2026-03-24 10:50:29 +00:00
Aidan Timson ae97cc1c8d Use singular section name 2026-03-24 10:50:29 +00:00
Aidan Timson 65bba30266 Add new line seperator 2026-03-24 10:50:29 +00:00
Aidan Timson 8ee3544a32 Swap section in picker values 2026-03-24 10:50:29 +00:00
Aidan Timson fcddf8f548 Rename 2026-03-24 10:50:29 +00:00
Aidan Timson c7824d4059 Section in picker values 2026-03-24 10:50:29 +00:00
Aidan Timson 8c4f5206b1 Preview in secondary 2026-03-24 10:50:29 +00:00
Aidan Timson cc2a7972fc Remove section from labels 2026-03-24 10:50:29 +00:00
Aidan Timson 33079bb12c Group sections 2026-03-24 10:50:29 +00:00
Aidan Timson 34152e522e Date formatter C 2026-03-24 10:50:29 +00:00
Aidan Timson a0dc331056 Date formatter B 2026-03-24 10:47:32 +00:00
Aidan Timson 4a56c1404f Date formatter A 2026-03-24 10:47:32 +00:00
Aidan Timson 7e7845853d Use local date var 2026-03-24 10:47:32 +00:00
Aidan Timson f8fe7a7d82 Cleanup old translation 2026-03-24 10:47:32 +00:00
Aidan Timson 8b40b55324 Scale small based on date length 2026-03-24 10:47:32 +00:00
Aidan Timson ab55d1fdde Sizing (CSS Impl) 2026-03-24 10:47:32 +00:00
Aidan Timson 597099f153 Sizing (JS Impl) 2026-03-24 10:47:32 +00:00
Aidan Timson 40ba2ade58 Format 2026-03-24 10:47:32 +00:00
Aidan Timson 901fa4cdda Add 2026-03-24 10:47:32 +00:00
Aidan Timson edf007718a Improve 2026-03-24 10:47:32 +00:00
Aidan Timson 5abaeea1f9 Type 2026-03-24 10:47:32 +00:00
Aidan Timson 1ce0a7eab2 Setup 2026-03-24 10:47:32 +00:00
Aidan Timson c0c02eb548 Match 2026-03-24 10:47:32 +00:00
Aidan Timson 18d5b84a02 Setup analog clock 2026-03-24 10:47:32 +00:00
Aidan Timson ebc58f025a Add date to digital clock 2026-03-24 10:47:32 +00:00
Aidan Timson cb2758d868 Setup 2026-03-24 10:47:32 +00:00
144 changed files with 6111 additions and 2037 deletions
+2 -2
View File
@@ -30,7 +30,7 @@ jobs:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -66,7 +66,7 @@ jobs:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
+4 -4
View File
@@ -31,7 +31,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -42,7 +42,7 @@ jobs:
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
- name: Setup lint cache
uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
node_modules/.cache/prettier
@@ -67,7 +67,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -87,7 +87,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
+3 -3
View File
@@ -41,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -62,4 +62,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
+2 -2
View File
@@ -31,7 +31,7 @@ jobs:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -67,7 +67,7 @@ jobs:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
+1 -1
View File
@@ -29,7 +29,7 @@ jobs:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
+2 -2
View File
@@ -39,7 +39,7 @@ jobs:
uses: home-assistant/actions/helpers/verify-version@f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b # master
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
@@ -104,7 +104,7 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
- name: Install dependencies
-3
View File
@@ -15,7 +15,6 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const generateMeanStatistics = (
start: Date,
end: Date,
// eslint-disable-next-line default-param-last
period: "5minute" | "hour" | "day" | "month" = "hour",
maxDiff: number
): StatisticValue[] => {
@@ -49,7 +48,6 @@ const generateMeanStatistics = (
const generateSumStatistics = (
start: Date,
end: Date,
// eslint-disable-next-line default-param-last
period: "5minute" | "hour" | "day" | "month" = "hour",
initValue: number,
maxDiff: number
@@ -86,7 +84,6 @@ const generateSumStatistics = (
const generateCurvedStatistics = (
start: Date,
end: Date,
// eslint-disable-next-line default-param-last
_period: "5minute" | "hour" | "day" | "month" = "hour",
initValue: number,
maxDiff: number,
+53 -64
View File
@@ -2,10 +2,7 @@
import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals";
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc";
import tseslint from "typescript-eslint";
import eslintConfigPrettier from "eslint-config-prettier";
import { configs as litConfigs } from "eslint-plugin-lit";
@@ -14,35 +11,8 @@ import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
import html from "@html-eslint/eslint-plugin";
import importX from "eslint-plugin-import-x";
const _filename = fileURLToPath(import.meta.url);
const _dirname = path.dirname(_filename);
const compat = new FlatCompat({
baseDirectory: _dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
// Load airbnb-base via FlatCompat for non-import rules only.
// eslint-plugin-import is incompatible with ESLint 10 (uses removed APIs),
// so we strip its plugin/rules/settings and use eslint-plugin-import-x instead.
const airbnbConfigs = compat.extends("airbnb-base").map((config) => {
const { plugins = {}, rules = {}, settings = {}, ...rest } = config;
return {
...rest,
plugins: Object.fromEntries(
Object.entries(plugins).filter(([key]) => key !== "import")
),
rules: Object.fromEntries(
Object.entries(rules).filter(([key]) => !key.startsWith("import/"))
),
settings: Object.fromEntries(
Object.entries(settings).filter(([key]) => !key.startsWith("import/"))
),
};
});
export default tseslint.config(
...airbnbConfigs,
js.configs.recommended,
eslintConfigPrettier,
litConfigs["flat/all"],
tseslint.configs.recommended,
@@ -86,35 +56,59 @@ export default tseslint.config(
},
rules: {
"class-methods-use-this": "off",
"new-cap": "off",
"prefer-template": "off",
"object-shorthand": "off",
"func-names": "off",
"no-underscore-dangle": "off",
strict: "off",
"no-plusplus": "off",
"no-bitwise": "error",
"comma-dangle": "off",
"vars-on-top": "off",
"no-continue": "off",
"no-param-reassign": "off",
"no-multi-assign": "off",
"no-console": "error",
radix: "off",
"no-alert": "off",
"no-nested-ternary": "off",
"prefer-destructuring": "off",
"no-restricted-globals": [2, "event"],
"prefer-promise-reject-errors": "off",
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"object-curly-newline": "off",
"default-case": "off",
"wc/no-self-class": "off",
"no-shadow": "off",
"no-use-before-define": "off",
"array-callback-return": ["error", { allowImplicit: true }],
"block-scoped-var": "error",
"consistent-return": "error",
curly: ["error", "multi-line"],
"default-case-last": "error",
eqeqeq: ["error", "always", { null: "ignore" }],
"guard-for-in": "error",
"no-await-in-loop": "error",
"no-caller": "error",
"no-constructor-return": "error",
"no-eval": "error",
"no-extend-native": "error",
"no-implied-eval": "error",
"no-iterator": "error",
"no-new-func": "error",
"no-new-wrappers": "error",
"no-octal-escape": "error",
"no-promise-executor-return": "error",
"no-return-assign": ["error", "always"],
"no-script-url": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-template-curly-in-string": "error",
"no-unreachable-loop": "error",
// import-x rules (migrated from eslint-plugin-import / airbnb-base)
"no-else-return": ["error", { allowElseIf: false }],
"no-lonely-if": "error",
"no-unneeded-ternary": ["error", { defaultAssignment: false }],
"no-useless-computed-key": "error",
"no-useless-concat": "error",
"no-useless-rename": "error",
"no-useless-return": "error",
"one-var": ["error", "never"],
"operator-assignment": ["error", "always"],
"prefer-arrow-callback": "error",
"prefer-exponentiation-operator": "error",
"prefer-object-spread": "error",
"prefer-regex-literals": ["error", { disallowRedundantWrapping: true }],
"symbol-description": "error",
yoda: "error",
// TODO: Enable once violations are fixed (43 instances as of 2026-04)
// "no-useless-assignment": "error",
"no-useless-assignment": "error",
// Project rules
"no-bitwise": "error",
"no-console": "error",
"no-restricted-globals": [2, "event"],
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"wc/no-self-class": "off",
// import-x rules
"import-x/named": "off",
"import-x/prefer-default-export": "off",
"import-x/no-default-export": "off",
@@ -146,13 +140,9 @@ export default tseslint.config(
"import-x/no-relative-packages": "error",
// TypeScript rules
"@typescript-eslint/camelcase": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-use-before-define": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-shadow": ["error"],
"@typescript-eslint/naming-convention": [
@@ -216,7 +206,6 @@ export default tseslint.config(
"lit-a11y/role-has-required-aria-attrs": "error",
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-import-type-side-effects": "error",
camelcase: "off",
"@typescript-eslint/no-dynamic-delete": "off",
"@typescript-eslint/no-empty-object-type": [
"error",
+79 -29
View File
@@ -1,17 +1,22 @@
import "@material/mwc-drawer";
import "@material/mwc-top-app-bar-fixed";
import { mdiMenu } from "@mdi/js";
import { mdiMenu, mdiSwapHorizontal } from "@mdi/js";
import type { PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, query, state } from "lit/decorators";
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
import "../../src/components/ha-button";
import { HaExpansionPanel } from "../../src/components/ha-expansion-panel";
import "../../src/components/ha-icon-button";
import "../../src/components/ha-svg-icon";
import "../../src/managers/notification-manager";
import { haStyle } from "../../src/resources/styles";
import { PAGES, SIDEBAR } from "../build/import-pages";
import "./components/page-description";
const RTL_STORAGE_KEY = "gallery-rtl";
const GITHUB_DEMO_URL =
"https://github.com/home-assistant/frontend/blob/dev/gallery/src/pages/";
@@ -29,6 +34,8 @@ class HaGallery extends LitElement {
document.location.hash.substring(1) ||
`${SIDEBAR[0].category}/${SIDEBAR[0].pages![0]}`;
@state() private _rtl = localStorage.getItem(RTL_STORAGE_KEY) === "true";
@query("notification-manager")
private _notifications!: HTMLElementTagNameMap["notification-manager"];
@@ -97,33 +104,43 @@ class HaGallery extends LitElement {
${dynamicElement(`demo-${this._page.replace("/", "-")}`)}
</div>
<div class="page-footer">
<div class="header">Help us to improve our documentation</div>
<div class="secondary">
Suggest an edit to this page, or provide/view feedback for this
page.
<div class="edit-docs">
<div class="header">Help us to improve our documentation</div>
<div class="secondary">
Suggest an edit to this page, or provide/view feedback for this
page.
</div>
<div>
${PAGES[this._page].description ||
Object.keys(PAGES[this._page].metadata).length > 0
? html`
<a
href=${`${GITHUB_DEMO_URL}${this._page}.markdown`}
target="_blank"
>
Edit text
</a>
`
: ""}
${PAGES[this._page].demo
? html`
<a
href=${`${GITHUB_DEMO_URL}${this._page}.ts`}
target="_blank"
>
Edit demo
</a>
`
: ""}
</div>
</div>
<div>
${PAGES[this._page].description ||
Object.keys(PAGES[this._page].metadata).length > 0
? html`
<a
href=${`${GITHUB_DEMO_URL}${this._page}.markdown`}
target="_blank"
>
Edit text
</a>
`
: ""}
${PAGES[this._page].demo
? html`
<a
href=${`${GITHUB_DEMO_URL}${this._page}.ts`}
target="_blank"
>
Edit demo
</a>
`
: ""}
<div class="rtl-toggle">
<ha-icon-button
@click=${this._toggleRtl}
.label=${this._rtl ? "Switch to LTR" : "Switch to RTL"}
>
<ha-svg-icon .path=${mdiSwapHorizontal}></ha-svg-icon>
</ha-icon-button>
</div>
</div>
</div>
@@ -138,6 +155,8 @@ class HaGallery extends LitElement {
firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._applyDirection();
this.addEventListener("show-notification", (ev) =>
this._notifications.showDialog({ message: ev.detail.message })
);
@@ -164,6 +183,11 @@ class HaGallery extends LitElement {
updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_rtl")) {
this._applyDirection();
}
if (!changedProps.has("_page")) {
return;
}
@@ -186,6 +210,15 @@ class HaGallery extends LitElement {
this._drawer.open = !this._drawer.open;
}
private _toggleRtl() {
this._rtl = !this._rtl;
localStorage.setItem(RTL_STORAGE_KEY, String(this._rtl));
}
private _applyDirection() {
setDirectionStyles(this._rtl ? "rtl" : "ltr", this);
}
static styles = [
haStyle,
css`
@@ -238,11 +271,16 @@ class HaGallery extends LitElement {
}
.page-footer {
display: flex;
border-radius: var(--ha-border-radius-lg);
background-color: var(--primary-background-color);
}
.edit-docs {
flex: 1;
text-align: center;
margin: 16px;
padding: 16px;
border-radius: var(--ha-border-radius-lg);
background-color: var(--primary-background-color);
}
.page-footer div {
@@ -266,6 +304,18 @@ class HaGallery extends LitElement {
margin: 0 8px;
text-decoration: none;
}
.rtl-toggle {
padding: var(--ha-space-4);
display: inline-flex;
align-items: flex-end;
margin-top: 12px !important;
}
.rtl-toggle ha-icon-button {
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-pill);
}
`,
];
}
@@ -9,6 +9,7 @@ import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-control-switch";
@@ -50,59 +51,100 @@ export class DemoHaControlSwitch extends LitElement {
protected render(): TemplateResult {
return html`
${repeat(switches, (sw) => {
const { id, label, ...config } = sw;
return html`
<ha-card>
<div class="card-content">
<label id=${id}>${label}</label>
<pre>Config: ${JSON.stringify(config)}</pre>
<ha-control-switch
.checked=${this.checked}
class=${ifDefined(config.class)}
@change=${this.handleValueChanged}
.pathOn=${mdiLightbulb}
.pathOff=${mdiLightbulbOff}
.label=${label}
?disabled=${config.disabled}
?reversed=${config.reversed}
>
</ha-control-switch>
<div class="themes">
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-control-switch ${mode}">
${repeat(switches, (sw) => {
const { id, label, ...config } = sw;
return html`
<div class="card-content">
<label id="${mode}-${id}">${label}</label>
<pre>Config: ${JSON.stringify(config)}</pre>
<ha-control-switch
.checked=${this.checked}
class=${ifDefined(config.class)}
@change=${this.handleValueChanged}
.pathOn=${mdiLightbulb}
.pathOff=${mdiLightbulbOff}
.label=${label}
?disabled=${config.disabled}
?reversed=${config.reversed}
>
</ha-control-switch>
</div>
`;
})}
<div class="card-content">
<p class="title"><b>Vertical</b></p>
<div class="vertical-switches">
${repeat(switches, (sw) => {
const { label, ...config } = sw;
return html`
<ha-control-switch
.checked=${this.checked}
vertical
class=${ifDefined(config.class)}
@change=${this.handleValueChanged}
.label=${label}
.pathOn=${mdiGarageOpen}
.pathOff=${mdiGarage}
?disabled=${config.disabled}
?reversed=${config.reversed}
>
</ha-control-switch>
`;
})}
</div>
</div>
</ha-card>
</div>
</ha-card>
`;
})}
<ha-card>
<div class="card-content">
<p class="title"><b>Vertical</b></p>
<div class="vertical-switches">
${repeat(switches, (sw) => {
const { id, label, ...config } = sw;
return html`
<ha-control-switch
.checked=${this.checked}
vertical
class=${ifDefined(config.class)}
@change=${this.handleValueChanged}
.label=${label}
.pathOn=${mdiGarageOpen}
.pathOff=${mdiGarage}
?disabled=${config.disabled}
?reversed=${config.reversed}
>
</ha-control-switch>
`;
})}
</div>
</div>
</ha-card>
`
)}
</div>
`;
}
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: block;
}
.themes {
display: flex;
flex-direction: row;
justify-content: center;
flex-wrap: wrap;
gap: 16px;
padding: 16px;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 16px;
border-radius: var(--ha-border-radius-md);
}
ha-card {
max-width: 600px;
margin: 24px auto;
margin: 0 auto;
}
pre {
margin-top: 0;
-1
View File
@@ -27,7 +27,6 @@ export class DemoHaInput extends LitElement {
constructor() {
super();
// Provides internationalizationContext for ha-input-copy, ha-input-multi and ha-input-search
// eslint-disable-next-line no-new
new ContextProvider(this, {
context: internationalizationContext,
initialValue: {
+14 -17
View File
@@ -33,20 +33,20 @@
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/search": "6.6.0",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.41.1",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.3.2",
"@formatjs/intl-displaynames": "7.3.2",
"@formatjs/intl-durationformat": "0.10.4",
"@formatjs/intl-getcanonicallocales": "3.2.3",
"@formatjs/intl-listformat": "8.3.2",
"@formatjs/intl-locale": "5.3.2",
"@formatjs/intl-numberformat": "9.3.2",
"@formatjs/intl-pluralrules": "6.3.2",
"@formatjs/intl-relativetimeformat": "12.3.2",
"@formatjs/intl-datetimeformat": "7.4.0",
"@formatjs/intl-displaynames": "7.3.3",
"@formatjs/intl-durationformat": "0.10.5",
"@formatjs/intl-getcanonicallocales": "3.2.4",
"@formatjs/intl-listformat": "8.3.3",
"@formatjs/intl-locale": "5.3.3",
"@formatjs/intl-numberformat": "9.3.3",
"@formatjs/intl-pluralrules": "6.3.3",
"@formatjs/intl-relativetimeformat": "12.3.3",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
@@ -99,7 +99,7 @@
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.2.1",
"intl-messageformat": "11.2.2",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -135,7 +135,6 @@
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.2",
"@bundle-stats/plugin-webpack-filter": "4.22.1",
"@eslint/eslintrc": "3.3.5",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.59.0",
"@lokalise/node-api": "15.7.1",
@@ -162,16 +161,14 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.4",
"@vitest/coverage-v8": "4.1.5",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.2.1",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-lit": "2.2.1",
"eslint-plugin-lit-a11y": "5.1.1",
@@ -200,12 +197,12 @@
"serve": "14.2.6",
"sinon": "21.1.2",
"tar": "7.5.13",
"terser-webpack-plugin": "5.4.0",
"terser-webpack-plugin": "5.5.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.59.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.4",
"vitest": "4.1.5",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"
+2 -2
View File
@@ -44,9 +44,9 @@ export const normalizeLuminance = (color: string): string => {
return midL;
}
if (testLuminance < targetLuminance) {
return findLightness(midL, highL, iterations--);
return findLightness(midL, highL, iterations - 1);
}
return findLightness(lowL, midL, iterations--);
return findLightness(lowL, midL, iterations - 1);
}
baseOklch.l = findLightness();
+2 -1
View File
@@ -21,8 +21,9 @@ export const closestWithProperty = (
own
? Object.prototype.hasOwnProperty.call(element, property)
: element && property in element
)
) {
return element;
}
return closestWithProperty(element, property, own);
};
@@ -1,6 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../../types";
import { ensureArray } from "../array/ensure-array";
import { computeRTL } from "../util/compute_rtl";
import { computeAreaName } from "./compute_area_name";
import { computeDeviceName } from "./compute_device_name";
import { computeEntityName, entityUseDeviceName } from "./compute_entity_name";
@@ -117,3 +118,32 @@ export const computeEntityNameList = (
return names;
};
export interface EntityPickerDisplay {
primary: string;
secondary?: string;
}
export const computeEntityPickerDisplay = (
hass: HomeAssistant,
stateObj: HassEntity
): EntityPickerDisplay => {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const isRTL = computeRTL(hass);
const primary = entityName || deviceName || stateObj.entity_id;
const secondary =
[areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
return { primary, secondary };
};
@@ -258,6 +258,7 @@ const computeStateToPartsFromEntityAttributes = (
"infrared",
"input_button",
"notify",
"radio_frequency",
"scene",
"stt",
"tag",
+1
View File
@@ -54,6 +54,7 @@ export const FIXED_DOMAIN_STATES = {
],
person: ["home", "not_home"],
plant: ["ok", "problem"],
radio_frequency: [],
remote: ["on", "off"],
scene: [],
schedule: ["on", "off"],
+8 -1
View File
@@ -7,7 +7,14 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
const compareState = state !== undefined ? state : stateObj?.state;
if (
["button", "event", "infrared", "input_button", "scene"].includes(domain)
[
"button",
"event",
"infrared",
"input_button",
"radio_frequency",
"scene",
].includes(domain)
) {
return compareState !== UNAVAILABLE;
}
-1
View File
@@ -19,7 +19,6 @@ const SECS_PER_HOUR = SECS_PER_MIN * 60;
// Adapted from https://github.com/formatjs/formatjs/blob/186cef62f980ec66252ee232f438a42d0b51b9f9/packages/intl-utils/src/diff.ts
export function selectUnit(
from: Date | number,
// eslint-disable-next-line default-param-last
to: Date | number = Date.now(),
locale: FrontendLocaleData,
thresholds: Partial<Thresholds> = {}
+1 -3
View File
@@ -360,14 +360,12 @@ export class HaChartBase extends LitElement {
return nothing;
}
let itemStyle: Record<string, any> = {};
let name = "";
let id = "";
let value = "";
const name = typeof item === "string" ? item : (item.name ?? "");
if (typeof item === "string") {
name = item;
id = item;
} else {
name = item.name ?? "";
id = item.id ?? name;
value = item.value ?? "";
itemStyle = item.itemStyle ?? {};
+16 -10
View File
@@ -291,20 +291,26 @@ export class HaSankeyChart extends LitElement {
}
private _findParentIndex(id: string, links: Link[], sections: Node[][]) {
const parent = links.find((l) => l.target === id)?.source;
if (!parent) {
const parents = links.filter((l) => l.target === id).map((l) => l.source);
if (parents.length === 0) {
return -1;
}
let offset = 0;
for (let i = sections.length - 1; i >= 0; i--) {
const section = sections[i];
const index = section.findIndex((n) => n.id === parent);
if (index !== -1) {
return offset + index;
let sum = 0;
let count = 0;
for (const parent of parents) {
let offset = 0;
for (let i = sections.length - 1; i >= 0; i--) {
const section = sections[i];
const index = section.findIndex((n) => n.id === parent);
if (index !== -1) {
sum += offset + index;
count++;
break;
}
offset += section.length;
}
offset += section.length;
}
return -1;
return count > 0 ? sum / count : -1;
}
static styles = css`
@@ -22,6 +22,7 @@ import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { filterXSS } from "../../common/util/xss";
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
const safeParseFloat = (value) => {
const parsed = parseFloat(value);
@@ -128,8 +129,9 @@ export class StateHistoryChartLine extends LitElement {
if (
dataset.tooltip?.show === false ||
this._hiddenStats.has(dataset.id as string)
)
) {
return;
}
const param = params.find(
(p: Record<string, any>) => p.seriesIndex === index
);
@@ -310,12 +312,50 @@ export class StateHistoryChartLine extends LitElement {
.filter((item) => !(item.dataset as LineSeriesOption).areaStyle)
.map((item) => {
const stateObj = this.hass.states[item.entityId];
let value: string | undefined;
if (stateObj) {
// For climate temperature datasets, show temperature values
const datasetId = item.dataset.id as string;
if (
datasetId?.endsWith("-current_temperature") ||
datasetId?.endsWith("-target_temperature") ||
datasetId?.endsWith("-target_temperature_mode") ||
datasetId?.endsWith("-target_temperature_mode_low")
) {
let attribute: string | undefined;
if (datasetId.endsWith("-current_temperature")) {
attribute = "current_temperature";
} else if (
datasetId.endsWith("-target_temperature_mode_low")
) {
attribute = "target_temp_low";
} else if (datasetId.endsWith("-target_temperature_mode")) {
attribute = "target_temp_high";
} else {
attribute = "temperature";
}
// Use the helper to format temperature with proper unit
value = computeAttributeValueDisplay(
this.hass.localize,
stateObj,
this.hass.locale,
this.hass.config,
this.hass.entities,
attribute
);
}
// Default for non-temperature datasets / missing attribute
if (value === undefined) {
value = this.hass.formatEntityState(stateObj);
}
}
return {
id: item.dataset.id as string,
name: item.dataset.name as string,
value: stateObj
? this.hass.formatEntityState(stateObj)
: undefined,
value: value,
};
}),
},
+79 -21
View File
@@ -4,9 +4,8 @@ import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import { computeRTL } from "../../common/util/compute_rtl";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import {
entityComboBoxKeys,
@@ -14,6 +13,7 @@ import {
type EntityComboBoxItem,
} from "../../data/entity/entity_picker";
import { domainToName } from "../../data/integration";
import type { EntitySelectorExtraOption } from "../../data/selector";
import {
isHelperDomain,
type HelperDomain,
@@ -22,6 +22,7 @@ import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-
import type { HomeAssistant } from "../../types";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import "../ha-icon";
import type { HaGenericPicker } from "../ha-generic-picker";
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
@@ -111,12 +112,21 @@ export class HaEntityPicker extends LitElement {
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/**
* Extra options shown alongside entities. The `id` is used as the value
* when the option is selected (it does not need to be a valid entity id).
*/
@property({ attribute: false })
public extraOptions?: EntitySelectorExtraOption[];
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: "add-button", type: Boolean })
public addButton = false;
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
protected firstUpdated(changedProperties: PropertyValues<this>): void {
@@ -125,9 +135,54 @@ export class HaEntityPicker extends LitElement {
this.hass.loadBackendTranslation("title");
}
private _findExtraOption(value: string | undefined) {
return value
? this.extraOptions?.find((opt) => opt.id === value)
: undefined;
}
private _renderExtraOptionStart(extraOption: EntitySelectorExtraOption) {
const stateObj = extraOption.entity_id
? this.hass.states[extraOption.entity_id]
: undefined;
if (stateObj) {
return html`
<state-badge
slot="start"
.stateObj=${stateObj}
.hass=${this.hass}
></state-badge>
`;
}
if (extraOption.icon_path) {
return html`
<ha-svg-icon
slot="start"
.path=${extraOption.icon_path}
style="margin: 0 4px"
></ha-svg-icon>
`;
}
if (extraOption.icon) {
return html`<ha-icon slot="start" .icon=${extraOption.icon}></ha-icon>`;
}
return nothing;
}
private _valueRenderer: PickerValueRenderer = (value) => {
const entityId = value || "";
const extraOption = this._findExtraOption(entityId);
if (extraOption) {
return html`
${this._renderExtraOptionStart(extraOption)}
<span slot="headline">${extraOption.primary}</span>
${extraOption.secondary
? html`<span slot="supporting-text">${extraOption.secondary}</span>`
: nothing}
`;
}
const stateObj = this.hass.states[entityId];
if (!stateObj) {
@@ -141,22 +196,11 @@ export class HaEntityPicker extends LitElement {
`;
}
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 { primary, secondary } = computeEntityPickerDisplay(
this.hass,
stateObj
);
const isRTL = computeRTL(this.hass);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return html`
<state-badge
.hass=${this.hass}
@@ -253,8 +297,8 @@ export class HaEntityPicker extends LitElement {
private _getEntitiesMemoized = memoizeOne(getEntities);
private _getItems = () =>
this._getEntitiesMemoized(
private _getItems = () => {
const items = this._getEntitiesMemoized(
this.hass,
this.includeDomains,
this.excludeDomains,
@@ -265,6 +309,19 @@ export class HaEntityPicker extends LitElement {
this.excludeEntities,
this.value
);
if (this.extraOptions?.length) {
const resolvedExtras = this.extraOptions.map((opt) => ({
...opt,
stateObj: opt.entity_id ? this.hass.states[opt.entity_id] : undefined,
}));
return [...resolvedExtras, ...items];
}
return items;
};
private _shouldHideClearIcon() {
return !!this._findExtraOption(this.value)?.hide_clear;
}
protected render() {
const placeholder =
@@ -287,13 +344,14 @@ export class HaEntityPicker extends LitElement {
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.hideClearIcon=${this.hideClearIcon}
.hideClearIcon=${this.hideClearIcon || this._shouldHideClearIcon()}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
.searchKeys=${entityComboBoxKeys}
use-top-label
.addButtonLabel=${this.addButton
? this.hass.localize("ui.components.entity.entity-picker.add")
? (this.addButtonLabel ??
this.hass.localize("ui.components.entity.entity-picker.add"))
: undefined}
.unknownItemText=${this.hass.localize(
"ui.components.entity.entity-picker.unknown"
@@ -347,7 +405,7 @@ export class HaEntityPicker extends LitElement {
return;
}
if (!isValidEntityId(value)) {
if (!isValidEntityId(value) && !this._findExtraOption(value)) {
return;
}
@@ -38,8 +38,6 @@ export class HaEntityStatePicker extends LitElement {
@property() public helper?: string;
@property({ attribute: "no-entity", type: Boolean }) public noEntity = false;
private _getItems = memoizeOne(
(
hass: HomeAssistant,
@@ -122,12 +120,13 @@ export class HaEntityStatePicker extends LitElement {
return nothing;
}
const noEntity = !ensureArray(this.entityId)?.length;
return html`
<ha-generic-picker
.hass=${this.hass}
.allowCustomValue=${this.allowCustomValue}
.disabled=${this.disabled ||
(!this.entityId && this.noEntity === false)}
.disabled=${this.disabled || noEntity}
.autofocus=${this.autofocus}
.required=${this.required}
.label=${this.label ??
+8 -7
View File
@@ -13,9 +13,9 @@ import {
} from "../../data/entity/entity";
import { forwardHaptic } from "../../data/haptics";
import type { HomeAssistant } from "../../types";
import "../ha-control-switch";
import "../ha-formfield";
import "../ha-icon-button";
import "../ha-switch";
const isOn = (stateObj?: HassEntity) =>
stateObj !== undefined &&
@@ -35,7 +35,7 @@ export class HaEntityToggle extends LitElement {
protected render(): TemplateResult {
if (!this.stateObj) {
return html` <ha-switch disabled></ha-switch> `;
return html`<ha-control-switch disabled></ha-control-switch> `;
}
if (
@@ -62,14 +62,14 @@ export class HaEntityToggle extends LitElement {
`;
}
const switchTemplate = html`<ha-switch
const switchTemplate = html`<ha-control-switch
aria-label=${`Toggle ${computeStateName(this.stateObj)} ${
this._isOn ? "off" : "on"
}`}
.checked=${this._isOn}
.disabled=${this.stateObj.state === UNAVAILABLE}
@change=${this._toggleChanged}
></ha-switch>`;
></ha-control-switch>`;
if (!this.label) {
return switchTemplate;
@@ -163,6 +163,10 @@ export class HaEntityToggle extends LitElement {
white-space: nowrap;
min-width: 38px;
}
ha-control-switch {
--control-switch-thickness: 20px;
--control-switch-off-color: var(--state-inactive-color);
}
ha-icon-button {
--ha-icon-button-size: 40px;
color: var(--ha-icon-button-inactive-color, var(--primary-text-color));
@@ -171,9 +175,6 @@ export class HaEntityToggle extends LitElement {
ha-icon-button.state-active {
color: var(--ha-icon-button-active-color, var(--primary-color));
}
ha-switch {
padding: 13px 5px;
}
`;
}
-1
View File
@@ -177,7 +177,6 @@ export class HaAnsiToHtml extends LitElement {
lineDiv.appendChild(span);
};
/* eslint-disable no-cond-assign */
let match;
while ((match = re.exec(line)) !== null) {
+2 -1
View File
@@ -26,7 +26,7 @@ class HaAttributeValue extends LitElement {
try {
// If invalid URL, exception will be raised
const url = new URL(attributeValue);
if (url.protocol === "http:" || url.protocol === "https:")
if (url.protocol === "http:" || url.protocol === "https:") {
return html`
<a
target="_blank"
@@ -36,6 +36,7 @@ class HaAttributeValue extends LitElement {
${attributeValue}
</a>
`;
}
} catch {
// Nothing to do here
}
@@ -0,0 +1,538 @@
import { consume, type ContextType } from "@lit/context";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
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 { ensureArray } from "../common/array/ensure-array";
import { resolveTimeZone } from "../common/datetime/resolve-time-zone";
import { fireEvent } from "../common/dom/fire_event";
import { configContext, internationalizationContext } from "../data/context";
import {
CLOCK_CARD_DATE_PARTS,
formatClockCardDate,
} from "../panels/lovelace/cards/clock/clock-date-format";
import type { ClockCardDatePart } from "../panels/lovelace/cards/types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./chips/ha-assist-chip";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-input-helper-text";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-sortable";
type ClockDatePartSection = "weekday" | "day" | "month" | "year" | "separator";
type ClockDateSeparatorPart = Extract<
ClockCardDatePart,
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
>;
const CLOCK_DATE_PART_SECTION_ORDER: readonly ClockDatePartSection[] = [
"day",
"month",
"year",
"weekday",
"separator",
];
const CLOCK_DATE_SEPARATOR_VALUES: Record<ClockDateSeparatorPart, string> = {
"separator-dash": "-",
"separator-slash": "/",
"separator-dot": ".",
"separator-new-line": "",
};
const getClockDatePartSection = (
part: ClockCardDatePart
): ClockDatePartSection => {
if (part.startsWith("weekday-")) {
return "weekday";
}
if (part.startsWith("day-")) {
return "day";
}
if (part.startsWith("month-")) {
return "month";
}
if (part.startsWith("year-")) {
return "year";
}
return "separator";
};
interface ClockDatePartSectionData {
id: ClockDatePartSection;
title: string;
items: PickerComboBoxItem[];
}
interface ClockDatePartValueItem {
key: string;
item: string;
idx: number;
}
@customElement("ha-clock-date-format-picker")
export class HaClockDateFormatPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property() public label?: string;
@property() public value?: string[] | string;
@property() public helper?: string;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig!: ContextType<typeof configContext>;
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
private _editIndex?: number;
protected render() {
const value = this._value;
const valueItems = this._getValueItems(value);
const sections = this._buildSections();
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${this._getPickerValue()}
.sections=${this._getSectionHeaders(sections)}
.getItems=${this._getItems(sections)}
@value-changed=${this._pickerValueChanged}
>
<div slot="field" class="container">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
valueItems,
(entry: ClockDatePartValueItem) => entry.key,
({ item, idx }) => this._renderValueChip(item, idx, sections)
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this._i18n.localize("ui.common.add")}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
</div>
</ha-generic-picker>
${this._renderHelper()}
`;
}
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing;
}
private _getValueItems = memoizeOne(
(value: string[]): ClockDatePartValueItem[] => {
const occurrences = new Map<string, number>();
return value.map((item, idx) => {
const occurrence = occurrences.get(item) ?? 0;
occurrences.set(item, occurrence + 1);
return {
key: `${item}:${occurrence}`,
item,
idx,
};
});
}
);
private _renderValueChip(
item: string,
idx: number,
sections: ClockDatePartSectionData[]
) {
const label = this._getItemLabel(item, sections);
const isValid = !!label;
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label ?? item}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-input-chip>
`;
}
private async _addItem(ev: Event) {
ev.stopPropagation();
if (this.disabled) {
return;
}
this._editIndex = undefined;
await this.updateComplete;
await this._picker?.open();
}
private async _editItem(ev: Event) {
ev.stopPropagation();
if (this.disabled) {
return;
}
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
10
);
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
}
private get _value() {
return !this.value ? [] : ensureArray(this.value);
}
private _toValue = memoizeOne((value: string[]): string[] | undefined =>
value.length === 0 ? undefined : value
);
private _buildSections(): ClockDatePartSectionData[] {
const itemsBySection: Record<ClockDatePartSection, PickerComboBoxItem[]> = {
weekday: [],
day: [],
month: [],
year: [],
separator: [],
};
const previewDate = new Date();
const previewTimeZone = resolveTimeZone(
this._i18n.locale.time_zone,
this._hassConfig.config.time_zone
);
CLOCK_CARD_DATE_PARTS.forEach((part) => {
const section = getClockDatePartSection(part);
const label =
this._i18n.localize(
`ui.panel.lovelace.editor.card.clock.date.parts.${part}`
) ?? part;
const secondary =
section === "separator"
? CLOCK_DATE_SEPARATOR_VALUES[part as ClockDateSeparatorPart]
: formatClockCardDate(
previewDate,
{ parts: [part] },
this._i18n.locale.language,
previewTimeZone
);
itemsBySection[section].push({
id: part,
primary: label,
secondary,
sorting_label: label,
});
});
return CLOCK_DATE_PART_SECTION_ORDER.map((section) => ({
id: section,
title:
this._i18n.localize(
`ui.panel.lovelace.editor.card.clock.date.sections.${section}`
) ?? section,
items: itemsBySection[section],
})).filter((section) => section.items.length > 0);
}
private _getSectionHeaders(
sections: ClockDatePartSectionData[]
): { id: string; label: string }[] {
return sections.map((section) => ({
id: section.id,
label: section.title,
}));
}
private _getItems = memoizeOne(
(sections: ClockDatePartSectionData[]) =>
(
searchString?: string,
section?: string
): (PickerComboBoxItem | string)[] => {
const normalizedSearch = searchString?.trim().toLowerCase();
const filteredSections = sections
.map((sectionData) => {
if (!normalizedSearch) {
return sectionData;
}
return {
...sectionData,
items: sectionData.items.filter(
(item) =>
item.primary.toLowerCase().includes(normalizedSearch) ||
item.secondary?.toLowerCase().includes(normalizedSearch) ||
item.id.toLowerCase().includes(normalizedSearch)
),
};
})
.filter((sectionData) => sectionData.items.length > 0);
if (section) {
return (
filteredSections.find((candidate) => candidate.id === section)
?.items || []
);
}
const groupedItems: (PickerComboBoxItem | string)[] = [];
filteredSections.forEach((sectionData) => {
groupedItems.push(sectionData.title, ...sectionData.items);
});
return groupedItems;
}
);
private _getItemLabel(
value: string,
sections: ClockDatePartSectionData[]
): string | undefined {
for (const section of sections) {
const item = section.items.find((candidate) => candidate.id === value);
if (item) {
if (section.id === "separator") {
if (value === "separator-new-line") {
return item.primary;
}
return item.secondary ?? item.primary;
}
return `${item.secondary} [${item.primary} ${section.title}]`;
}
}
return undefined;
}
private _getPickerValue(): string | undefined {
if (this._editIndex != null) {
return this._value[this._editIndex];
}
return undefined;
}
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._value;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
}
private async _removeItem(ev: Event) {
ev.preventDefault();
ev.stopPropagation();
const idx = parseInt(
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
10
);
if (Number.isNaN(idx)) {
return;
}
const value = [...this._value];
value.splice(idx, 1);
if (this._editIndex !== undefined) {
if (this._editIndex === idx) {
this._editIndex = undefined;
} else if (this._editIndex > idx) {
this._editIndex -= 1;
}
}
this._setValue(value);
}
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const value = ev.detail.value;
if (this.disabled || !value) {
return;
}
const newValue = [...this._value];
if (this._editIndex != null) {
newValue[this._editIndex] = value;
this._editIndex = undefined;
} else {
newValue.push(value);
}
this._setValue(newValue);
if (this._picker) {
this._picker.value = undefined;
}
}
private _setValue(value: string[]) {
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
});
}
static styles = css`
:host {
position: relative;
width: 100%;
}
.container {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.container:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
transition:
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
:host([disabled]) .container:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.container:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
label {
display: block;
margin: 0 0 var(--ha-space-2);
}
.add {
order: 1;
}
ha-chip-set {
padding: var(--ha-space-2);
}
.invalid {
text-decoration: line-through;
}
.sortable-fallback {
display: none;
opacity: 0;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-drag {
cursor: grabbing;
}
ha-input-helper-text {
display: block;
margin: var(--ha-space-2) 0 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-clock-date-format-picker": HaClockDateFormatPicker;
}
}
+3 -2
View File
@@ -370,11 +370,12 @@ export class HaCodeEditor extends ReactiveElement {
}
private _fullscreenLabel(): string {
if (this._isFullscreen)
if (this._isFullscreen) {
return (
this.hass?.localize("ui.components.yaml-editor.exit_fullscreen") ||
"Exit fullscreen"
);
}
return (
this.hass?.localize("ui.components.yaml-editor.enter_fullscreen") ||
"Enter fullscreen"
@@ -914,7 +915,7 @@ export class HaCodeEditor extends ReactiveElement {
// In both cases the parent is a MemberExpression.
const memberNode = node.parent;
// "from" for the completion result (start of what the user is currently typing)
let completionFrom = pos;
let completionFrom: number;
if (
node.name === "PropertyName" &&
+60 -10
View File
@@ -11,6 +11,7 @@ import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import "./ha-svg-icon";
@customElement("ha-control-switch")
@@ -39,7 +40,7 @@ export class HaControlSwitch extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
this.setupListeners();
this.setupSwipeListeners();
}
private _toggle() {
@@ -50,7 +51,19 @@ export class HaControlSwitch extends LitElement {
connectedCallback(): void {
super.connectedCallback();
this.setupListeners();
this.setupSwipeListeners();
}
updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (
changedProperties.has("disabled") ||
changedProperties.has("vertical") ||
changedProperties.has("reversed")
) {
this.destroyListeners();
this.setupSwipeListeners();
}
}
disconnectedCallback(): void {
@@ -61,7 +74,11 @@ export class HaControlSwitch extends LitElement {
@query("#switch")
private switch!: HTMLDivElement;
setupListeners() {
setupSwipeListeners() {
if (this.disabled) {
return;
}
if (this.switch && !this._mc) {
this._mc = new Manager(this.switch, {
touchAction: this.touchAction ?? (this.vertical ? "pan-x" : "pan-y"),
@@ -90,13 +107,15 @@ export class HaControlSwitch extends LitElement {
} else {
this._mc.on("swiperight", () => {
if (this.disabled) return;
this.checked = !this.reversed;
const isRTL = mainWindow.document.dir === "rtl";
this.checked = (!this.reversed && !isRTL) || (this.reversed && isRTL);
fireEvent(this, "change");
});
this._mc.on("swipeleft", () => {
if (this.disabled) return;
this.checked = !!this.reversed;
const isRTL = mainWindow.document.dir === "rtl";
this.checked = (this.reversed && !isRTL) || (!this.reversed && isRTL);
fireEvent(this, "change");
});
}
@@ -116,11 +135,30 @@ export class HaControlSwitch extends LitElement {
}
private _keydown(ev: any) {
if (ev.key !== "Enter" && ev.key !== " ") {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._toggle();
return;
}
const rtl = !this.vertical && mainWindow.document.dir === "rtl";
const flip = this.reversed !== rtl;
const [forward, backward] = this.vertical
? ["ArrowDown", "ArrowUp"]
: ["ArrowRight", "ArrowLeft"];
const onKey = flip ? backward : forward;
const offKey = flip ? forward : backward;
if (ev.key !== onKey && ev.key !== offKey) {
return;
}
ev.preventDefault();
this._toggle();
const wantOn = ev.key === onKey;
if (wantOn !== this.checked) {
this._toggle();
}
}
protected render(): TemplateResult {
@@ -132,7 +170,7 @@ export class HaControlSwitch extends LitElement {
aria-checked=${this.checked ? "true" : "false"}
aria-label=${ifDefined(this.label)}
role="switch"
tabindex="0"
tabindex=${ifDefined(this.disabled ? undefined : "0")}
?checked=${this.checked}
?disabled=${this.disabled}
>
@@ -156,6 +194,7 @@ export class HaControlSwitch extends LitElement {
--control-switch-on-color: var(--primary-color);
--control-switch-off-color: var(--disabled-color);
--control-switch-background-opacity: 0.2;
--control-switch-hover-background-opacity: 0.4;
--control-switch-thickness: 40px;
--control-switch-border-radius: var(--ha-border-radius-lg);
--control-switch-padding: 4px;
@@ -167,10 +206,10 @@ export class HaControlSwitch extends LitElement {
transition: box-shadow 180ms ease-in-out;
-webkit-tap-highlight-color: transparent;
}
.switch:focus-visible {
.switch:not([disabled]):focus-visible {
box-shadow: 0 0 0 2px var(--control-switch-off-color);
}
.switch[checked]:focus-visible {
.switch[checked]:not([disabled]):focus-visible {
box-shadow: 0 0 0 2px var(--control-switch-on-color);
}
.switch {
@@ -199,6 +238,10 @@ export class HaControlSwitch extends LitElement {
transition: background-color 180ms ease-in-out;
opacity: var(--control-switch-background-opacity);
}
.switch:not([disabled]):focus-visible .background,
.switch:not([disabled]):hover .background {
opacity: var(--control-switch-hover-background-opacity);
}
.switch .button {
width: 50%;
height: 100%;
@@ -222,12 +265,19 @@ export class HaControlSwitch extends LitElement {
transform: translateX(100%);
background-color: var(--control-switch-on-color);
}
.switch[checked] .button:dir(rtl) {
transform: translateX(-100%);
background-color: var(--control-switch-on-color);
}
:host([reversed]) .switch {
flex-direction: row-reverse;
}
:host([reversed]) .switch[checked] .button {
transform: translateX(-100%);
}
:host([reversed]) .switch[checked] .button:dir(rtl) {
transform: translateX(100%);
}
:host([vertical]) {
width: var(--control-switch-thickness);
height: 100%;
+2 -2
View File
@@ -445,10 +445,10 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
}
wa-popover::part(body) {
width: max(var(--body-width), 250px);
width: var(--ha-generic-picker-width, max(var(--body-width), 250px));
max-width: var(
--ha-generic-picker-max-width,
max(var(--body-width), 250px)
var(--ha-generic-picker-width, max(var(--body-width), 250px))
);
max-height: 500px;
height: 70vh;
+21 -6
View File
@@ -13,7 +13,10 @@ import {
} from "lit/decorators";
import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
} from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { internationalizationContext } from "../data/context";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
@@ -52,6 +55,7 @@ export interface PickerComboBoxItem {
id: string;
primary: string;
secondary?: string;
disabled?: boolean;
search_labels?: Record<string, string | null>;
sorting_label?: string;
icon_path?: string;
@@ -64,6 +68,12 @@ export interface PickerComboBoxIndexSelectedDetail {
newTab?: boolean;
}
type PickerComboBoxRowElement = HTMLDivElement & {
disabled?: boolean;
index: number;
value: string;
};
export const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
const PADDING_ID = "___padding___";
@@ -425,6 +435,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
class="combo-box-row ${this.value === item.id ? "current-value" : ""}"
.value=${item.id}
.index=${index}
.disabled=${item.disabled}
@click=${this._valueSelected}
>
${renderer(item, index)}
@@ -437,10 +448,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this._listScrolled = top > 0;
}
private _valueSelected = (ev: MouseEvent) => {
private _valueSelected = (
ev: MouseEvent & HASSDomCurrentTargetEvent<PickerComboBoxRowElement>
) => {
ev.stopPropagation();
const value = (ev.currentTarget as any).value as string;
const index = Number((ev.currentTarget as any).index);
const { disabled, index, value } = ev.currentTarget;
if (disabled) {
return;
}
const newValue = value?.trim();
const newTab = ev.ctrlKey || ev.metaKey;
@@ -728,7 +743,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
(
this.virtualizerElement?.items as (PickerComboBoxItem | string)[]
).forEach((item, index) => {
if (typeof item !== "string") {
if (typeof item !== "string" && !item.disabled) {
this._fireSelectedEvents(item.id, index, newTab);
}
});
@@ -748,7 +763,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
const item = this.virtualizerElement?.items[
this._selectedItemIndex
] as PickerComboBoxItem;
if (item) {
if (item && !item.disabled) {
this._fireSelectedEvents(item.id, this._selectedItemIndex, newTab);
}
};
@@ -68,7 +68,7 @@ export class HaSelectorAttribute extends LitElement {
}
// Validate that that the attribute is still valid for this entity, else unselect.
let invalid = false;
let invalid: boolean;
if (this.context.filter_entity) {
const entityIds = ensureArray(this.context.filter_entity);
@@ -70,6 +70,7 @@ export class HaEntitySelector extends LitElement {
.helper=${this.helper}
.includeEntities=${this.selector.entity?.include_entities}
.excludeEntities=${this.selector.entity?.exclude_entities}
.extraOptions=${this.selector.entity?.extra_options}
.entityFilter=${this._filterEntities}
.createDomains=${this._createDomains}
.disabled=${this.disabled}
@@ -99,7 +99,6 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.noEntity=${this.selector.state?.no_entity ?? false}
.disabled=${this.disabled}
.required=${this.required}
allow-custom-value
@@ -0,0 +1,41 @@
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { UiClockDateFormatSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-clock-date-format-picker";
@customElement("ha-selector-ui_clock_date_format")
export class HaSelectorUiClockDateFormat extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: UiClockDateFormatSelector;
@property() public value?: string | string[];
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
protected render() {
return html`
<ha-clock-date-format-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-clock-date-format-picker>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-ui_clock_date_format": HaSelectorUiClockDateFormat;
}
}
@@ -64,6 +64,7 @@ const LOAD_ELEMENTS = {
location: () => import("./ha-selector-location"),
color_temp: () => import("./ha-selector-color-temp"),
ui_action: () => import("./ha-selector-ui-action"),
ui_clock_date_format: () => import("./ha-selector-ui-clock-date-format"),
ui_color: () => import("./ha-selector-ui-color"),
ui_state_content: () => import("./ha-selector-ui-state-content"),
};
+5 -2
View File
@@ -234,7 +234,10 @@ export class HaToast extends LitElement {
border-radius: var(--ha-border-radius-sm);
box-shadow: var(--wa-shadow-l);
opacity: 0;
transform: translate(-50%, var(--ha-space-2));
transform: translate(
calc(-50% * var(--scale-direction)),
var(--ha-space-2)
);
transition:
opacity var(--ha-animation-duration-fast, 150ms) ease,
transform var(--ha-animation-duration-fast, 150ms) ease;
@@ -242,7 +245,7 @@ export class HaToast extends LitElement {
.toast.visible {
opacity: 1;
transform: translate(-50%, 0);
transform: translate(calc(-50% * var(--scale-direction)), 0);
}
.toast:not(.active) {
+1 -1
View File
@@ -602,7 +602,7 @@ export class HaMap extends ReactiveElement {
}
// create icon
let iconHTML = "";
let iconHTML: string;
if (icon) {
const el = document.createElement("ha-icon");
el.setAttribute("icon", icon);
@@ -0,0 +1,264 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume, type ContextType } from "@lit/context";
import { mdiMonitor } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { supportsFeature } from "../../common/entity/supports-feature";
import {
areasContext,
configContext,
devicesContext,
entitiesContext,
floorsContext,
internationalizationContext,
statesContext,
} from "../../data/context";
import { UNAVAILABLE } from "../../data/entity/entity";
import {
entityComboBoxKeys,
type EntityComboBoxItem,
} from "../../data/entity/entity_picker";
import { domainToName } from "../../data/integration";
import {
BROWSER_PLAYER,
type MediaPlayerEntity,
MediaPlayerEntityFeature,
} from "../../data/media-player";
import "../entity/state-badge";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type {
PickerComboBoxItem,
PickerComboBoxSearchFn,
} from "../ha-picker-combo-box";
import "../ha-svg-icon";
interface BrowserPlayerComboBoxItem extends EntityComboBoxItem {
id: typeof BROWSER_PLAYER;
icon_path: string;
stateObj?: never;
}
interface MediaPlayerComboBoxItem extends EntityComboBoxItem {
icon_path?: never;
stateObj: MediaPlayerEntity;
}
type PlayerComboBoxItem = BrowserPlayerComboBoxItem | MediaPlayerComboBoxItem;
@customElement("ha-media-player-picker")
export class HaMediaPlayerPicker extends LitElement {
@property() public value?: string;
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@consume({ context: entitiesContext, subscribe: true })
private _entities!: ContextType<typeof entitiesContext>;
@consume({ context: devicesContext, subscribe: true })
private _devices!: ContextType<typeof devicesContext>;
@consume({ context: areasContext, subscribe: true })
private _areas!: ContextType<typeof areasContext>;
@consume({ context: floorsContext, subscribe: true })
private _floors!: ContextType<typeof floorsContext>;
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@consume({ context: configContext, subscribe: true })
private _hassConfig!: ContextType<typeof configContext>;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
protected render() {
return html`
<ha-generic-picker
.value=${this.value}
.getItems=${this._getPlayerItems}
.rowRenderer=${this._playerRowRenderer}
.searchFn=${this._playerSearchFn}
.searchKeys=${entityComboBoxKeys}
.notFoundLabel=${this._notFoundPlayerLabel}
.popoverPlacement=${"top-end"}
.hideClearIcon=${true}
@value-changed=${this._valueChanged}
>
<slot name="field" slot="field" @click=${this.open}></slot>
</ha-generic-picker>
`;
}
public open(ev?: Event): void {
this._picker?.open(ev, { selectedValue: this.value });
}
private _getPlayerItems = (): PlayerComboBoxItem[] => {
const webBrowserLabel = this._i18n.localize(
"ui.components.media-browser.web-browser"
);
const lang = this._i18n.language || "en";
const isRTL =
this._i18n.translationMetadata.translations[lang]?.isRTL || false;
return [
{
id: BROWSER_PLAYER,
primary: webBrowserLabel,
icon_path: mdiMonitor,
search_labels: {
entityName: webBrowserLabel,
friendlyName: webBrowserLabel,
deviceName: null,
areaName: null,
domainName: null,
entityId: BROWSER_PLAYER,
},
},
...Object.values(this._states)
.filter(this._filterPlayerEntities)
.map<MediaPlayerComboBoxItem>((stateObj) => {
const friendlyName = computeStateName(stateObj);
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this._entities,
this._devices,
this._areas,
this._floors
);
const entityId = stateObj.entity_id;
const domainName = domainToName(
this._i18n.localize,
computeDomain(entityId)
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return {
id: entityId,
primary,
secondary,
disabled: stateObj.state === UNAVAILABLE,
domain_name: domainName,
sorting_label: [primary, secondary].filter(Boolean).join("_"),
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
areaName: areaName || null,
domainName: domainName || null,
friendlyName: friendlyName || null,
entityId,
},
stateObj,
};
}),
];
};
private _filterPlayerEntities = (
entity: HassEntity
): entity is MediaPlayerEntity =>
computeStateDomain(entity) === "media_player" &&
supportsFeature(entity, MediaPlayerEntityFeature.BROWSE_MEDIA) &&
!this._entities[entity.entity_id]?.hidden;
private _playerRowRenderer: RenderItemFunction<PickerComboBoxItem> = (
item: PickerComboBoxItem,
index: number
) => {
const stateObj = this._isMediaPlayerItem(item) ? item.stateObj : undefined;
return html`
<div
style=${styleMap({
width: "100%",
borderTop: index === 0 ? undefined : "1px solid var(--divider-color)",
})}
>
<ha-combo-box-item type="button" compact .disabled=${!!item.disabled}>
${item.icon_path
? html`
<ha-svg-icon
slot="start"
style="margin: 0 4px"
.path=${item.icon_path}
></ha-svg-icon>
`
: html`
<state-badge slot="start" .stateObj=${stateObj}></state-badge>
`}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${stateObj && this._hassConfig.userData?.showEntityIdPicker
? html`
<span slot="supporting-text" class="code">
${stateObj.entity_id}
</span>
`
: nothing}
</ha-combo-box-item>
</div>
`;
};
private _playerSearchFn: PickerComboBoxSearchFn<PickerComboBoxItem> = (
search: string,
filteredItems: PickerComboBoxItem[]
) => {
const index = filteredItems.findIndex((item) => item.id === search);
if (index === -1) {
return filteredItems;
}
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
};
private _isMediaPlayerItem(
item: PickerComboBoxItem
): item is MediaPlayerComboBoxItem {
return "stateObj" in item && item.stateObj !== undefined;
}
private _notFoundPlayerLabel = (search: string) =>
this._i18n.localize("ui.components.entity.entity-picker.no_match", {
term: html`<b>${search}</b>`,
});
private _valueChanged(ev: HASSDomEvent<{ value: string }>): void {
ev.stopPropagation();
if (!ev.detail.value) {
return;
}
fireEvent(this, "value-changed", { value: ev.detail.value });
}
static styles = css`
ha-generic-picker {
--ha-generic-picker-width: min(360px, calc(100vw - 32px));
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-media-player-picker": HaMediaPlayerPicker;
}
}
@@ -428,7 +428,8 @@ export class HaTargetPickerItemRow extends LitElement {
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
@@ -458,7 +459,8 @@ export class HaTargetPickerItemRow extends LitElement {
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
this.entityFilter,
!this.primaryEntitiesOnly
)
) {
return true;
+3 -5
View File
@@ -328,13 +328,12 @@ const describeLegacyTrigger = (
let fromChoice = "other";
let fromString = "";
if (trigger.from !== undefined) {
let fromArray: string[] = [];
if (trigger.from === null) {
if (!trigger.attribute) {
fromChoice = "null";
}
} else {
fromArray = ensureArray(trigger.from);
const fromArray = ensureArray(trigger.from);
const from: string[] = [];
for (const state of fromArray) {
@@ -362,13 +361,12 @@ const describeLegacyTrigger = (
let toChoice = "other";
let toString = "";
if (trigger.to !== undefined) {
let toArray: string[] = [];
if (trigger.to === null) {
if (!trigger.attribute) {
toChoice = "null";
}
} else {
toArray = ensureArray(trigger.to);
const toArray = ensureArray(trigger.to);
const to: string[] = [];
for (const state of toArray) {
@@ -521,7 +519,7 @@ const describeLegacyTrigger = (
| "every_interval"
| "on_the_xth"
| "other"
| "has_seconds_or_minutes" = "other";
| "has_seconds_or_minutes";
let seconds = 0;
let minutes = 0;
+6 -18
View File
@@ -1235,13 +1235,7 @@ export const computeConsumptionSingle = (data: {
(to_grid || 0) -
(to_battery || 0);
let used_solar = 0;
let grid_to_battery = 0;
let battery_to_grid = 0;
let solar_to_battery = 0;
let solar_to_grid = 0;
let used_battery = 0;
let used_grid = 0;
let used_total_remaining = Math.max(used_total, 0);
// Consumption Priority
@@ -1266,40 +1260,34 @@ export const computeConsumptionSingle = (data: {
// Fill the remainder of the battery input from solar
// Solar -> Battery_In
solar_to_battery = Math.min(solar, to_battery);
const solar_to_battery = Math.min(solar, to_battery);
to_battery -= solar_to_battery;
solar -= solar_to_battery;
// Solar -> Grid_Out
solar_to_grid = Math.min(solar, to_grid);
const solar_to_grid = Math.min(solar, to_grid);
to_grid -= solar_to_grid;
solar -= solar_to_grid;
// Battery_Out -> Grid_Out
battery_to_grid = Math.min(from_battery, to_grid);
const battery_to_grid = Math.min(from_battery, to_grid);
from_battery -= battery_to_grid;
to_grid -= battery_to_grid;
// Grid_In -> Battery_In (second pass)
const grid_to_battery_2 = Math.min(from_grid, to_battery);
grid_to_battery += grid_to_battery_2;
from_grid -= grid_to_battery_2;
to_battery -= grid_to_battery_2;
// Solar -> Consumption
used_solar = Math.min(used_total_remaining, solar);
const used_solar = Math.min(used_total_remaining, solar);
used_total_remaining -= used_solar;
solar -= used_solar;
// Battery_Out -> Consumption
used_battery = Math.min(from_battery, used_total_remaining);
from_battery -= used_battery;
const used_battery = Math.min(from_battery, used_total_remaining);
used_total_remaining -= used_battery;
// Grid_In -> Consumption
used_grid = Math.min(used_total_remaining, from_grid);
from_grid -= used_grid;
used_total_remaining -= from_grid;
const used_grid = Math.min(used_total_remaining, from_grid);
return {
used_solar,
+24
View File
@@ -187,6 +187,30 @@ export const NON_NUMERIC_ATTRIBUTES = [
"xy_color",
];
export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
"access_token",
"available_modes",
"color_modes",
"editable",
"effect_list",
"entity_picture",
"event_types",
"fan_modes",
"fan_speed_list",
"forecast",
"friendly_name",
"hvac_modes",
"icon",
"operation_list",
"options",
"preset_modes",
"sound_mode_list",
"source_list",
"state_class",
"swing_modes",
"token",
];
export const computeShownAttributes = (stateObj: HassEntity) => {
const domain = computeStateDomain(stateObj);
const filtersArray = STATE_ATTRIBUTES.concat(
+1 -1
View File
@@ -53,7 +53,7 @@ export const getEntities = (
value?: string,
idPrefix = ""
): EntityComboBoxItem[] => {
let items: EntityComboBoxItem[] = [];
let items: EntityComboBoxItem[];
let entityIds = Object.keys(hass.states);
+2
View File
@@ -52,6 +52,7 @@ import {
mdiThermostat,
mdiTimerOutline,
mdiToggleSwitch,
mdiVideoInputAntenna,
mdiWater,
mdiWaterPercent,
mdiWeatherPartlyCloudy,
@@ -128,6 +129,7 @@ export const FALLBACK_DOMAIN_ICONS = {
plant: mdiFlower,
power: mdiFlash,
proximity: mdiAppleSafari,
radio_frequency: mdiVideoInputAntenna,
remote: mdiRemote,
scene: mdiPalette,
schedule: mdiCalendarClock,
+16 -3
View File
@@ -16,6 +16,8 @@ import {
mdiPlayPause,
mdiPodcast,
mdiPower,
mdiPowerOff,
mdiPowerOn,
mdiRepeat,
mdiRepeatOff,
mdiRepeatOnce,
@@ -286,7 +288,9 @@ export const computeMediaControls = (
return undefined;
}
if (!stateActive(stateObj)) {
const assumedState = stateObj.attributes.assumed_state === true;
if (!stateActive(stateObj) && !assumedState) {
return supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_ON)
? [
{
@@ -299,14 +303,23 @@ export const computeMediaControls = (
const buttons: ControlButton[] = [];
if (
assumedState &&
supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_ON)
) {
buttons.push({
icon: mdiPowerOn,
action: "turn_on",
});
}
if (supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_OFF)) {
buttons.push({
icon: mdiPower,
icon: assumedState ? mdiPowerOff : mdiPower,
action: "turn_off",
});
}
const assumedState = stateObj.attributes.assumed_state === true;
const stateAttr = stateObj.attributes;
if (
+1
View File
@@ -16,6 +16,7 @@ export const SCENE_IGNORED_DOMAINS = [
"input_button",
"persistent_notification",
"person",
"radio_frequency",
"scene",
"schedule",
"script",
+16 -1
View File
@@ -79,6 +79,7 @@ export type Selector =
| TTSVoiceSelector
| SerialPortSelector
| UiActionSelector
| UiClockDateFormatSelector
| UiColorSelector
| UiStateContentSelector
| BackupLocationSelector;
@@ -249,6 +250,16 @@ interface EntitySelectorFilter {
unit_of_measurement?: string | readonly string[];
}
export interface EntitySelectorExtraOption {
id: string;
primary: string;
secondary?: string;
icon?: string;
icon_path?: string;
entity_id?: string;
hide_clear?: boolean;
}
export interface EntitySelector {
entity: {
multiple?: boolean;
@@ -256,6 +267,7 @@ export interface EntitySelector {
exclude_entities?: string[];
filter?: EntitySelectorFilter | readonly EntitySelectorFilter[];
reorder?: boolean;
extra_options?: EntitySelectorExtraOption[];
} | null;
}
@@ -463,7 +475,6 @@ export interface StateSelector {
attribute?: string;
hide_states?: string[];
multiple?: boolean;
no_entity?: boolean;
} | null;
}
@@ -549,6 +560,10 @@ export interface UiActionSelector {
} | null;
}
export interface UiClockDateFormatSelector {
ui_clock_date_format: {} | null;
}
export interface UiColorExtraOption {
value: string;
label: string;
+27 -20
View File
@@ -1,4 +1,5 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import { computeDomain } from "../common/entity/compute_domain";
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
@@ -58,23 +59,26 @@ export const extractFromTarget = async (
primary_entities_only: primaryEntitiesOnly,
});
export const getResolvedTargetEntityCount = async (
hass: HomeAssistant,
target?: HassServiceTarget
): Promise<number | undefined> => {
if (!target) {
return undefined;
export const getTargetEntityCount = (target?: HassServiceTarget): number => {
const tempTarget = {
entity_id: target?.entity_id ? ensureArray(target?.entity_id) : [],
device_id: target?.device_id ? ensureArray(target?.device_id) : [],
area_id: target?.area_id ? ensureArray(target?.area_id) : [],
floor_id: target?.floor_id ? ensureArray(target?.floor_id) : [],
label_id: target?.label_id ? ensureArray(target?.label_id) : [],
};
if (
tempTarget?.device_id?.length > 0 ||
tempTarget?.area_id?.length > 0 ||
tempTarget?.floor_id?.length > 0 ||
tempTarget?.label_id?.length > 0
) {
// if targeting non entities the number of entities is dynamic
return Infinity;
}
try {
return (await extractFromTarget(hass, target, true)).referenced_entities
.length;
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error resolving target entity count", err);
}
return undefined;
return tempTarget?.entity_id?.length;
};
export const getTriggersForTarget = async (
@@ -118,7 +122,8 @@ export const areaMeetsFilter = (
includeDomains?: string[],
includeDeviceClasses?: string[],
states?: HomeAssistant["states"],
entityFilter?: HaEntityPickerEntityFilterFunc
entityFilter?: HaEntityPickerEntityFilterFunc,
includeSecondary = false
): boolean => {
const areaDevices = Object.values(devices).filter(
(device) => device.area_id === area.area_id
@@ -133,7 +138,8 @@ export const areaMeetsFilter = (
includeDomains,
includeDeviceClasses,
states,
entityFilter
entityFilter,
includeSecondary
)
)
) {
@@ -148,7 +154,7 @@ export const areaMeetsFilter = (
areaEntities.some((entity) =>
entityRegMeetsFilter(
entity,
false,
includeSecondary,
includeDomains,
includeDeviceClasses,
states,
@@ -169,7 +175,8 @@ export const deviceMeetsFilter = (
includeDomains?: string[],
includeDeviceClasses?: string[],
states?: HomeAssistant["states"],
entityFilter?: HaEntityPickerEntityFilterFunc
entityFilter?: HaEntityPickerEntityFilterFunc,
includeSecondary = false
): boolean => {
const devEntities = Object.values(entities).filter(
(entity) => entity.device_id === device.id
@@ -179,7 +186,7 @@ export const deviceMeetsFilter = (
!devEntities.some((entity) =>
entityRegMeetsFilter(
entity,
false,
includeSecondary,
includeDomains,
includeDeviceClasses,
states,
+1 -1
View File
@@ -67,6 +67,7 @@ export type ActionTraceStep =
interface BaseTrace {
domain: string;
error?: string;
item_id: string;
last_step: string | null;
run_id: string;
@@ -97,7 +98,6 @@ interface BaseTrace {
interface BaseTraceExtended {
trace: Record<string, ActionTraceStep[]>;
context: Context;
error?: string;
}
export interface AutomationTrace extends BaseTrace {
+3 -3
View File
@@ -1,10 +1,10 @@
import type { HomeAssistant } from "../types";
export interface CommonControlResult {
export interface CommonControlsResult {
entities: string[];
}
export const getCommonControlUsagePrediction = (hass: HomeAssistant) =>
hass.callWS<CommonControlResult>({
export const getCommonControlsUsagePrediction = (hass: HomeAssistant) =>
hass.callWS<CommonControlsResult>({
type: "usage_prediction/common_control",
});
+10
View File
@@ -57,6 +57,16 @@ export interface ForecastAttribute {
wind_speed?: string;
}
export type ForecastPrecipitationType = "amount" | "probability";
export const getForecastPrecipitation = (
entry: ForecastAttribute,
type: ForecastPrecipitationType
) =>
type === "probability"
? entry.precipitation_probability
: entry.precipitation;
interface WeatherEntityAttributes extends HassEntityAttributeBase {
attribution?: string;
humidity?: number;
+5 -1
View File
@@ -495,8 +495,12 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
private _goToAddEntityTo(ev) {
// Only check for request-selected events (from menu items), not regular clicks (from icon button)
if (ev.type === "request-selected" && !shouldHandleRequestSelectedEvent(ev))
if (
ev.type === "request-selected" &&
!shouldHandleRequestSelectedEvent(ev)
) {
return;
}
this._setView("add_to");
}
+25 -17
View File
@@ -53,7 +53,11 @@ class MoreInfoContent extends LitElement {
if (!moreInfoType) return nothing;
const memberIds = this._getEntityMemberIds(this.stateObj);
const memberIds = this._getEntityMemberIds(
this.stateObj,
this.entry,
this.hass.entities
);
return html`
${dynamicElement(moreInfoType, {
@@ -75,23 +79,27 @@ class MoreInfoContent extends LitElement {
`;
}
private _getEntityMemberIds(stateObj: HassEntity): string[] | undefined {
if (computeStateDomain(stateObj) === "group") {
// Don't show entity members for legacy groups as they already show
// the members in their more info dialog.
return undefined;
private _getEntityMemberIds = memoizeOne(
(
stateObj: HassEntity,
entry: ExtEntityRegistryEntry | null | undefined,
entities: HomeAssistant["entities"]
): string[] | undefined => {
if (computeStateDomain(stateObj) === "group") {
// Don't show entity members for legacy groups as they already show
// the members in their more info dialog.
return undefined;
}
const memberIds =
(entry?.capabilities?.group_entities as string[] | undefined) ??
(Array.isArray(stateObj.attributes.entity_id)
? (stateObj.attributes.entity_id as string[])
: undefined);
return memberIds?.filter((entityId) => !entities[entityId]?.hidden);
}
const memberIds =
(this.entry?.capabilities?.group_entities as string[] | undefined) ??
(Array.isArray(stateObj.attributes.entity_id)
? (stateObj.attributes.entity_id as string[])
: undefined);
return memberIds?.filter(
(entityId) => !this.hass!.entities[entityId]?.hidden
);
}
);
private _entitiesSectionConfig = memoizeOne((entityIds: string[]) => {
const hass = this.hass!;
@@ -456,10 +456,7 @@ export class HaVoiceAssistantSetupStepLocal extends LitElement {
);
let i = 1;
while (
pipelines.pipelines.find(
// eslint-disable-next-line no-loop-func
(pipeline) => pipeline.name === pipelineName
)
pipelines.pipelines.find((pipeline) => pipeline.name === pipelineName)
) {
pipelineName = `${this.hass.localize(`ui.panel.config.voice_assistants.satellite_wizard.local.${this.localOption}_pipeline`)} ${i}`;
i++;
@@ -343,10 +343,7 @@ export class HaVoiceAssistantSetupStepPipeline extends LitElement {
let pipelineName = "Home Assistant Cloud";
let i = 1;
while (
pipelines.pipelines.find(
// eslint-disable-next-line no-loop-func
(pipeline) => pipeline.name === pipelineName
)
pipelines.pipelines.find((pipeline) => pipeline.name === pipelineName)
) {
pipelineName = `Home Assistant Cloud ${i}`;
i++;
+1 -2
View File
@@ -283,8 +283,7 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
.label=${localize("ui.components.subpage-data-table.sort_by", {
sortColumn:
this._sortColumn && this.columns[this._sortColumn]
? ` ${this.columns[this._sortColumn].title || this.columns[this._sortColumn].label}` ||
""
? ` ${this.columns[this._sortColumn].title || this.columns[this._sortColumn].label}`
: "",
})}
>
+4 -1
View File
@@ -74,7 +74,10 @@ export const ConditionalListenerMixin = <
protected willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties);
if (changedProperties.has("_maxColumns")) {
this._conditionContext = { max_columns: this._maxColumns };
this._conditionContext = {
...this._conditionContext,
max_columns: this._maxColumns,
};
}
}
@@ -1,12 +1,12 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import "@home-assistant/webawesome/dist/components/popover/popover";
import type WaPopover from "@home-assistant/webawesome/dist/components/popover/popover";
import {
mdiDelete,
mdiDotsVertical,
mdiFloorPlan,
mdiHelpCircleOutline,
mdiPencil,
mdiPlus,
mdiSofa,
mdiSort,
} from "@mdi/js";
import {
@@ -17,7 +17,7 @@ import {
type PropertyValues,
type TemplateResult,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import {
@@ -88,8 +88,6 @@ export class HaConfigAreasDashboard extends LitElement {
@state() private _hierarchy?: AreasFloorHierarchy;
@query("wa-popover") private _popover?: WaPopover;
private _searchParms = new URLSearchParams(window.location.search);
private _blockHierarchyUpdate = false;
@@ -322,26 +320,20 @@ export class HaConfigAreasDashboard extends LitElement {
`
: nothing}
</div>
<ha-button id="fab" slot="fab" size="large">
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.common.add")}
</ha-button>
<wa-popover
trap-focus
placement="top-start"
distance="8"
without-arrow
for="fab"
>
<ha-button appearance="filled" @click=${this._createFloor}>
<ha-dropdown slot="fab" @wa-select=${this._handleCreateAction}>
<ha-button slot="trigger" id="fab" size="large">
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.common.add")}
</ha-button>
<ha-dropdown-item value="create_floor">
<ha-svg-icon .path=${mdiFloorPlan} slot="icon"></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.picker.create_floor")}
</ha-button>
<ha-button appearance="filled" @click=${this._createArea}>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="create_area">
<ha-svg-icon .path=${mdiSofa} slot="icon"></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.picker.create_area")}
</ha-button>
</wa-popover>
</ha-dropdown-item>
</ha-dropdown>
</hass-tabs-subpage>
`;
}
@@ -561,8 +553,16 @@ export class HaConfigAreasDashboard extends LitElement {
}
}
private _handleCreateAction(ev: HaDropdownSelectEvent) {
const action = ev.detail.item.value;
if (action === "create_floor") {
this._createFloor();
} else if (action === "create_area") {
this._createArea();
}
}
private _createFloor() {
this._popover?.hide();
this._openFloorDialog();
}
@@ -588,7 +588,6 @@ export class HaConfigAreasDashboard extends LitElement {
}
private _createArea() {
this._popover?.hide();
this._openAreaDialog();
}
@@ -730,14 +729,6 @@ export class HaConfigAreasDashboard extends LitElement {
align-items: center;
overflow-wrap: anywhere;
}
wa-popover::part(body) {
gap: var(--ha-space-2);
background-color: transparent;
border-color: transparent;
box-shadow: none;
padding: 0;
}
`;
}
@@ -249,10 +249,14 @@ export default class HaAutomationActionRow extends LitElement {
"target" in
(this.hass.services?.[computeDomain(action)]?.[
computeObjectId(action)
] || {});
] || {}) &&
// special case for reload config entry as it has an optional target but mainly uses entry_id
((this.action as ServiceAction).action !==
"homeassistant.reload_config_entry" ||
!(this.action as ServiceAction).data?.entry_id);
const target = actionHasTarget
? (this.action as ServiceAction).target
? this._extractTargets(this.action as ServiceAction)
: type === "device_id" && (this.action as DeviceAction).device_id
? { device_id: (this.action as DeviceAction).device_id }
: undefined;
@@ -617,6 +621,18 @@ export default class HaAutomationActionRow extends LitElement {
`;
}
private _extractTargets(action: ServiceAction): HassServiceTarget {
if (action.target) {
return action.target;
}
// legacy support for entity_id
if (action.entity_id) {
return { entity_id: action.entity_id };
}
return {};
}
private _renderTargets = memoizeOne(
(target?: HassServiceTarget, targetRequired = false) =>
html`<ha-automation-row-targets
@@ -138,7 +138,7 @@ export class HaConditionAction
const isDynamicValue = isDynamic(value);
const condition = isDynamicValue ? getValueFromDynamic(value) : value;
let label = condition;
let label: string;
if (isDynamicValue) {
const domain = getConditionDomain(condition);
@@ -16,7 +16,7 @@ import {
import type { IntegrationManifest } from "../../../../../data/integration";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import type { TargetSelector } from "../../../../../data/selector";
import { getResolvedTargetEntityCount } from "../../../../../data/target";
import { getTargetEntityCount } from "../../../../../data/target";
import type { HomeAssistant } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
@@ -432,35 +432,13 @@ export class HaPlatformCondition extends LitElement {
}
}
private _resolveTargetEntityCount = memoizeOne(
async (target: PlatformCondition["target"]) =>
getResolvedTargetEntityCount(this.hass, target)
);
private async _updateResolvedTargetEntityCount(
private _updateResolvedTargetEntityCount(
target: PlatformCondition["target"]
) {
this._resolvedTargetEntityCount =
await this._resolveTargetEntityCount(target);
this._resolvedTargetEntityCount = getTargetEntityCount(target);
if (
(!target ||
(this._resolvedTargetEntityCount !== undefined &&
this._resolvedTargetEntityCount <= 1)) &&
this.condition.options?.behavior !== undefined
) {
const options = { ...this.condition.options };
delete options.behavior;
fireEvent(this, "value-changed", {
value: {
...this.condition,
options,
},
});
} else if (
target &&
this._resolvedTargetEntityCount !== undefined &&
this._resolvedTargetEntityCount > 1 &&
this.condition.options?.behavior === undefined
) {
@@ -18,6 +18,7 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { StateCondition } from "../../../../../data/automation";
import { STATE_CONDITION_HIDDEN_ATTRIBUTES } from "../../../../../data/entity/entity_attributes";
import type { HomeAssistant } from "../../../../../types";
import { forDictStruct } from "../../structs";
import type { ConditionElement } from "../ha-automation-condition-row";
@@ -38,29 +39,7 @@ const SCHEMA = [
name: "attribute",
selector: {
attribute: {
hide_attributes: [
"access_token",
"available_modes",
"color_modes",
"editable",
"effect_list",
"entity_picture",
"event_types",
"fan_modes",
"fan_speed_list",
"forecast",
"friendly_name",
"hvac_modes",
"icon",
"operation_list",
"options",
"preset_modes",
"sound_mode_list",
"source_list",
"state_class",
"swing_modes",
"token",
],
hide_attributes: STATE_CONDITION_HIDDEN_ATTRIBUTES,
},
},
context: {
@@ -1,18 +1,24 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import {
mdiAlertCircleOutline,
mdiCheckCircleOutline,
mdiChevronDown,
mdiDotsVertical,
mdiDownload,
mdiHelpCircleOutline,
mdiInformationOutline,
mdiPencil,
mdiProgressClock,
mdiProgressWrench,
mdiRayEndArrow,
mdiRayStartArrow,
mdiRefresh,
mdiStopCircleOutline,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { formatDateTimeWithSeconds } from "../../../common/datetime/format_date_time";
import { fireEvent } from "../../../common/dom/fire_event";
@@ -47,6 +53,9 @@ import "../../../layouts/hass-subpage";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { fileDownload } from "../../../util/file_download";
import "../../../components/ha-generic-picker";
import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box";
import type { HaGenericPicker } from "../../../components/ha-generic-picker";
const TABS = ["details", "timeline", "logbook", "automation_config"] as const;
@@ -78,6 +87,8 @@ export class HaAutomationTrace extends LitElement {
@state() private _view: (typeof TABS)[number] | "blueprint" = "details";
@query("ha-generic-picker") private tracePicker?: HaGenericPicker;
@query("hat-script-graph") private _graph?: HatScriptGraph;
protected render(): TemplateResult {
@@ -180,20 +191,32 @@ export class HaAutomationTrace extends LitElement {
this._runId}
@click=${this._pickOlderTrace}
></ha-icon-button>
<select .value=${this._runId} @change=${this._pickTrace}>
${repeat(
this._traces,
(trace) => trace.run_id,
(trace) =>
html`<option value=${trace.run_id}>
${formatDateTimeWithSeconds(
new Date(trace.timestamp.start),
this.hass.locale,
this.hass.config
)}
</option>`
<ha-generic-picker
name="trace"
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.automation.trace.select_trace"
)}
</select>
.value=${this._runId}
.getItems=${this._getTraces}
required
@value-changed=${this._pickTrace}
>
<ha-button
slot="field"
appearance="filled"
variant="neutral"
size="small"
@click=${this._openPicker}
>
${this._renderTracePickerValue(this._runId!)}
<ha-svg-icon
slot="end"
.path=${mdiChevronDown}
></ha-svg-icon>
</ha-button>
</ha-generic-picker>
<ha-icon-button
.label=${this.hass!.localize(
"ui.panel.config.automation.trace.newer_trace"
@@ -321,6 +344,118 @@ export class HaAutomationTrace extends LitElement {
`;
}
private _openPicker(ev: Event) {
ev.stopPropagation();
this.tracePicker?.open();
}
private _getTraces = (): PickerComboBoxItem[] =>
this._traces?.map((trace) => {
const renderRuntime = () =>
(
(new Date(trace.timestamp.finish!).getTime() -
new Date(trace.timestamp.start).getTime()) /
1000
).toFixed(2);
const item: PickerComboBoxItem = {
id: trace.run_id,
primary: formatDateTimeWithSeconds(
new Date(trace.timestamp.start),
this.hass.locale,
this.hass.config
),
};
if (trace.state === "running") {
item.secondary = this.hass.localize(
"ui.panel.config.automation.trace.picker.still_running"
);
item.icon_path = mdiProgressClock;
} else if (trace.state === "debugged") {
item.secondary = this.hass.localize(
"ui.panel.config.automation.trace.picker.debugged"
);
item.icon_path = mdiProgressWrench;
} else if (trace.script_execution === "finished") {
item.secondary = this.hass.localize(
"ui.panel.config.automation.trace.picker.finished",
{
executiontime: renderRuntime(),
}
);
item.icon_path = mdiCheckCircleOutline;
} else if (trace.script_execution === "aborted") {
item.secondary = this.hass.localize(
"ui.panel.config.automation.trace.picker.aborted",
{
executiontime: renderRuntime(),
}
);
item.icon_path = mdiAlertCircleOutline;
} else if (trace.script_execution === "cancelled") {
item.secondary = this.hass.localize(
"ui.panel.config.automation.trace.picker.cancelled",
{
executiontime: renderRuntime(),
}
);
item.icon_path = mdiAlertCircleOutline;
} else {
let message:
| "stopped_failed_conditions"
| "stopped_failed_single"
| "stopped_failed_max_runs"
| "stopped_error"
| "stopped_unknown_reason";
let error: string | undefined;
let icon: string;
switch (trace.script_execution) {
case "failed_conditions":
message = "stopped_failed_conditions";
icon = mdiStopCircleOutline;
break;
case "failed_single":
message = "stopped_failed_single";
icon = mdiStopCircleOutline;
break;
case "failed_max_runs":
message = "stopped_failed_max_runs";
icon = mdiStopCircleOutline;
break;
case "error":
message = "stopped_error";
error = trace.error!;
icon = mdiAlertCircleOutline;
break;
default:
message = "stopped_unknown_reason";
icon = mdiHelpCircleOutline;
}
item.secondary = this.hass.localize(
`ui.panel.config.automation.trace.picker.${message}`,
{
error,
executiontime: renderRuntime(),
}
);
item.icon_path = icon;
}
return item;
}) ?? [];
private _renderTracePickerValue = (runId: string) => {
const trace = this._traces?.find((t) => t.run_id === runId);
return html`${trace
? formatDateTimeWithSeconds(
new Date(trace.timestamp.start),
this.hass.locale,
this.hass.config
)
: runId}`;
};
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
@@ -378,7 +513,7 @@ export class HaAutomationTrace extends LitElement {
}
private _pickTrace(ev) {
this._runId = ev.target.value;
this._runId = ev.detail.value;
this._selected = undefined;
}
@@ -618,6 +753,13 @@ export class HaAutomationTrace extends LitElement {
ha-trace-logbook {
direction: var(--direction);
}
ha-generic-picker {
flex-grow: 1;
max-width: 500px;
}
ha-generic-picker > ha-button {
width: 100%;
}
`,
];
}
@@ -11,7 +11,7 @@ import type { PlatformTrigger } from "../../../../../data/automation";
import type { IntegrationManifest } from "../../../../../data/integration";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import type { TargetSelector } from "../../../../../data/selector";
import { getResolvedTargetEntityCount } from "../../../../../data/target";
import { getTargetEntityCount } from "../../../../../data/target";
import {
getTriggerDomain,
getTriggerObjectId,
@@ -467,35 +467,11 @@ export class HaPlatformTrigger extends LitElement {
}
}
private _resolveTargetEntityCount = memoizeOne(
async (target: PlatformTrigger["target"]) =>
getResolvedTargetEntityCount(this.hass, target)
);
private async _updateResolvedTargetEntityCount(
target: PlatformTrigger["target"]
) {
this._resolvedTargetEntityCount =
await this._resolveTargetEntityCount(target);
private _updateResolvedTargetEntityCount(target: PlatformTrigger["target"]) {
this._resolvedTargetEntityCount = getTargetEntityCount(target);
if (
(!target ||
(this._resolvedTargetEntityCount !== undefined &&
this._resolvedTargetEntityCount <= 1)) &&
this.trigger.options?.behavior !== undefined
) {
const options = { ...this.trigger.options };
delete options.behavior;
fireEvent(this, "value-changed", {
value: {
...this.trigger,
options,
},
});
} else if (
target &&
this._resolvedTargetEntityCount !== undefined &&
this._resolvedTargetEntityCount > 1 &&
this.trigger.options?.behavior === undefined
) {
@@ -85,7 +85,9 @@ class DialogImportBlueprint extends LitElement {
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title" @click=${this._enlarge}> ${heading} </span>
<span slot="title" class="title-enlargeable" @click=${this._enlarge}>
${heading}
</span>
</ha-dialog-header>
<div>
${this._error
@@ -344,6 +346,9 @@ class DialogImportBlueprint extends LitElement {
ha-expansion-panel {
--expansion-panel-content-padding: 0px;
}
.title-enlargeable {
display: block;
}
`,
];
}
@@ -58,7 +58,7 @@ class HaConfigSystemNavigation extends LitElement {
const pages = configSections.general
.filter((page) => canShowPage(this.hass, page))
.map((page) => {
let description = "";
let description: string;
switch (page.translationKey) {
case "backup":
@@ -637,7 +637,7 @@ class HaPanelDevAction extends LitElement {
const example = {};
fields.forEach((field) => {
if (field.example) {
let value: any = "";
let value: any;
try {
value = load(field.example, { schema: JSON_SCHEMA });
} catch (_err: any) {
@@ -267,8 +267,7 @@ class HaPanelDevStatistics extends KeyboardShortcutMixin(LitElement) {
slot="trigger"
.label=${localize("ui.components.subpage-data-table.sort_by", {
sortColumn: this._sortColumn
? ` ${columns[this._sortColumn]?.title || columns[this._sortColumn]?.label}` ||
""
? ` ${columns[this._sortColumn]?.title || columns[this._sortColumn]?.label}`
: "",
})}
>
@@ -68,14 +68,18 @@ export function buildPowerExcludeList(
sources.forEach((entry) => {
if (entry.stat_rate) powerIds.push(entry.stat_rate);
if (entry.power_config) {
if (entry.power_config.stat_rate)
if (entry.power_config.stat_rate) {
powerIds.push(entry.power_config.stat_rate);
if (entry.power_config.stat_rate_inverted)
}
if (entry.power_config.stat_rate_inverted) {
powerIds.push(entry.power_config.stat_rate_inverted);
if (entry.power_config.stat_rate_from)
}
if (entry.power_config.stat_rate_from) {
powerIds.push(entry.power_config.stat_rate_from);
if (entry.power_config.stat_rate_to)
}
if (entry.power_config.stat_rate_to) {
powerIds.push(entry.power_config.stat_rate_to);
}
}
});
@@ -421,7 +421,7 @@ class AddIntegrationDialog extends LitElement {
return [];
}
// Get domains for this brand
let domains: string[] = [];
let domains: string[];
if ("integrations" in integration && integration.integrations) {
domains = Object.keys(integration.integrations);
if (this._pickedBrand === "apple") {
+4 -2
View File
@@ -47,10 +47,12 @@ class HaConfigLabs extends SubscribeMixin(LitElement) {
return featuresToSort.sort((a, b) => {
// Place frontend.winter_mode at the bottom
if (a.domain === "frontend" && a.preview_feature === "winter_mode")
if (a.domain === "frontend" && a.preview_feature === "winter_mode") {
return 1;
if (b.domain === "frontend" && b.preview_feature === "winter_mode")
}
if (b.domain === "frontend" && b.preview_feature === "winter_mode") {
return -1;
}
// Sort everything else alphabetically
return domainToName(localize, a.domain).localeCompare(
@@ -103,7 +103,7 @@ export default class HaScriptFieldEditor extends LitElement {
"field"
: slugify(value.name);
if (this.excludeKeys.includes(key)) {
let uniqueKey = key;
let uniqueKey: string;
let i = 2;
do {
uniqueKey = `${key}_${i}`;
+12
View File
@@ -54,6 +54,18 @@ export class HaPanelCustom extends ReactiveElement {
this.querySelector("iframe")?.classList.add("loaded");
}
public connectedCallback() {
super.connectedCallback();
// The suspendWhenHidden disconnect timer in partial-panel-resolver
// removes this element from the DOM after 5 minutes, which triggers
// _cleanupPanel() and destroys our child panel. When the user returns,
// the same element is re-appended but `update()` won't call _createPanel
// again (the `panel` property reference hasn't changed). Rebuild here.
if (!this._setProperties && !this.hasChildNodes() && this.panel) {
this._createPanel(this.panel);
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._cleanupPanel();
@@ -90,6 +90,21 @@ export class EnergyViewStrategy extends ReactiveElement {
});
}
// Only include if we have both grid import and export configured
if (hasGrid && hasReturn) {
const gridResultCard = {
type: "energy-grid-balance",
collection_key: collectionKey,
};
sidebarSection.cards!.push(gridResultCard);
view.sections!.push({
type: "grid",
column_span: 1,
visibility: [SMALL_SCREEN_CONDITION],
cards: [gridResultCard],
});
}
// Only include if we have a grid source & return.
if (hasReturn) {
const card = {
@@ -0,0 +1,151 @@
import { mdiDragHorizontalVariant } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import type { HaNavigationPicker } from "../../../components/ha-navigation-picker";
import "../../../components/ha-navigation-picker";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type { CustomShortcutItem } from "../../../data/frontend";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import { showEditShortcutDialog } from "../dialogs/show-dialog-edit-shortcut";
import "./home-shortcut-list-item";
// Paths already covered by built-in summaries
const SUMMARY_PANEL_PATHS = [
"/home",
"/light",
"/climate",
"/security",
"/energy",
"/maintenance",
];
@customElement("home-custom-shortcuts-editor")
export class HomeCustomShortcutsEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public shortcuts: CustomShortcutItem[] = [];
protected render() {
const excludePaths = [
...SUMMARY_PANEL_PATHS,
...this.shortcuts.map((s) => s.path),
];
return html`
<ha-sortable handle-selector=".handle" @item-moved=${this._shortcutMoved}>
<div class="home-list">
${repeat(
this.shortcuts,
(item) => item.path,
(item, index) => html`
<div class="home-list-item shortcut-row">
<div class="handle">
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
</div>
<home-shortcut-list-item
class="shortcut-content"
.hass=${this.hass}
.item=${item}
.index=${index}
@edit-shortcut=${this._editShortcut}
@delete-shortcut=${this._removeShortcut}
></home-shortcut-list-item>
</div>
`
)}
</div>
</ha-sortable>
<ha-navigation-picker
.hass=${this.hass}
.addButtonLabel=${this.hass.localize(
"ui.panel.home.editor.add_custom_shortcut"
)}
.excludePaths=${excludePaths}
@value-changed=${this._addShortcut}
></ha-navigation-picker>
`;
}
private _update(next: CustomShortcutItem[]): void {
fireEvent(this, "value-changed", { value: next });
}
private _addShortcut(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const path = ev.detail.value;
if (!path) return;
(ev.currentTarget as HaNavigationPicker).value = "";
if (this.shortcuts.some((item) => item.path === path)) return;
this._update([...this.shortcuts, { path }]);
}
private _editShortcut(ev: HASSDomEvent<{ index: number }>): void {
const { index } = ev.detail;
const item = this.shortcuts[index];
if (!item) return;
showEditShortcutDialog(this, {
item,
saveCallback: (updated) => {
const next = [...this.shortcuts];
next[index] = updated;
this._update(next);
},
});
}
private _removeShortcut(ev: HASSDomEvent<{ index: number }>): void {
const { index } = ev.detail;
const next = [...this.shortcuts];
next.splice(index, 1);
this._update(next);
}
private _shortcutMoved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const next = [...this.shortcuts];
const [moved] = next.splice(oldIndex, 1);
next.splice(newIndex, 0, moved);
this._update(next);
}
static styles = css`
.home-list {
display: flex;
flex-direction: column;
}
.shortcut-row {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
.shortcut-content {
flex: 1;
min-width: 0;
}
.handle {
cursor: grab;
color: var(--secondary-text-color);
flex-shrink: 0;
display: flex;
align-items: center;
}
ha-navigation-picker {
display: block;
padding-top: var(--ha-space-2);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"home-custom-shortcuts-editor": HomeCustomShortcutsEditor;
}
}
@@ -0,0 +1,88 @@
import { mdiDelete } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { computeEntityPickerDisplay } from "../../../common/entity/compute_entity_name_display";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/entity/state-badge";
import "../../../components/ha-icon-button";
import "../../../components/ha-settings-row";
import type { HomeAssistant } from "../../../types";
declare global {
interface HASSDomEvents {
"delete-favorite-entity": { index: number };
}
interface HTMLElementTagNameMap {
"home-favorite-entity-list-item": HomeFavoriteEntityListItem;
}
}
@customElement("home-favorite-entity-list-item")
export class HomeFavoriteEntityListItem extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "entity-id" }) public entityId!: string;
@property({ type: Number }) public index = 0;
protected render() {
const stateObj = this.hass.states[this.entityId];
const { primary, secondary } = stateObj
? computeEntityPickerDisplay(this.hass, stateObj)
: { primary: this.entityId, secondary: undefined };
return html`
<ha-settings-row slim>
<state-badge
slot="prefix"
.hass=${this.hass}
.stateObj=${stateObj}
></state-badge>
<span slot="heading">${primary}</span>
${secondary
? html`<span slot="description">${secondary}</span>`
: nothing}
<ha-icon-button
.path=${mdiDelete}
.label=${this.hass.localize("ui.common.delete")}
@click=${this._delete}
></ha-icon-button>
</ha-settings-row>
`;
}
private _delete() {
fireEvent(this, "delete-favorite-entity", { index: this.index });
}
static styles = css`
:host {
display: block;
}
ha-settings-row {
padding: 0;
gap: var(--ha-space-3);
min-height: 40px;
--settings-row-prefix-display: contents;
--settings-row-content-display: contents;
--settings-row-body-padding-top: var(--ha-space-1);
--settings-row-body-padding-bottom: var(--ha-space-1);
}
state-badge {
flex-shrink: 0;
width: 24px;
height: 24px;
--state-icon-color: var(--secondary-text-color);
}
[slot="heading"],
[slot="description"] {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
ha-icon-button {
--ha-icon-button-size: 40px;
}
`;
}
@@ -0,0 +1,137 @@
import { mdiDragHorizontalVariant } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
import "../../../components/entity/ha-entity-picker";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import "./home-favorite-entity-list-item";
@customElement("home-favorites-editor")
export class HomeFavoritesEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public favorites: string[] = [];
@property() public label?: string;
@property() public helper?: string;
protected render() {
return html`
${this.label ? html`<p class="field-label">${this.label}</p>` : nothing}
${this.helper
? html`<p class="field-helper">${this.helper}</p>`
: nothing}
<ha-sortable handle-selector=".handle" @item-moved=${this._moved}>
<div class="home-list">
${repeat(
this.favorites,
(entityId) => entityId,
(entityId, index) => html`
<div class="home-list-item favorite-row">
<div class="handle">
<ha-svg-icon .path=${mdiDragHorizontalVariant}></ha-svg-icon>
</div>
<home-favorite-entity-list-item
class="favorite-content"
.hass=${this.hass}
.entityId=${entityId}
.index=${index}
@delete-favorite-entity=${this._remove}
></home-favorite-entity-list-item>
</div>
`
)}
</div>
</ha-sortable>
<ha-entity-picker
add-button
.hass=${this.hass}
.addButtonLabel=${this.hass.localize(
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
)}
.excludeEntities=${this.favorites}
@value-changed=${this._add}
></ha-entity-picker>
`;
}
private _update(next: string[]): void {
fireEvent(this, "value-changed", { value: next });
}
private _add(ev: ValueChangedEvent<string | undefined>): void {
ev.stopPropagation();
const entityId = ev.detail.value;
if (!entityId) return;
(ev.currentTarget as HaEntityPicker).value = "";
if (this.favorites.includes(entityId)) return;
this._update([...this.favorites, entityId]);
}
private _remove(ev: HASSDomEvent<{ index: number }>): void {
const { index } = ev.detail;
const next = [...this.favorites];
next.splice(index, 1);
this._update(next);
}
private _moved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const next = [...this.favorites];
const [moved] = next.splice(oldIndex, 1);
next.splice(newIndex, 0, moved);
this._update(next);
}
static styles = css`
.field-label {
margin: 0 0 var(--ha-space-1) 0;
font-size: 14px;
color: var(--primary-text-color);
}
.field-helper {
margin: 0 0 var(--ha-space-2) 0;
color: var(--secondary-text-color);
font-size: 12px;
}
.home-list {
display: flex;
flex-direction: column;
}
.favorite-row {
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
.favorite-content {
flex: 1;
min-width: 0;
}
.handle {
cursor: grab;
color: var(--secondary-text-color);
flex-shrink: 0;
display: flex;
align-items: center;
}
ha-entity-picker {
display: block;
padding-top: var(--ha-space-2);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"home-favorites-editor": HomeFavoritesEditor;
}
}
@@ -6,6 +6,7 @@ import { computeCssColor } from "../../../common/color/compute-color";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-settings-row";
import "../../../components/ha-svg-icon";
import type { CustomShortcutItem } from "../../../data/frontend";
import { NavigationPathInfoController } from "../../../data/navigation-path-controller";
@@ -55,14 +56,19 @@ export class HomeShortcutListItem extends LitElement {
const iconStyle = { "--mdc-icon-size": "24px", color };
return html`
${icon
? html`<ha-icon .icon=${icon} style=${styleMap(iconStyle)}></ha-icon>`
: html`<ha-svg-icon
.path=${iconPath}
style=${styleMap(iconStyle)}
></ha-svg-icon>`}
<span class="label">${label}</span>
<div class="actions">
<ha-settings-row slim>
${icon
? html`<ha-icon
slot="prefix"
.icon=${icon}
style=${styleMap(iconStyle)}
></ha-icon>`
: html`<ha-svg-icon
slot="prefix"
.path=${iconPath}
style=${styleMap(iconStyle)}
></ha-svg-icon>`}
<span slot="heading">${label}</span>
<ha-icon-button
.path=${mdiPencil}
.label=${this.hass.localize("ui.common.edit")}
@@ -73,7 +79,7 @@ export class HomeShortcutListItem extends LitElement {
.label=${this.hass.localize("ui.common.delete")}
@click=${this._delete}
></ha-icon-button>
</div>
</ha-settings-row>
`;
}
@@ -87,27 +93,27 @@ export class HomeShortcutListItem extends LitElement {
static styles = css`
:host {
display: flex;
align-items: center;
display: block;
}
ha-settings-row {
padding: 0;
gap: var(--ha-space-3);
min-height: 40px;
--settings-row-prefix-display: contents;
--settings-row-content-display: contents;
--settings-row-body-padding-top: var(--ha-space-1);
--settings-row-body-padding-bottom: var(--ha-space-1);
}
ha-icon,
ha-svg-icon {
--mdc-icon-size: 24px;
flex-shrink: 0;
}
.label {
flex: 1;
min-width: 0;
font-size: 14px;
[slot="heading"] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: flex;
align-items: center;
}
ha-icon-button {
--ha-icon-button-size: 40px;
}
@@ -0,0 +1,130 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import {
fireEvent,
type HASSDomTargetEvent,
} from "../../../common/dom/fire_event";
import "../../../components/ha-icon";
import "../../../components/ha-settings-row";
import "../../../components/ha-switch";
import {
getSummaryLabel,
HOME_SUMMARIES_ICONS,
type HomeSummary,
} from "../../lovelace/strategies/home/helpers/home-summaries";
import type { HomeAssistant } from "../../../types";
interface SummaryInfo {
key: string;
icon: string;
color: string;
}
// Ordered to match dashboard rendering order
const SUMMARY_ITEMS: SummaryInfo[] = [
{ key: "light", icon: HOME_SUMMARIES_ICONS.light, color: "amber" },
{ key: "climate", icon: HOME_SUMMARIES_ICONS.climate, color: "deep-orange" },
{ key: "security", icon: HOME_SUMMARIES_ICONS.security, color: "blue-grey" },
{
key: "media_players",
icon: HOME_SUMMARIES_ICONS.media_players,
color: "blue",
},
{
key: "maintenance",
icon: HOME_SUMMARIES_ICONS.maintenance,
color: "orange",
},
{ key: "weather", icon: "mdi:weather-partly-cloudy", color: "teal" },
{ key: "energy", icon: HOME_SUMMARIES_ICONS.energy, color: "amber" },
];
@customElement("home-summaries-editor")
export class HomeSummariesEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public hiddenSummaries: string[] = [];
protected render() {
const hidden = new Set(this.hiddenSummaries);
return html`
<div class="home-list">
${SUMMARY_ITEMS.map((item) => {
const label = this._getSummaryLabel(item.key);
const color = computeCssColor(item.color);
return html`
<ha-settings-row slim>
<ha-icon
slot="prefix"
.icon=${item.icon}
style=${styleMap({ "--mdc-icon-size": "24px", color })}
></ha-icon>
<span slot="heading">${label}</span>
<ha-switch
.checked=${!hidden.has(item.key)}
.summary=${item.key}
@change=${this._toggleChanged}
></ha-switch>
</ha-settings-row>
`;
})}
</div>
`;
}
private _getSummaryLabel(key: string): string {
if (key === "weather") {
return this.hass.localize(
"ui.panel.lovelace.strategy.home.summary_list.weather"
);
}
return getSummaryLabel(this.hass.localize, key as HomeSummary);
}
private _toggleChanged(
ev: HASSDomTargetEvent<
HTMLElement & {
checked: boolean;
summary: string;
}
>
): void {
const target = ev.target;
const hidden = new Set(this.hiddenSummaries);
if (target.checked) {
hidden.delete(target.summary);
} else {
hidden.add(target.summary);
}
fireEvent(this, "value-changed", { value: [...hidden] });
}
static styles = css`
.home-list {
display: flex;
flex-direction: column;
}
ha-settings-row {
padding: 0;
gap: var(--ha-space-3);
min-height: 40px;
--settings-row-prefix-display: contents;
--settings-row-content-display: contents;
--settings-row-body-padding-top: var(--ha-space-1);
--settings-row-body-padding-bottom: var(--ha-space-1);
}
[slot="heading"] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"home-summaries-editor": HomeSummariesEditor;
}
}
+180 -295
View File
@@ -1,80 +1,42 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import { fireEvent, type HASSDomEvent } from "../../../common/dom/fire_event";
import "../../../components/entity/ha-entities-picker";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-button";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-dialog";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-form/ha-form";
import "../../../components/ha-icon";
import "../../../components/ha-navigation-picker";
import "../../../components/ha-switch";
import type { HaFormSchema } from "../../../components/ha-form/types";
import type {
CustomShortcutItem,
HomeFrontendSystemData,
} from "../../../data/frontend";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import {
getSummaryLabel,
HOME_SUMMARIES_ICONS,
type HomeSummary,
} from "../../lovelace/strategies/home/helpers/home-summaries";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import "../components/home-shortcut-list-item";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import "../components/home-custom-shortcuts-editor";
import "../components/home-favorites-editor";
import "../components/home-summaries-editor";
import type { EditHomeDialogParams } from "./show-dialog-edit-home";
import { showEditShortcutDialog } from "./show-dialog-edit-shortcut";
interface SummaryInfo {
key: string;
icon: string;
color: string;
interface EditorState {
favorite_entities: string[];
show_suggested_entities: boolean;
show_welcome_message: boolean;
hidden_summaries: string[];
custom_shortcuts: CustomShortcutItem[];
}
const SUGGESTED_ENTITIES_SCHEMA: HaFormSchema[] = [
{ name: "show_suggested", selector: { boolean: {} } },
];
// The common-controls strategy caps the section at 8 (or the favorites count,
// whichever is larger); once favorites reach the cap, predictions never render
// so the suggested-entities toggle has no effect.
const SUGGESTED_ENTITIES_CAP = 8;
// Ordered to match dashboard rendering order
const SUMMARY_ITEMS: SummaryInfo[] = [
{ key: "light", icon: HOME_SUMMARIES_ICONS.light, color: "amber" },
{ key: "climate", icon: HOME_SUMMARIES_ICONS.climate, color: "deep-orange" },
{
key: "security",
icon: HOME_SUMMARIES_ICONS.security,
color: "blue-grey",
},
{
key: "media_players",
icon: HOME_SUMMARIES_ICONS.media_players,
color: "blue",
},
{
key: "maintenance",
icon: HOME_SUMMARIES_ICONS.maintenance,
color: "orange",
},
{ key: "weather", icon: "mdi:weather-partly-cloudy", color: "teal" },
{ key: "energy", icon: HOME_SUMMARIES_ICONS.energy, color: "amber" },
];
// Paths already covered by built-in summaries
const SUMMARY_PANEL_PATHS = [
"/home",
"/light",
"/climate",
"/security",
"/energy",
"/maintenance",
];
const WELCOME_MESSAGE_SCHEMA = [
{ name: "welcome_message", selector: { boolean: {} } },
const WELCOME_SCHEMA: HaFormSchema[] = [
{ name: "show_welcome_message", selector: { boolean: {} } },
];
@customElement("dialog-edit-home")
@@ -86,7 +48,7 @@ export class DialogEditHome
@state() private _params?: EditHomeDialogParams;
@state() private _config?: HomeFrontendSystemData;
@state() private _state?: EditorState;
@state() private _open = false;
@@ -94,7 +56,19 @@ export class DialogEditHome
public showDialog(params: EditHomeDialogParams): void {
this._params = params;
this._config = { ...params.config };
this._state = {
favorite_entities: params.config.favorite_entities
? [...params.config.favorite_entities]
: [],
show_suggested_entities: !params.config.hide_suggested_entities,
show_welcome_message: !params.config.hide_welcome_message,
hidden_summaries: params.config.hidden_summaries
? [...params.config.hidden_summaries]
: [],
custom_shortcuts: params.config.custom_shortcuts
? [...params.config.custom_shortcuts]
: [],
};
this._open = true;
}
@@ -105,23 +79,16 @@ export class DialogEditHome
private _dialogClosed(): void {
this._params = undefined;
this._config = undefined;
this._state = undefined;
this._submitting = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
if (!this._params || !this._state) {
return nothing;
}
const hiddenSummaries = new Set(this._config?.hidden_summaries || []);
const customShortcuts = this._config?.custom_shortcuts || [];
const excludePaths = [
...SUMMARY_PANEL_PATHS,
...customShortcuts.map((s) => s.path),
];
return html`
<ha-dialog
.hass=${this.hass}
@@ -146,115 +113,86 @@ export class DialogEditHome
<ha-expansion-panel
outlined
expanded
.header=${this.hass.localize(
"ui.panel.lovelace.editor.strategy.home.favorite_entities"
.header=${this.hass.localize("ui.panel.home.editor.personalize")}
.secondary=${this.hass.localize(
"ui.panel.home.editor.personalize_description"
)}
>
<ha-icon slot="leading-icon" icon="mdi:star-outline"></ha-icon>
<ha-icon slot="leading-icon" icon="mdi:palette-outline"></ha-icon>
<div class="expansion-content">
<p class="section-description">
${this.hass.localize(
"ui.panel.home.editor.favorite_entities_description"
)}
</p>
<ha-entities-picker
autofocus
<ha-form
.hass=${this.hass}
.value=${this._config?.favorite_entities || []}
.placeholder=${this.hass.localize(
"ui.panel.lovelace.editor.strategy.home.add_favorite_entity"
.data=${{
show_welcome_message: this._state.show_welcome_message,
}}
.schema=${WELCOME_SCHEMA}
.computeLabel=${this._computeWelcomeLabel}
.computeHelper=${this._computeWelcomeHelper}
@value-changed=${this._welcomeChanged}
></ha-form>
<home-favorites-editor
.hass=${this.hass}
.favorites=${this._state.favorite_entities}
.label=${this.hass.localize(
"ui.panel.lovelace.editor.strategy.home.favorite_entities"
)}
.helper=${this.hass.localize(
"ui.panel.home.editor.favorite_entities_helper"
)}
reorder
@value-changed=${this._favoriteEntitiesChanged}
></ha-entities-picker>
></home-favorites-editor>
<ha-form
.hass=${this.hass}
.data=${{
show_suggested: !this._config?.hide_suggested_entities,
show_suggested_entities: this._state.show_suggested_entities,
}}
.schema=${SUGGESTED_ENTITIES_SCHEMA}
.schema=${this._suggestedSchema(
this._state.favorite_entities.length >= SUGGESTED_ENTITIES_CAP
)}
.computeLabel=${this._computeSuggestedLabel}
.computeHelper=${this._computeSuggestedHelper}
@value-changed=${this._suggestedEntitiesChanged}
@value-changed=${this._suggestedChanged}
></ha-form>
</div>
</ha-expansion-panel>
<h3 class="section-header">
${this.hass.localize("ui.panel.home.editor.welcome_message")}
</h3>
<p class="section-description">
${this.hass.localize("ui.panel.home.editor.welcome_message_helper")}
</p>
<ha-form
.hass=${this.hass}
.data=${{ welcome_message: !this._config?.hide_welcome_message }}
.schema=${WELCOME_MESSAGE_SCHEMA}
.computeLabel=${this._computeWelcomeLabel}
@value-changed=${this._welcomeMessageToggleChanged}
></ha-form>
<ha-expansion-panel
outlined
expanded
.header=${this.hass.localize("ui.panel.home.editor.summaries")}
.secondary=${this.hass.localize(
"ui.panel.home.editor.summaries_description"
)}
>
<ha-icon
slot="leading-icon"
icon="mdi:view-dashboard-outline"
></ha-icon>
<div class="expansion-content">
<home-summaries-editor
.hass=${this.hass}
.hiddenSummaries=${this._state.hidden_summaries}
@value-changed=${this._hiddenSummariesChanged}
></home-summaries-editor>
</div>
</ha-expansion-panel>
<h3 class="section-header">
${this.hass.localize("ui.panel.home.editor.summaries")}
</h3>
<p class="section-description">
${this.hass.localize("ui.panel.home.editor.summaries_description")}
</p>
<div class="home-list">
${SUMMARY_ITEMS.map((item) => {
const label = this._getSummaryLabel(item.key);
const color = computeCssColor(item.color);
return html`
<label class="home-list-item summary-toggle">
<ha-icon
.icon=${item.icon}
style=${styleMap({ "--mdc-icon-size": "24px", color })}
></ha-icon>
<span class="label">${label}</span>
<ha-switch
.checked=${!hiddenSummaries.has(item.key)}
.summary=${item.key}
@change=${this._summaryToggleChanged}
></ha-switch>
</label>
`;
})}
</div>
<h3 class="section-header">
${this.hass.localize("ui.panel.home.editor.custom_shortcuts")}
</h3>
<p class="section-description">
${this.hass.localize(
<ha-expansion-panel
outlined
expanded
.header=${this.hass.localize("ui.panel.home.editor.custom_shortcuts")}
.secondary=${this.hass.localize(
"ui.panel.home.editor.custom_shortcuts_description"
)}
</p>
<div class="home-list">
${customShortcuts.map(
(item, index) => html`
<home-shortcut-list-item
class="home-list-item"
.hass=${this.hass}
.item=${item}
.index=${index}
@edit-shortcut=${this._editShortcut}
@delete-shortcut=${this._removeShortcut}
></home-shortcut-list-item>
`
)}
</div>
<ha-navigation-picker
.hass=${this.hass}
.addButtonLabel=${this.hass.localize(
"ui.panel.home.editor.add_custom_shortcut"
)}
.excludePaths=${excludePaths}
@value-changed=${this._addShortcut}
></ha-navigation-picker>
>
<ha-icon slot="leading-icon" icon="mdi:link-variant"></ha-icon>
<div class="expansion-content">
<home-custom-shortcuts-editor
.hass=${this.hass}
.shortcuts=${this._state.custom_shortcuts}
@value-changed=${this._shortcutsChanged}
></home-custom-shortcuts-editor>
</div>
</ha-expansion-panel>
<ha-dialog-footer slot="footer">
<ha-button
@@ -277,132 +215,104 @@ export class DialogEditHome
`;
}
private _getSummaryLabel(key: string): string {
if (key === "weather") {
return this.hass.localize(
"ui.panel.lovelace.strategy.home.summary_list.weather"
);
}
return getSummaryLabel(this.hass.localize, key as HomeSummary);
}
private _suggestedSchema = memoizeOne(
(disabled: boolean) =>
[
{
name: "show_suggested_entities",
selector: { boolean: {} },
disabled,
},
] as HaFormSchema[]
);
private _summaryToggleChanged(ev: Event): void {
const target = ev.target as HTMLElement & {
checked: boolean;
summary: string;
};
const summary = target.summary;
const checked = target.checked;
const hiddenSummaries = new Set(this._config?.hidden_summaries || []);
if (checked) {
hiddenSummaries.delete(summary);
} else {
hiddenSummaries.add(summary);
}
this._config = {
...this._config,
hidden_summaries:
hiddenSummaries.size > 0 ? [...hiddenSummaries] : undefined,
};
}
private _computeWelcomeLabel = () =>
private _computeWelcomeLabel = (): string =>
this.hass.localize("ui.panel.home.editor.welcome_message");
private _welcomeMessageToggleChanged(ev: CustomEvent): void {
this._config = {
...this._config,
hide_welcome_message: ev.detail.value.welcome_message ? undefined : true,
};
}
private _computeWelcomeHelper = (): string =>
this.hass.localize("ui.panel.home.editor.welcome_message_helper");
private _computeSuggestedLabel = (): string =>
this.hass.localize("ui.panel.home.editor.suggested_entities");
private _computeSuggestedHelper = (): string =>
this.hass.localize("ui.panel.home.editor.suggested_entities_description");
private _suggestedEntitiesChanged(ev: CustomEvent): void {
const showSuggested = (ev.detail.value as { show_suggested: boolean })
.show_suggested;
this._config = {
...this._config,
hide_suggested_entities: showSuggested ? undefined : true,
};
}
private _updateShortcuts(
updater: (shortcuts: CustomShortcutItem[]) => CustomShortcutItem[]
): void {
const next = updater([...(this._config?.custom_shortcuts || [])]);
this._config = {
...this._config,
custom_shortcuts: next.length > 0 ? next : undefined,
};
}
private _addShortcut(ev: CustomEvent): void {
ev.stopPropagation();
const path = ev.detail.value as string;
if (!path) return;
(ev.currentTarget as any).value = "";
this._updateShortcuts((shortcuts) =>
shortcuts.some((item) => item.path === path)
? shortcuts
: [...shortcuts, { path }]
private _computeSuggestedHelper = (): string => {
const favoritesFull =
(this._state?.favorite_entities.length ?? 0) >= SUGGESTED_ENTITIES_CAP;
return this.hass.localize(
favoritesFull
? "ui.panel.home.editor.suggested_entities_disabled_description"
: "ui.panel.home.editor.suggested_entities_description"
);
};
private _favoriteEntitiesChanged(ev: ValueChangedEvent<string[]>): void {
this._state = {
...this._state!,
favorite_entities: ev.detail.value,
};
}
private _editShortcut(ev: HASSDomEvent<{ index: number }>): void {
const { index } = ev.detail;
const item = this._config?.custom_shortcuts?.[index];
if (!item) return;
showEditShortcutDialog(this, {
item,
saveCallback: (updated) => {
this._updateShortcuts((shortcuts) => {
shortcuts[index] = updated;
return shortcuts;
});
},
});
private _welcomeChanged(
ev: ValueChangedEvent<{ show_welcome_message: boolean }>
): void {
this._state = {
...this._state!,
show_welcome_message: ev.detail.value.show_welcome_message,
};
}
private _removeShortcut(ev: HASSDomEvent<{ index: number }>): void {
const { index } = ev.detail;
this._updateShortcuts((shortcuts) => {
shortcuts.splice(index, 1);
return shortcuts;
});
private _suggestedChanged(
ev: ValueChangedEvent<{ show_suggested_entities: boolean }>
): void {
this._state = {
...this._state!,
show_suggested_entities: ev.detail.value.show_suggested_entities,
};
}
private _favoriteEntitiesChanged(ev: CustomEvent): void {
const entities = ev.detail.value as string[];
this._config = {
...this._config,
favorite_entities: entities.length > 0 ? entities : undefined,
private _hiddenSummariesChanged(ev: ValueChangedEvent<string[]>): void {
this._state = {
...this._state!,
hidden_summaries: ev.detail.value,
};
}
private _shortcutsChanged(ev: ValueChangedEvent<CustomShortcutItem[]>): void {
this._state = {
...this._state!,
custom_shortcuts: ev.detail.value,
};
}
private async _save(): Promise<void> {
if (!this._params || !this._config) {
return;
}
if (!this._params || !this._state) return;
this._submitting = true;
const editor = this._state;
const config: HomeFrontendSystemData = {
...this._params.config,
favorite_entities:
editor.favorite_entities.length > 0
? editor.favorite_entities
: undefined,
hide_suggested_entities: editor.show_suggested_entities
? undefined
: true,
hide_welcome_message: editor.show_welcome_message ? undefined : true,
hidden_summaries:
editor.hidden_summaries.length > 0
? editor.hidden_summaries
: undefined,
custom_shortcuts:
editor.custom_shortcuts.length > 0
? editor.custom_shortcuts
: undefined,
};
try {
await this._params.saveConfig(this._config);
await this._params.saveConfig(config);
this.closeDialog();
} catch (err: any) {
// eslint-disable-next-line no-console
console.error("Failed to save home configuration:", err);
} finally {
this._submitting = false;
}
@@ -415,60 +325,35 @@ export class DialogEditHome
--dialog-content-padding: var(--ha-space-6);
}
.section-header {
font-size: 16px;
font-weight: 500;
margin: var(--ha-space-6) 0 var(--ha-space-1) 0;
}
.section-description {
margin: 0 0 var(--ha-space-2) 0;
color: var(--secondary-text-color);
font-size: 14px;
}
.home-list {
display: flex;
flex-direction: column;
}
.summary-toggle {
display: flex;
align-items: center;
gap: var(--ha-space-3);
padding: var(--ha-space-2) 0;
cursor: pointer;
}
.summary-toggle .label {
flex: 1;
font-size: 14px;
}
ha-expansion-panel {
display: block;
margin-top: var(--ha-space-4);
--expansion-panel-content-padding: 0;
border-radius: var(--ha-border-radius-md);
--ha-card-border-radius: var(--ha-border-radius-md);
}
ha-expansion-panel + ha-expansion-panel {
margin-top: var(--ha-space-2);
}
.expansion-content {
padding: var(--ha-space-3);
}
ha-navigation-picker {
ha-form {
display: block;
padding-top: var(--ha-space-2);
}
ha-entities-picker {
home-favorites-editor {
display: block;
margin-top: var(--ha-space-2);
margin-bottom: var(--ha-space-4);
}
ha-alert {
display: block;
margin: 0 calc(-1 * var(--dialog-content-padding));
margin: calc(-1 * var(--dialog-content-padding));
margin-bottom: var(--ha-space-4);
}
`,
];
@@ -106,7 +106,7 @@ export class HuiPowerTotalBadge
const power = this._computeTotalPower(this._data.prefs);
let displayValue = "";
let displayValue: string;
if (power >= 1000) {
displayValue = `${formatNumber(power / 1000, this.hass.locale, {
maximumFractionDigits: 2,
+8
View File
@@ -6,6 +6,7 @@ import "../../../components/ha-svg-icon";
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import type { HomeAssistant } from "../../../types";
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
import { getConfigEntityId } from "../common/get-config-entity-id";
import { checkConditionsMet } from "../common/validate-condition";
import { createBadgeElement } from "../create-element/create-badge-element";
import { createErrorBadgeConfig } from "../create-element/create-element-base";
@@ -101,6 +102,13 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("config")) {
this._conditionContext = {
...this._conditionContext,
entity_id: this.config ? getConfigEntityId(this.config) : undefined,
};
}
if (!this._element) {
this.load();
}
@@ -7,6 +7,7 @@ import type {
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeCssColor } from "../../../common/color/compute-color";
import { consumeEntityState } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import { computeDomain } from "../../../common/entity/compute_domain";
@@ -19,7 +20,11 @@ import {
internationalizationContext,
} from "../../../data/context";
import type { ForecastAttribute, ForecastEvent } from "../../../data/weather";
import { subscribeForecast, WeatherEntityFeature } from "../../../data/weather";
import {
getForecastPrecipitation,
subscribeForecast,
WeatherEntityFeature,
} from "../../../data/weather";
import type {
HomeAssistantConnection,
HomeAssistantInternationalization,
@@ -32,7 +37,7 @@ import type {
export const DEFAULT_DAYS_TO_SHOW = 7;
const MAX_BAR_WIDTH = 12;
const MAX_BAR_WIDTH = 8;
export type DailyForecastType = "daily" | "twice_daily";
@@ -183,16 +188,20 @@ class HuiDailyForecastCardFeature
`;
}
const showTemperature = this._config.show_temperature ?? true;
const showPrecipitation = this._config.show_precipitation ?? false;
const daysToShow = this._config.days_to_show ?? DEFAULT_DAYS_TO_SHOW;
const entriesPerDay = this._subscribedType === "twice_daily" ? 2 : 1;
const entries = this._forecast
.filter(
(entry) =>
entry.temperature != null &&
!Number.isNaN(entry.temperature) &&
entry.templow != null &&
!Number.isNaN(entry.templow)
)
.filter((entry) => {
if (showTemperature) {
return (
Number.isFinite(entry.temperature) && Number.isFinite(entry.templow)
);
}
return showPrecipitation;
})
.slice(0, daysToShow * entriesPerDay);
if (!entries.length) {
@@ -217,62 +226,152 @@ class HuiDailyForecastCardFeature
const minGap = 4;
const slotWidth = width / entries.length;
const barWidth = Math.max(1, Math.min(MAX_BAR_WIDTH, slotWidth - minGap));
const drawableHeight = height - padding * 2;
const showTemperature = this._config!.show_temperature ?? true;
const showCurrentTemperature =
this._config!.show_current_temperature ?? true;
const showPrecipitation = this._config!.show_precipitation ?? false;
const precipitationType = this._config!.precipitation_type ?? "amount";
const customColor = this._config!.color
? computeCssColor(this._config!.color)
: undefined;
const currentTemp = Number(this._stateObj?.attributes?.temperature);
const hasCurrentTemp = currentTemp != null && !Number.isNaN(currentTemp);
const hasCurrentTemp = Number.isFinite(currentTemp);
let tempMin = Infinity;
let tempMax = -Infinity;
for (const entry of entries) {
tempMin = Math.min(tempMin, entry.templow!);
tempMax = Math.max(tempMax, entry.temperature);
}
if (hasCurrentTemp) {
tempMin = Math.min(tempMin, currentTemp);
tempMax = Math.max(tempMax, currentTemp);
}
if (tempMin === tempMax) {
tempMin -= 1;
tempMax += 1;
let yFor: ((value: number) => number) | undefined;
if (showTemperature) {
let tempMin = Infinity;
let tempMax = -Infinity;
for (const entry of entries) {
if (
Number.isFinite(entry.templow) &&
Number.isFinite(entry.temperature)
) {
tempMin = Math.min(tempMin, entry.templow!);
tempMax = Math.max(tempMax, entry.temperature);
}
}
if (hasCurrentTemp) {
tempMin = Math.min(tempMin, currentTemp);
tempMax = Math.max(tempMax, currentTemp);
}
if (tempMin === tempMax) {
tempMin -= 1;
tempMax += 1;
}
yFor = (value: number) =>
padding +
drawableHeight -
((value - tempMin) / (tempMax - tempMin)) * drawableHeight;
}
const drawableHeight = height - padding * 2;
const yFor = (value: number) =>
padding +
drawableHeight -
((value - tempMin) / (tempMax - tempMin)) * drawableHeight;
let maxPrecipitation = 0;
if (showPrecipitation) {
if (precipitationType === "probability") {
maxPrecipitation = 100;
} else {
for (const entry of entries) {
const value = getForecastPrecipitation(entry, precipitationType);
if (Number.isFinite(value)) {
maxPrecipitation = Math.max(maxPrecipitation, value!);
}
}
}
}
const bars = entries.map((entry, i) => {
const x = slotWidth * i + (slotWidth - barWidth) / 2;
const yHigh = yFor(entry.temperature);
const yLow = yFor(entry.templow!);
const barHeight = Math.max(1, yLow - yHigh);
const rx = Math.min(barWidth / 2, barHeight / 2);
const fill = entry.condition
? `var(--state-weather-${slugify(entry.condition, "_")}-color, var(--feature-color))`
: "var(--feature-color)";
return svg`<rect
x=${x}
y=${yHigh}
width=${barWidth}
height=${barHeight}
rx=${rx}
ry=${rx}
fill=${fill}
></rect>`;
});
const rainBarWidth = Math.max(
barWidth,
Math.min(barWidth + 8, slotWidth - 2)
);
const currentTempLine = hasCurrentTemp
? svg`<line
x1="0"
x2=${width}
y1=${yFor(currentTemp)}
y2=${yFor(currentTemp)}
stroke="var(--feature-color)"
stroke-width="1"
stroke-opacity="0.5"
vector-effect="non-scaling-stroke"
></line>`
const precipitationBars =
showPrecipitation && maxPrecipitation > 0
? entries.map((entry, i) => {
const value = getForecastPrecipitation(entry, precipitationType);
if (!Number.isFinite(value) || value! <= 0) {
return nothing;
}
const x = slotWidth * i + (slotWidth - rainBarWidth) / 2;
const barHeight = Math.max(
1,
(value! / maxPrecipitation) * drawableHeight
);
const y = padding + drawableHeight - barHeight;
return svg`<rect
x=${x}
y=${y}
width=${rainBarWidth}
height=${barHeight}
fill="var(--state-weather-rainy-color)"
opacity="0.4"
></rect>`;
})
: nothing;
const bars =
showTemperature && yFor
? entries.map((entry, i) => {
if (
!Number.isFinite(entry.temperature) ||
!Number.isFinite(entry.templow)
) {
return nothing;
}
const x = slotWidth * i + (slotWidth - barWidth) / 2;
const yHigh = yFor(entry.temperature);
const yLow = yFor(entry.templow!);
const barHeight = Math.max(1, yLow - yHigh);
const rx = Math.min(barWidth / 2, barHeight / 2);
const fill =
customColor ??
(entry.condition
? `var(--state-weather-${slugify(entry.condition, "_")}-color, var(--feature-color))`
: "var(--feature-color)");
return svg`<rect
x=${x}
y=${yHigh}
width=${barWidth}
height=${barHeight}
rx=${rx}
ry=${rx}
fill=${fill}
></rect>`;
})
: nothing;
const currentTempLine =
showTemperature && showCurrentTemperature && yFor && hasCurrentTemp
? svg`<line
x1="0"
x2=${width}
y1=${yFor(currentTemp)}
y2=${yFor(currentTemp)}
stroke=${customColor ?? "var(--feature-color)"}
stroke-width="1"
stroke-opacity="0.5"
vector-effect="non-scaling-stroke"
></line>`
: nothing;
const dotRadius = 1.5;
const dots = !showTemperature
? entries.map((entry, i) => {
const value = getForecastPrecipitation(entry, precipitationType);
if (Number.isFinite(value) && value! > 0) {
return nothing;
}
const cx = slotWidth * i + slotWidth / 2;
const cy = padding + drawableHeight - dotRadius;
return svg`<circle
cx=${cx}
cy=${cy}
r=${dotRadius}
fill="var(--state-weather-rainy-color)"
opacity="0.4"
></circle>`;
})
: nothing;
return html`
@@ -282,7 +381,7 @@ class HuiDailyForecastCardFeature
viewBox="0 0 ${width} ${height}"
preserveAspectRatio="none"
>
${bars}${currentTempLine}
${dots}${precipitationBars}${bars}${currentTempLine}
</svg>
`;
}
@@ -1,12 +1,18 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import { computeDomain } from "../../../common/entity/compute_domain";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-spinner";
import type { ForecastEvent } from "../../../data/weather";
import { subscribeForecast, WeatherEntityFeature } from "../../../data/weather";
import type { ForecastAttribute, ForecastEvent } from "../../../data/weather";
import {
getForecastPrecipitation,
subscribeForecast,
WeatherEntityFeature,
} from "../../../data/weather";
import type { HomeAssistant } from "../../../types";
import { coordinates } from "../common/graph/coordinates";
import "../components/hui-graph-base";
@@ -18,6 +24,9 @@ import type {
export const DEFAULT_HOURS_TO_SHOW = 24;
const MS_PER_HOUR = 60 * 60 * 1000;
const MAX_RAIN_BAR_WIDTH = 16;
export const supportsHourlyForecastCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
@@ -45,6 +54,8 @@ class HuiHourlyForecastCardFeature
@state() private _config?: HourlyForecastCardFeatureConfig;
@state() private _forecast?: ForecastAttribute[];
@state() private _coordinates?: [number, number][];
@state() private _yAxisOrigin?: number;
@@ -117,14 +128,26 @@ class HuiHourlyForecastCardFeature
</div>
`;
}
if (!this._coordinates) {
if (!this._forecast || !this._coordinates) {
return html`
<div class="container loading">
<ha-spinner size="small"></ha-spinner>
</div>
`;
}
if (!this._coordinates.length) {
const showTemperature = this._config.show_temperature ?? true;
const showPrecipitation = this._config.show_precipitation ?? false;
const showDots = !showTemperature && showPrecipitation;
const layer =
showPrecipitation || showDots
? this._renderForecastLayer(showPrecipitation, showDots)
: nothing;
const hasGraphData = this._coordinates.length > 0;
const showGraph = showTemperature && hasGraphData;
if (!showGraph && layer === nothing) {
return html`
<div class="container">
<div class="info">
@@ -135,11 +158,141 @@ class HuiHourlyForecastCardFeature
</div>
`;
}
const customColor = this._config.color
? computeCssColor(this._config.color)
: undefined;
const graphStyle = customColor
? styleMap({ "--feature-color": customColor })
: nothing;
return html`
<hui-graph-base
.coordinates=${this._coordinates}
.yAxisOrigin=${this._yAxisOrigin}
></hui-graph-base>
<div class="layers">
${layer}
${showGraph
? html`
<hui-graph-base
.coordinates=${this._coordinates}
.yAxisOrigin=${this._yAxisOrigin}
style=${graphStyle}
></hui-graph-base>
`
: nothing}
</div>
`;
}
private _renderForecastLayer(showRain: boolean, showDots: boolean) {
if (!this._forecast?.length) {
return nothing;
}
const width = this.clientWidth || 300;
const height = this.clientHeight || 42;
// No bottom padding so bars and dots line up with the line graph baseline.
const topPadding = 4;
const drawableHeight = height - topPadding;
const now = Date.now();
const hoursToShow = this._config!.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
const maxTime =
Math.floor((now + hoursToShow * MS_PER_HOUR) / MS_PER_HOUR) * MS_PER_HOUR;
const timeRange = maxTime - now;
if (timeRange <= 0) {
return nothing;
}
const precipitationType = this._config!.precipitation_type ?? "amount";
const inRange: { entry: ForecastAttribute; t: number }[] = [];
for (const entry of this._forecast) {
const t = new Date(entry.datetime).getTime();
if (t >= now && t <= maxTime) {
inRange.push({ entry, t });
}
}
if (!inRange.length) {
return nothing;
}
const rainRects: TemplateResult[] = [];
if (showRain) {
const rainEntries = inRange.filter(({ entry }) => {
const value = getForecastPrecipitation(entry, precipitationType);
return Number.isFinite(value) && value! > 0;
});
let maxPrecipitation = 0;
if (precipitationType === "probability") {
maxPrecipitation = 100;
} else {
for (const { entry } of rainEntries) {
maxPrecipitation = Math.max(
maxPrecipitation,
getForecastPrecipitation(entry, precipitationType)!
);
}
}
if (maxPrecipitation > 0 && rainEntries.length) {
const slotWidth = width / hoursToShow;
const barWidth = Math.max(
1,
Math.min(MAX_RAIN_BAR_WIDTH, slotWidth - 2)
);
for (const { entry, t } of rainEntries) {
const value = getForecastPrecipitation(entry, precipitationType)!;
const xCenter = ((t - now) / timeRange) * width;
const x = xCenter - barWidth / 2;
const barHeight = Math.max(
1,
(value / maxPrecipitation) * drawableHeight
);
const y = height - barHeight;
rainRects.push(svg`<rect
x=${x}
y=${y}
width=${barWidth}
height=${barHeight}
fill="var(--state-weather-rainy-color)"
opacity="0.4"
></rect>`);
}
}
}
const dots: TemplateResult[] = [];
if (showDots) {
const dotRadius = 1.5;
const cy = height - dotRadius;
for (const { entry, t } of inRange) {
const value = getForecastPrecipitation(entry, precipitationType);
if (Number.isFinite(value) && value! > 0) {
continue;
}
const cx = ((t - now) / timeRange) * width;
dots.push(svg`<circle
cx=${cx}
cy=${cy}
r=${dotRadius}
fill="var(--state-weather-rainy-color)"
opacity="0.4"
></circle>`);
}
}
if (!rainRects.length && !dots.length) {
return nothing;
}
return html`
<svg
class="rain"
width="100%"
height="100%"
viewBox="0 0 ${width} ${height}"
preserveAspectRatio="none"
>
${dots}${rainRects}
</svg>
`;
}
@@ -162,10 +315,9 @@ class HuiHourlyForecastCardFeature
const data: [number, number][] = [];
const now = Date.now();
const hoursToShow = this._config!.hours_to_show ?? DEFAULT_HOURS_TO_SHOW;
const msPerHour = 60 * 60 * 1000;
// Round down to the nearest hour so the axis aligns with forecast data points
const maxTime =
Math.floor((now + hoursToShow * msPerHour) / msPerHour) * msPerHour;
Math.floor((now + hoursToShow * MS_PER_HOUR) / MS_PER_HOUR) * MS_PER_HOUR;
// Start with current temperature
const currentTemp = stateObj?.attributes?.temperature;
@@ -218,6 +370,7 @@ class HuiHourlyForecastCardFeature
entityId,
"hourly",
(forecastEvent) => {
this._forecast = forecastEvent.forecast ?? [];
this._computeCoordinates(forecastEvent);
}
).catch((err) => {
@@ -234,24 +387,45 @@ class HuiHourlyForecastCardFeature
height: var(--feature-height);
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
align-items: stretch;
pointer-events: none !important;
}
.container.loading {
.container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
hui-graph-base {
.info {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.layers {
position: relative;
width: 100%;
--accent-color: var(--feature-color);
height: 100%;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 8px;
overflow: hidden;
}
.rain {
position: absolute;
inset: 0;
display: block;
}
hui-graph-base {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
--accent-color: var(--feature-color);
}
`;
}
@@ -3,6 +3,8 @@ import {
mdiPlay,
mdiPlayPause,
mdiPower,
mdiPowerOff,
mdiPowerOn,
mdiSkipNext,
mdiSkipPrevious,
mdiStop,
@@ -198,25 +200,32 @@ class HuiMediaPlayerPlaybackCardFeature
stateObj: MediaPlayerEntity
): ControlButton[] {
const active = stateActive(stateObj);
const assumedState = stateObj.attributes.assumed_state === true;
const buttons: ControlButton[] = [];
for (const control of this._controls) {
switch (control) {
case "turn_off":
if (
active &&
(active || assumedState) &&
supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_OFF)
) {
buttons.push({ icon: mdiPower, action: "turn_off" });
buttons.push({
icon: assumedState ? mdiPowerOff : mdiPower,
action: "turn_off",
});
}
break;
case "turn_on":
if (
!active &&
(!active || assumedState) &&
!isUnavailableState(stateObj.state) &&
supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_ON)
) {
buttons.push({ icon: mdiPower, action: "turn_on" });
buttons.push({
icon: assumedState ? mdiPowerOn : mdiPower,
action: "turn_on",
});
}
break;
case "media_play":
@@ -1,6 +1,9 @@
import type { AlarmMode } from "../../../data/alarm_control_panel";
import type { HvacMode } from "../../../data/climate";
import type { OperationMode } from "../../../data/water_heater";
import type { ForecastPrecipitationType } from "../../../data/weather";
export type { ForecastPrecipitationType };
export type ButtonCardData = Record<string, any>;
@@ -244,12 +247,21 @@ export interface TrendGraphCardFeatureConfig {
export interface HourlyForecastCardFeatureConfig {
type: "hourly-forecast";
hours_to_show?: number;
show_temperature?: boolean;
show_precipitation?: boolean;
precipitation_type?: ForecastPrecipitationType;
color?: string;
}
export interface DailyForecastCardFeatureConfig {
type: "daily-forecast";
forecast_type?: "daily" | "twice_daily";
days_to_show?: number;
show_temperature?: boolean;
show_current_temperature?: boolean;
show_precipitation?: boolean;
precipitation_type?: ForecastPrecipitationType;
color?: string;
}
export const AREA_CONTROL_DOMAINS = [
@@ -0,0 +1,243 @@
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import type { HomeAssistant } from "../../../../types";
import type { ClockCardConfig, ClockCardDatePart } from "../types";
type ClockCardSeparatorPart = Extract<
ClockCardDatePart,
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
>;
type ClockCardValuePart = Exclude<ClockCardDatePart, ClockCardSeparatorPart>;
/**
* Normalized date configuration used by clock card renderers.
*/
interface ClockCardDateConfig {
parts: ClockCardDatePart[];
}
/**
* Resolves the locale and time zone for a clock card from `hass` and the
* card's configuration. Applies the optional `time_format` override to the
* locale and falls back to the user's preferred time zone.
*/
export const resolveClockCardLocale = (
hass: HomeAssistant,
config: Pick<ClockCardConfig, "time_format" | "time_zone">
): { locale: HomeAssistant["locale"]; timeZone: string } => {
const locale = config.time_format
? { ...hass.locale, time_format: config.time_format }
: hass.locale;
const timeZone =
config.time_zone ||
resolveTimeZone(locale.time_zone, hass.config?.time_zone);
return { locale, timeZone };
};
/**
* All selectable date tokens exposed by the clock card editor.
*/
export const CLOCK_CARD_DATE_PARTS: readonly ClockCardDatePart[] = [
"weekday-short",
"weekday-long",
"day-numeric",
"day-2-digit",
"month-short",
"month-long",
"month-numeric",
"month-2-digit",
"year-2-digit",
"year-numeric",
"separator-dash",
"separator-slash",
"separator-dot",
"separator-new-line",
];
const DATE_PART_OPTIONS: Record<
ClockCardValuePart,
Pick<Intl.DateTimeFormatOptions, "weekday" | "day" | "month" | "year">
> = {
"weekday-short": { weekday: "short" },
"weekday-long": { weekday: "long" },
"day-numeric": { day: "numeric" },
"day-2-digit": { day: "2-digit" },
"month-short": { month: "short" },
"month-long": { month: "long" },
"month-numeric": { month: "numeric" },
"month-2-digit": { month: "2-digit" },
"year-2-digit": { year: "2-digit" },
"year-numeric": { year: "numeric" },
};
const DATE_SEPARATORS: Record<ClockCardSeparatorPart, string> = {
"separator-dash": "-",
"separator-slash": "/",
"separator-dot": ".",
"separator-new-line": "\n",
};
const DATE_SEPARATOR_PARTS = new Set<ClockCardSeparatorPart>([
"separator-dash",
"separator-slash",
"separator-dot",
"separator-new-line",
]);
const DATE_PART_FORMATTERS = new Map<string, Intl.DateTimeFormat>();
const isClockCardDatePart = (value: string): value is ClockCardDatePart =>
CLOCK_CARD_DATE_PARTS.includes(value as ClockCardDatePart);
const isDateSeparatorPart = (
part: ClockCardDatePart
): part is ClockCardSeparatorPart =>
DATE_SEPARATOR_PARTS.has(part as ClockCardSeparatorPart);
/**
* Returns a reusable formatter for a specific date token.
*/
const getDatePartFormatter = (
part: ClockCardValuePart,
language: string,
timeZone?: string
): Intl.DateTimeFormat => {
const cacheKey = `${language}|${timeZone || ""}|${part}`;
const cached = DATE_PART_FORMATTERS.get(cacheKey);
if (cached) {
return cached;
}
const formatter = new Intl.DateTimeFormat(language, {
...DATE_PART_OPTIONS[part],
...(timeZone ? { timeZone } : {}),
});
DATE_PART_FORMATTERS.set(cacheKey, formatter);
return formatter;
};
const formatDatePart = (
part: ClockCardValuePart,
date: Date,
language: string,
timeZone?: string
) => getDatePartFormatter(part, language, timeZone).format(date);
/**
* Applies a single date token to Intl.DateTimeFormat options.
*/
const applyDatePartOption = (
options: Intl.DateTimeFormatOptions,
part: ClockCardDatePart
) => {
if (isDateSeparatorPart(part)) {
return;
}
const partOptions = DATE_PART_OPTIONS[part];
if (partOptions.weekday) {
options.weekday = partOptions.weekday;
}
if (partOptions.day) {
options.day = partOptions.day;
}
if (partOptions.month) {
options.month = partOptions.month;
}
if (partOptions.year) {
options.year = partOptions.year;
}
};
/**
* Sanitizes configured date tokens while preserving their literal order.
*/
const normalizeDateParts = (
parts: ClockCardConfig["date_format"]
): ClockCardDatePart[] =>
parts?.filter((part): part is ClockCardDatePart =>
isClockCardDatePart(part)
) || [];
/**
* Returns a normalized date config from a card configuration object.
*/
export const getClockCardDateConfig = (
config?: Pick<ClockCardConfig, "date_format">
): ClockCardDateConfig => ({
parts: normalizeDateParts(config?.date_format),
});
/**
* Checks whether the clock configuration resolves to any visible date output.
*/
export const hasClockCardDate = (
config?: Pick<ClockCardConfig, "date_format">
): boolean => getClockCardDateConfig(config).parts.length > 0;
/**
* Converts normalized date tokens into Intl.DateTimeFormat options.
*
* Separator tokens are ignored. If multiple tokens target the same Intl field,
* the last one wins.
*/
export const getClockCardDateTimeFormatOptions = (
dateConfig: ClockCardDateConfig
): Intl.DateTimeFormatOptions => {
const options: Intl.DateTimeFormatOptions = {};
dateConfig.parts.forEach((part) => {
applyDatePartOption(options, part);
});
return options;
};
/**
* Builds the final date string from literal date tokens.
*
* Value tokens are localized through Intl.DateTimeFormat. Separator tokens are
* always rendered literally. A default space is only inserted between adjacent
* value tokens.
*/
export const formatClockCardDate = (
date: Date,
dateConfig: ClockCardDateConfig,
language: string,
timeZone?: string
): string => {
let result = "";
let previousRenderedPartWasValue = false;
dateConfig.parts.forEach((part) => {
if (isDateSeparatorPart(part)) {
result += DATE_SEPARATORS[part];
previousRenderedPartWasValue = false;
return;
}
const value = formatDatePart(part, date, language, timeZone);
if (!value) {
return;
}
if (previousRenderedPartWasValue) {
result += " ";
}
result += value;
previousRenderedPartWasValue = true;
});
return result;
};
@@ -2,9 +2,16 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import type { HomeAssistant } from "../../../../types";
import type { ClockCardConfig } from "../types";
import {
formatClockCardDate,
getClockCardDateConfig,
hasClockCardDate,
resolveClockCardLocale,
} from "./clock-date-format";
function romanize12HourClock(num: number) {
const numerals = [
@@ -26,6 +33,11 @@ function romanize12HourClock(num: number) {
return numerals[num];
}
const DATE_UPDATE_INTERVAL = 60_000;
const QUARTER_TICKS = Array.from({ length: 4 }, (_, i) => i);
const HOUR_TICKS = Array.from({ length: 12 }, (_, i) => i);
const MINUTE_TICKS = Array.from({ length: 60 }, (_, i) => i);
@customElement("hui-clock-card-analog")
export class HuiClockCardAnalog extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@@ -40,42 +52,18 @@ export class HuiClockCardAnalog extends LitElement {
@state() private _secondOffsetSec?: number;
private _initDate() {
if (!this.config || !this.hass) {
return;
}
@state() private _date?: string;
let locale = this.hass.locale;
if (this.config.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
private _dateInterval?: number;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h12",
timeZone:
this.config.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
});
private _timeZone?: string;
this._computeOffsets();
}
protected updated(changedProps: PropertyValues<this>) {
if (changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (!oldHass || oldHass.locale !== this.hass?.locale) {
this._initDate();
}
}
}
private _language?: string;
public connectedCallback() {
super.connectedCallback();
document.addEventListener("visibilitychange", this._handleVisibilityChange);
this._computeOffsets();
this._initDate();
}
public disconnectedCallback() {
@@ -84,18 +72,80 @@ export class HuiClockCardAnalog extends LitElement {
"visibilitychange",
this._handleVisibilityChange
);
this._stopDateTick();
}
protected updated(changedProps: PropertyValues<this>) {
if (changedProps.has("config") || changedProps.has("hass")) {
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
changedProps.has("config") ||
!oldHass ||
oldHass.locale !== this.hass?.locale
) {
this._initDate();
}
}
}
private _handleVisibilityChange = () => {
if (!document.hidden) {
this._computeOffsets();
this._updateDate();
}
};
private _initDate() {
if (!this.config || !this.hass) {
this._stopDateTick();
this._date = undefined;
return;
}
const { timeZone } = resolveClockCardLocale(this.hass, this.config);
this._language = this.hass.locale.language;
this._timeZone = timeZone;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h12",
timeZone,
});
this._computeOffsets();
this._updateDate();
if (this.isConnected && hasClockCardDate(this.config)) {
this._startDateTick();
} else {
this._stopDateTick();
}
}
private _startDateTick() {
this._stopDateTick();
this._dateInterval = window.setInterval(
() => this._updateDate(),
DATE_UPDATE_INTERVAL
);
}
private _stopDateTick() {
if (this._dateInterval) {
clearInterval(this._dateInterval);
this._dateInterval = undefined;
}
}
private _computeOffsets() {
if (!this._dateTimeFormat) return;
const parts = this._dateTimeFormat.formatToParts();
const date = new Date();
const parts = this._dateTimeFormat.formatToParts(date);
const hourStr = parts.find((p) => p.type === "hour")?.value;
const minuteStr = parts.find((p) => p.type === "minute")?.value;
const secondStr = parts.find((p) => p.type === "second")?.value;
@@ -103,7 +153,7 @@ export class HuiClockCardAnalog extends LitElement {
const hour = hourStr ? parseInt(hourStr, 10) : 0;
const minute = minuteStr ? parseInt(minuteStr, 10) : 0;
const second = secondStr ? parseInt(secondStr, 10) : 0;
const ms = new Date().getMilliseconds();
const ms = date.getMilliseconds();
const secondsWithMs = second + ms / 1000;
const hour12 = hour % 12;
@@ -113,16 +163,44 @@ export class HuiClockCardAnalog extends LitElement {
this._hourOffsetSec = hour12 * 3600 + minute * 60 + secondsWithMs;
}
private _updateDate() {
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
this._date = undefined;
return;
}
const dateConfig = getClockCardDateConfig(this.config);
this._date = formatClockCardDate(
new Date(),
dateConfig,
this._language,
this._timeZone
);
}
private _computeClock = memoizeOne((config: ClockCardConfig) => {
const faceParts = config.face_style?.split("_");
const dateConfig = getClockCardDateConfig(config);
const showDate = hasClockCardDate(config);
const isLongDate =
dateConfig.parts.includes("month-long") ||
dateConfig.parts.includes("weekday-long");
return {
sizeClass: config.clock_size ? `size-${config.clock_size}` : "",
isNumbers: faceParts?.includes("numbers") ?? false,
isRoman: faceParts?.includes("roman") ?? false,
isUpright: faceParts?.includes("upright") ?? false,
showDate,
isLongDate,
};
});
render() {
if (!this.config) return nothing;
const sizeClass = this.config.clock_size
? `size-${this.config.clock_size}`
: "";
const isNumbers = this.config?.face_style?.startsWith("numbers");
const isRoman = this.config?.face_style?.startsWith("roman");
const isUpright = this.config?.face_style?.endsWith("upright");
const { sizeClass, isNumbers, isRoman, isUpright, isLongDate, showDate } =
this._computeClock(this.config);
const indicator = (number?: number) => html`
<div
@@ -163,14 +241,14 @@ export class HuiClockCardAnalog extends LitElement {
})}
>
${this.config.ticks === "quarter"
? Array.from({ length: 4 }, (_, i) => i).map(
? QUARTER_TICKS.map(
(i) =>
// 4 ticks (12, 3, 6, 9) at 0°, 90°, 180°, 270°
html`
<div
aria-hidden="true"
class="tick hour"
style=${`--tick-rotation: ${i * 90}deg;`}
style=${styleMap({ "--tick-rotation": `${i * 90}deg` })}
>
${indicator([12, 3, 6, 9][i])}
</div>
@@ -178,28 +256,30 @@ export class HuiClockCardAnalog extends LitElement {
)
: !this.config.ticks || // Default to hour ticks
this.config.ticks === "hour"
? Array.from({ length: 12 }, (_, i) => i).map(
? HOUR_TICKS.map(
(i) =>
// 12 ticks (1-12)
html`
<div
aria-hidden="true"
class="tick hour"
style=${`--tick-rotation: ${i * 30}deg;`}
style=${styleMap({ "--tick-rotation": `${i * 30}deg` })}
>
${indicator(((i + 11) % 12) + 1)}
</div>
`
)
: this.config.ticks === "minute"
? Array.from({ length: 60 }, (_, i) => i).map(
? MINUTE_TICKS.map(
(i) =>
// 60 ticks (1-60)
html`
<div
aria-hidden="true"
class="tick ${i % 5 === 0 ? "hour" : "minute"}"
style=${`--tick-rotation: ${i * 6}deg;`}
style=${styleMap({
"--tick-rotation": `${i * 6}deg`,
})}
>
${i % 5 === 0
? indicator(((i / 5 + 11) % 12) + 1)
@@ -208,14 +288,34 @@ export class HuiClockCardAnalog extends LitElement {
`
)
: nothing}
${showDate
? html`<div
class=${classMap({
date: true,
[sizeClass]: true,
"long-date": isLongDate,
})}
>
${this._date
?.split("\n")
.map((line, index) =>
index > 0 ? html`<br />${line}` : line
)}
</div>`
: nothing}
<div class="center-dot"></div>
<div
class="hand hour"
style=${`animation-delay: -${this._hourOffsetSec ?? 0}s;`}
style=${styleMap({
"animation-delay": `-${this._hourOffsetSec ?? 0}s`,
})}
></div>
<div
class="hand minute"
style=${`animation-delay: -${this._minuteOffsetSec ?? 0}s;`}
style=${styleMap({
"animation-delay": `-${this._minuteOffsetSec ?? 0}s`,
})}
></div>
${this.config.show_seconds
? html`<div
@@ -224,11 +324,13 @@ export class HuiClockCardAnalog extends LitElement {
second: true,
step: this.config.seconds_motion === "tick",
})}
style=${`animation-delay: -${
(this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)) as number
}s;`}
style=${styleMap({
"animation-delay": `-${
this.config.seconds_motion === "tick"
? Math.floor(this._secondOffsetSec ?? 0)
: (this._secondOffsetSec ?? 0)
}s`,
})}
></div>`
: nothing}
</div>
@@ -407,6 +509,36 @@ export class HuiClockCardAnalog extends LitElement {
transform: translate(-50%, 0) rotate(360deg);
}
}
.date {
position: absolute;
top: 68%;
left: 50%;
transform: translate(-50%, -50%);
display: block;
color: var(--primary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
text-align: center;
opacity: 0.8;
max-width: 87%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.date.long-date:not(.size-medium):not(.size-large) {
font-size: var(--ha-font-size-xs);
}
.date.size-medium {
font-size: var(--ha-font-size-l);
}
.date.size-large {
font-size: var(--ha-font-size-xl);
}
`;
}
@@ -4,7 +4,12 @@ import { customElement, property, state } from "lit/decorators";
import type { ClockCardConfig } from "../types";
import type { HomeAssistant } from "../../../../types";
import { useAmPm } from "../../../../common/datetime/use_am_pm";
import { resolveTimeZone } from "../../../../common/datetime/resolve-time-zone";
import {
formatClockCardDate,
getClockCardDateConfig,
hasClockCardDate,
resolveClockCardLocale,
} from "./clock-date-format";
const INTERVAL = 1000;
@@ -24,37 +29,50 @@ export class HuiClockCardDigital extends LitElement {
@state() private _timeAmPm?: string;
@state() private _date?: string;
private _tickInterval?: undefined | number;
private _lastDateMinute?: string;
private _timeZone?: string;
private _language?: string;
private _initDate() {
if (!this.config || !this.hass) {
this._date = undefined;
this._lastDateMinute = undefined;
return;
}
let locale = this.hass?.locale;
if (this.config?.time_format) {
locale = { ...locale, time_format: this.config.time_format };
}
const { locale, timeZone } = resolveClockCardLocale(this.hass, this.config);
const h12 = useAmPm(locale);
this._language = this.hass.locale.language;
this._timeZone = timeZone;
this._dateTimeFormat = new Intl.DateTimeFormat(this.hass.locale.language, {
hour: h12 ? "numeric" : "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: h12 ? "h12" : "h23",
timeZone:
this.config?.time_zone ||
resolveTimeZone(locale.time_zone, this.hass.config?.time_zone),
timeZone,
});
this._lastDateMinute = undefined;
this._tick();
}
protected updated(changedProps: PropertyValues<this>) {
if (changedProps.has("hass")) {
if (changedProps.has("config") || changedProps.has("hass")) {
const oldHass = changedProps.get("hass");
if (!oldHass || oldHass.locale !== this.hass?.locale) {
if (
changedProps.has("config") ||
!oldHass ||
oldHass.locale !== this.hass?.locale
) {
this._initDate();
}
}
@@ -71,6 +89,7 @@ export class HuiClockCardDigital extends LitElement {
}
private _startTick() {
this._stopTick();
this._tickInterval = window.setInterval(() => this._tick(), INTERVAL);
this._tick();
}
@@ -85,7 +104,8 @@ export class HuiClockCardDigital extends LitElement {
private _tick() {
if (!this._dateTimeFormat) return;
const parts = this._dateTimeFormat.formatToParts();
const date = new Date();
const parts = this._dateTimeFormat.formatToParts(date);
this._timeHour = parts.find((part) => part.type === "hour")?.value;
this._timeMinute = parts.find((part) => part.type === "minute")?.value;
@@ -93,6 +113,33 @@ export class HuiClockCardDigital extends LitElement {
? parts.find((part) => part.type === "second")?.value
: undefined;
this._timeAmPm = parts.find((part) => part.type === "dayPeriod")?.value;
this._updateDate(date);
}
private _updateDate(date: Date) {
if (!this.config || !hasClockCardDate(this.config) || !this._language) {
this._date = undefined;
this._lastDateMinute = undefined;
return;
}
if (
this._timeMinute !== undefined &&
this._timeMinute === this._lastDateMinute &&
this._date !== undefined
) {
return;
}
const dateConfig = getClockCardDateConfig(this.config);
this._date = formatClockCardDate(
date,
dateConfig,
this._language,
this._timeZone
);
this._lastDateMinute = this._timeMinute;
}
render() {
@@ -101,18 +148,30 @@ export class HuiClockCardDigital extends LitElement {
const sizeClass = this.config.clock_size
? `size-${this.config.clock_size}`
: "";
const showDate = hasClockCardDate(this.config);
return html`
<div class="time-parts ${sizeClass}">
<div class="time-part hour">${this._timeHour}</div>
<div class="time-part minute">${this._timeMinute}</div>
${this._timeSecond !== undefined
? html`<div class="time-part second">${this._timeSecond}</div>`
: nothing}
${this._timeAmPm !== undefined
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
: nothing}
<div class="clock-container">
<div class="time-parts ${sizeClass}">
<div class="time-part hour">${this._timeHour}</div>
<div class="time-part minute">${this._timeMinute}</div>
${this._timeSecond !== undefined
? html`<div class="time-part second">${this._timeSecond}</div>`
: nothing}
${this._timeAmPm !== undefined
? html`<div class="time-part am-pm">${this._timeAmPm}</div>`
: nothing}
</div>
</div>
${showDate
? html`<div class="date-container">
<div class="date ${sizeClass}">
${this._date
?.split("\n")
.map((line, index) => (index > 0 ? html`<br />${line}` : line))}
</div>
</div>`
: nothing}
`;
}
@@ -121,6 +180,17 @@ export class HuiClockCardDigital extends LitElement {
display: block;
}
.clock-container {
width: 100%;
display: flex;
justify-content: center;
}
.date-container {
width: 100%;
margin-top: var(--ha-space-1);
}
.time-parts {
align-items: center;
display: grid;
@@ -188,6 +258,21 @@ export class HuiClockCardDigital extends LitElement {
content: ":";
margin: 0 2px;
}
.date {
text-align: center;
opacity: 0.8;
font-size: var(--ha-font-size-s);
line-height: 1.1;
}
.date.size-medium {
font-size: var(--ha-font-size-l);
}
.date.size-large {
font-size: var(--ha-font-size-2xl);
}
`;
}
@@ -0,0 +1,378 @@
import { mdiTransmissionTower } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import "../../../../components/tile/ha-tile-icon";
import "../../../../components/tile/ha-tile-info";
import type { EnergyData } from "../../../../data/energy";
import {
getEnergyDataCollection,
getSummedData,
validateEnergyCollectionKey,
} from "../../../../data/energy";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { EnergyGridBalanceCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
@customElement("hui-energy-grid-balance-card")
class HuiEnergyGridBalanceCard
extends SubscribeMixin(LitElement)
implements LovelaceCard
{
public static async getConfigElement() {
await import("../../editor/config-elements/hui-energy-graph-card-editor");
return document.createElement("hui-energy-graph-card-editor");
}
public static getStubConfig(
_hass: HomeAssistant,
_entities: string[],
_entitiesFill: string[]
): EnergyGridBalanceCardConfig {
return {
type: "energy-grid-balance",
};
}
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: EnergyGridBalanceCardConfig;
@state() private _data?: EnergyData;
protected hassSubscribeRequiredHostProps = ["_config"];
public hassSubscribe(): UnsubscribeFunc[] {
return [
getEnergyDataCollection(this.hass!, {
key: this._config?.collection_key,
}).subscribe((data) => {
this._data = data;
}),
];
}
public getCardSize(): number {
return 1;
}
public setConfig(config: EnergyGridBalanceCardConfig): void {
if (config.collection_key) {
validateEnergyCollectionKey(config.collection_key);
}
this._config = config;
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
return (
hasConfigChanged(this, changedProps) ||
changedProps.size > 1 ||
!changedProps.has("hass")
);
}
protected render() {
if (!this._config || !this.hass) {
return nothing;
}
if (!this._data) {
return html`${this.hass.localize(
"ui.panel.lovelace.cards.energy.loading"
)}`;
}
const { summedData } = getSummedData(this._data);
const imported = summedData.total.from_grid ?? 0;
const exported = summedData.total.to_grid ?? 0;
const net = imported - exported;
const fmt = (value: number) =>
formatNumber(
value,
this.hass.locale,
Math.abs(value) < 0.01
? { maximumSignificantDigits: 2 }
: { maximumFractionDigits: 2 }
);
const isConsumption = net >= 0;
const max = Math.max(imported, exported);
const leftPercent = max > 0 ? (exported / max) * 100 : 0;
const rightPercent = max > 0 ? (imported / max) * 100 : 0;
const netBarWidth = max > 0 ? (Math.abs(net) / max) * 100 : 0;
return html`
<ha-card>
<div class="content">
<ha-tile-icon>
<ha-svg-icon
slot="icon"
.path=${mdiTransmissionTower}
></ha-svg-icon>
</ha-tile-icon>
<ha-tile-info>
<span slot="primary">
${this._config.title ||
this.hass.localize(
"ui.panel.lovelace.cards.energy.grid_balance.title"
)}
</span>
<span slot="secondary" class="equation">
<span class="imported" id="eq-imported">
${fmt(imported)} kWh
</span>
<ha-tooltip for="eq-imported" placement="top">
${this.hass.localize(
"ui.panel.lovelace.cards.energy.grid_balance.imported",
{ value: fmt(imported) }
)}
</ha-tooltip>
<span class="operator"> - </span>
<span class="exported" id="eq-exported">
${fmt(exported)} kWh
</span>
<ha-tooltip for="eq-exported" placement="top">
${this.hass.localize(
"ui.panel.lovelace.cards.energy.grid_balance.exported",
{ value: fmt(exported) }
)}
</ha-tooltip>
<span class="operator"> = </span>
<span
class="net ${isConsumption ? "consumption" : "return"}"
id="eq-net"
>
${fmt(net)} kWh
</span>
<ha-tooltip for="eq-net" placement="top">
${this.hass.localize(
`ui.panel.lovelace.cards.energy.grid_balance.net_${isConsumption ? "import" : "export"}`,
{ value: fmt(Math.abs(net)) }
)}
</ha-tooltip>
</span>
</ha-tile-info>
</div>
<div class="bar">
<div class="bar-half bar-left">
<div
id="bar-exported"
class="bar-fill return"
style="width: ${leftPercent}%"
></div>
<ha-tooltip for="bar-exported" placement="top">
${this.hass.localize(
"ui.panel.lovelace.cards.energy.grid_balance.exported",
{ value: fmt(exported) }
)}
</ha-tooltip>
${!isConsumption
? html`<div
id="bar-net-left"
class="bar-net return"
style="width: ${netBarWidth}%"
></div>
<ha-tooltip for="bar-net-left" placement="top">
${this.hass.localize(
"ui.panel.lovelace.cards.energy.grid_balance.net_export",
{
value: fmt(Math.abs(net)),
}
)}
</ha-tooltip>`
: nothing}
</div>
<div class="bar-center"></div>
<div class="bar-half bar-right">
<div
id="bar-imported"
class="bar-fill consumption"
style="width: ${rightPercent}%"
></div>
<ha-tooltip for="bar-imported" placement="top">
${this.hass.localize(
"ui.panel.lovelace.cards.energy.grid_balance.imported",
{ value: fmt(imported) }
)}
</ha-tooltip>
${isConsumption
? html`<div
id="bar-net-right"
class="bar-net consumption"
style="width: ${netBarWidth}%"
></div>
<ha-tooltip for="bar-net-right" placement="top">
${this.hass.localize(
"ui.panel.lovelace.cards.energy.grid_balance.net_import",
{
value: fmt(Math.abs(net)),
}
)}
</ha-tooltip>`
: nothing}
</div>
</div>
</ha-card>
`;
}
static styles = css`
ha-card {
height: 100%;
display: flex;
flex-direction: column;
}
.content {
display: flex;
align-items: center;
padding: var(--ha-space-3);
gap: var(--ha-space-3);
flex: 1;
min-width: 0;
}
ha-tile-icon {
--tile-icon-color: var(--state-inactive-color);
}
.equation {
display: flex;
flex-wrap: wrap;
}
.operator {
color: var(--secondary-text-color);
white-space: pre;
}
.imported {
color: var(--energy-grid-consumption-color);
}
.exported {
color: var(--energy-grid-return-color);
}
.net.consumption {
color: var(--energy-grid-consumption-color);
}
.net.return {
color: var(--energy-grid-return-color);
}
.bar {
position: relative;
display: flex;
height: 42px;
margin: 0 var(--ha-space-3) var(--ha-space-4);
overflow: visible;
}
.bar-half {
position: relative;
display: flex;
flex: 1;
height: 100%;
overflow: hidden;
border: var(--ha-border-width-sm) solid;
}
.bar-left {
flex-direction: row-reverse;
border-radius: var(--ha-border-radius-lg) 0 0 var(--ha-border-radius-lg);
border-color: var(--ha-color-border-neutral-quiet);
border-color: color-mix(
in srgb,
var(--energy-grid-return-color) 30%,
transparent
);
border-right: none;
}
.bar-right {
border-radius: 0 var(--ha-border-radius-lg) var(--ha-border-radius-lg) 0;
border-color: var(--ha-color-border-neutral-quiet);
border-color: color-mix(
in srgb,
var(--energy-grid-consumption-color) 30%,
transparent
);
border-left: none;
}
.bar-fill {
position: absolute;
top: 0;
height: 100%;
opacity: 0.3;
transition: width var(--ha-animation-duration-fast) ease-in-out;
}
.bar-left .bar-fill {
right: 0;
}
.bar-right .bar-fill {
left: 0;
}
.bar-fill.consumption {
background-color: var(--energy-grid-consumption-color);
}
.bar-fill.return {
background-color: var(--energy-grid-return-color);
}
.bar-net {
position: absolute;
top: 0;
height: 100%;
transition: width var(--ha-animation-duration-fast) ease-in-out;
}
.bar-left .bar-net {
right: 0;
}
.bar-right .bar-net {
left: 0;
}
.bar-net.consumption {
background-color: var(--energy-grid-consumption-color);
}
.bar-net.return {
background-color: var(--energy-grid-return-color);
}
.bar-center {
position: absolute;
left: 50%;
top: -6px;
transform: translateX(-50%);
width: 2px;
height: calc(100% + 12px);
background: var(--primary-text-color);
z-index: 1;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-energy-grid-balance-card": HuiEnergyGridBalanceCard;
}
}
@@ -663,12 +663,6 @@ class HuiPowerSankeyCard
let used_total_remaining = Math.max(used_total, 0);
let grid_to_battery = 0;
let battery_to_grid = 0;
let solar_to_battery = 0;
let solar_to_grid = 0;
let used_solar = 0;
let used_battery = 0;
let used_grid = 0;
// Handle excess grid input to battery first
const excess_grid_in_after_consumption = Math.max(
@@ -680,40 +674,34 @@ class HuiPowerSankeyCard
grid_remaining -= excess_grid_in_after_consumption;
// Solar -> Battery_In
solar_to_battery = Math.min(solar_remaining, to_battery_remaining);
const solar_to_battery = Math.min(solar_remaining, to_battery_remaining);
to_battery_remaining -= solar_to_battery;
solar_remaining -= solar_to_battery;
// Solar -> Grid_Out
solar_to_grid = Math.min(solar_remaining, to_grid_remaining);
const solar_to_grid = Math.min(solar_remaining, to_grid_remaining);
to_grid_remaining -= solar_to_grid;
solar_remaining -= solar_to_grid;
// Battery_Out -> Grid_Out
battery_to_grid = Math.min(battery_remaining, to_grid_remaining);
const battery_to_grid = Math.min(battery_remaining, to_grid_remaining);
battery_remaining -= battery_to_grid;
to_grid_remaining -= battery_to_grid;
// Grid_In -> Battery_In (second pass)
const grid_to_battery_2 = Math.min(grid_remaining, to_battery_remaining);
grid_to_battery += grid_to_battery_2;
grid_remaining -= grid_to_battery_2;
to_battery_remaining -= grid_to_battery_2;
// Solar -> Consumption
used_solar = Math.min(used_total_remaining, solar_remaining);
const used_solar = Math.min(used_total_remaining, solar_remaining);
used_total_remaining -= used_solar;
solar_remaining -= used_solar;
// Battery_Out -> Consumption
used_battery = Math.min(battery_remaining, used_total_remaining);
battery_remaining -= used_battery;
const used_battery = Math.min(battery_remaining, used_total_remaining);
used_total_remaining -= used_battery;
// Grid_In -> Consumption
used_grid = Math.min(used_total_remaining, grid_remaining);
grid_remaining -= used_grid;
used_total_remaining -= used_grid;
const used_grid = Math.min(used_total_remaining, grid_remaining);
return {
solar,
+8
View File
@@ -8,6 +8,7 @@ import type { HomeAssistant } from "../../../types";
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
import { migrateLayoutToGridOptions } from "../common/compute-card-grid-size";
import { computeCardSize } from "../common/compute-card-size";
import { getConfigEntityId } from "../common/get-config-entity-id";
import { checkConditionsMet } from "../common/validate-condition";
import { tryCreateCardElement } from "../create-element/create-card-element";
import { createErrorCardElement } from "../create-element/create-element-base";
@@ -169,6 +170,13 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (changedProps.has("config")) {
this._conditionContext = {
...this._conditionContext,
entity_id: this.config ? getConfigEntityId(this.config) : undefined,
};
}
if (!this._element) {
this.load();
}
+21
View File
@@ -240,6 +240,10 @@ export interface EnergyGridNeutralityGaugeCardConfig extends EnergyCardConfig {
type: "energy-grid-neutrality-gauge";
}
export interface EnergyGridBalanceCardConfig extends EnergyCardConfig {
type: "energy-grid-balance";
}
export interface EnergyCarbonGaugeCardConfig extends EnergyCardConfig {
type: "energy-carbon-consumed-gauge";
}
@@ -442,12 +446,29 @@ export interface ClockCardConfig extends LovelaceCardConfig {
time_format?: TimeFormat;
time_zone?: string;
no_background?: boolean;
date_format?: ClockCardDatePart[];
// Analog clock options
border?: boolean;
ticks?: "none" | "quarter" | "hour" | "minute";
face_style?: "markers" | "numbers_upright" | "roman";
}
export type ClockCardDatePart =
| "weekday-short"
| "weekday-long"
| "day-numeric"
| "day-2-digit"
| "month-short"
| "month-long"
| "month-numeric"
| "month-2-digit"
| "year-2-digit"
| "year-numeric"
| "separator-dash"
| "separator-slash"
| "separator-dot"
| "separator-new-line";
export interface MediaControlCardConfig extends LovelaceCardConfig {
entity: string;
name?: string | EntityNameItem | EntityNameItem[];
@@ -70,7 +70,6 @@ export const computeTooltip = (hass: HomeAssistant, config: Config): string => {
}
let stateName = "";
let tooltip = "";
if (config.entity) {
stateName =
@@ -92,7 +91,5 @@ export const computeTooltip = (hass: HomeAssistant, config: Config): string => {
const newline = tapTooltip && holdTooltip ? "\n" : "";
tooltip = tapTooltip + newline + holdTooltip;
return tooltip;
return tapTooltip + newline + holdTooltip;
};
@@ -1,4 +1,3 @@
/* eslint-disable max-classes-per-file */
import type { AttributePart } from "lit";
import { noChange } from "lit";
import { customElement } from "lit/decorators";
@@ -220,7 +220,6 @@ export const computeCards = (
if (
titlePrefix &&
stateObj &&
// eslint-disable-next-line no-cond-assign
(name = stripPrefixFromEntityName(
computeStateName(stateObj),
titlePrefix
@@ -234,7 +233,6 @@ export const computeCards = (
const entityConf =
titlePrefix &&
stateObj &&
// eslint-disable-next-line no-cond-assign
(name = stripPrefixFromEntityName(
computeStateName(stateObj),
titlePrefix

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