Compare commits

..

100 Commits

Author SHA1 Message Date
Simon Lamon 60c86899f3 Swap google-timezones-json to @vvo/tzdb (#52770) 2026-06-25 16:14:42 +02:00
Petar Petrov f8d870d6bb Group Sankey flow siblings under their parent to fix segment crossovers (#52867) 2026-06-25 16:12:52 +02:00
Copilot 4d82b352a9 Localize "(default)" label in Edit sidebar dialog (#52868)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-25 16:02:29 +02:00
Paul Bottein 179b4cf77c Show dash for unavailable number entity in slider row (#52866) 2026-06-25 14:17:54 +02:00
Paul Bottein 542f07606a Fix logbook padding and margin (#52864) 2026-06-25 14:17:24 +02:00
Paul Bottein cf2c440e7b Show action labels instead of timestamps in the logbook (#52861) 2026-06-25 14:16:01 +02:00
Franck Nijhof 27fbabb71b Use choose selector for legacy trigger fields (#52859)
* Use choose selector for legacy trigger fields

Replace the duration-only selector on the `for` field in the state,
numeric_state, and template triggers with a choose selector that
offers both duration and template options.

Replace the hand-rolled lower_limit/upper_limit select toggle for
above/below in the numeric_state trigger with a choose selector
that switches between a fixed number and an entity reference.

Add translation entries for the choose selector toggle button labels.

* Shorten the numeric state value toggle label

Use "Value of an entity" instead of "Numeric value of another entity" for
the numeric state trigger toggle, so it stays compact.
2026-06-25 12:57:45 +02:00
Paul Bottein 389af6e00c Keep self-closing slashes when minifying svg`` templates (#52857)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 10:31:58 +01:00
Bram Kragten 7ff4cf58e8 Split config sections from panel config, add CI for entrypoint size (#52830)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-25 07:11:53 +00:00
renovate[bot] f849302876 Update dependency @rspack/dev-server to v2.1.0 (#52856)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-25 09:56:17 +03:00
Paulus Schoutsen 1db707937b Show supported frequencies column in radio frequency devices list (#52851)
Add a "Frequencies" column to the radio frequency devices (proxy) list so
users can see which frequency bands each transmitter supports. The supported
frequency ranges are formatted into a human-readable, locale-aware string
(picking Hz/kHz/MHz/GHz automatically) with a helper in the data layer.


Claude-Session: https://claude.ai/code/session_01SYyMTtBdrt7EBrVEt869Uw

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 08:08:29 +03:00
Franck Nijhof 70f0d12e43 Use the Jinja block comment for toggle-comment in templates (#52854)
The jinja2 editor mode is rendered on a YAML base, so Ctrl+/ inserted a "#"
line comment, which does nothing useful in a template. Give the jinja2
language a Jinja block comment token so toggle-comment wraps with {# #},
while the plain YAML mode keeps its # comment.
2026-06-25 08:06:54 +03:00
Michael Hansen 12bb09dad2 Add demo voice assistants and exposed entities (#52855) 2026-06-24 18:23:52 -04:00
Aidan Timson f08ffefe28 Output combined e2e report on failure to markdown comment (#52844)
* Output combined e2e report on failure to markdown comment

* Move to file, parse json file (markdown output didnt exist)

* Add syntax highlighting
2026-06-24 20:14:36 +02:00
Aidan Timson 9de89278cd Move inline workflow mjs scripts to dedicated files, add to eslint config (#52846)
* Move inline workflow mjs scripts to dedicated files, add to eslint config

* Potential fix for pull request finding 'CodeQL / Incomplete multi-character sanitization'

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-24 20:10:01 +02:00
renovate[bot] 207d997a3a Update playwright monorepo (#52839)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-24 18:16:49 +02:00
Bram Kragten 0bb32aa1b4 Provide Lit contexts to gallery demos; stop ignoring init errors (#52845) 2026-06-24 17:09:15 +02:00
Bram Kragten ba0310ee58 Show warning when priming will not work for condition (#52709)
* Show warning when priming will not work for condition

* rename

* change to warning icon with tooltip

* review

* Update duration_to_seconds.test.ts
2026-06-24 16:00:23 +02:00
Bram Kragten 7da090aec5 Merge branch 'rc' into dev 2026-06-24 15:33:18 +02:00
Bram Kragten f216d97315 Bumped version to 20260624.0 2026-06-24 15:29:46 +02:00
Aidan Timson ad21be1ace Allow middle click on dashboard views to open new tab (#52808)
* Allow opening Lovelace views in new tabs

* Allow opening Lovelace back targets in new tabs

* Review
2026-06-24 14:25:50 +02:00
Aidan Timson 811545581c Only run e2e report if jobs not cancelled (#52842) 2026-06-24 14:18:25 +02:00
Bram Kragten 807199c54b Add time and sun category to target view (#52819) 2026-06-24 14:11:21 +02:00
Bram Kragten 77cef2429b Fix minify-literals build error in box-shadow gallery page (#52840)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 12:08:38 +00:00
Jan-Philipp Benecke b6eb4a50d9 Fix ES5 transpilation for lit-html (#52835)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-06-24 11:59:06 +00:00
Aidan Timson 75fded1a43 Migrate entity picker to context (#52833) 2026-06-24 13:53:48 +02:00
Bram Kragten e53ffd76ac Add Playwright e2e tests (local Chromium) (#51929)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 13:50:47 +02:00
Petar Petrov 54c54fa5a2 Fix media player volume slider clipped at 100% in entities card (#52838) 2026-06-24 12:48:05 +01:00
Petar Petrov a4aec3a734 Replace babel-plugin-template-html-minifier with minify-literals (#52818) 2026-06-24 12:12:57 +02:00
Bram Kragten c73e735164 Fix search bar look in datatables (#52831)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:11:32 +00:00
Petar Petrov d4b1fe0c7f Roll the energy "Now" view over to the new day at midnight (#52829) 2026-06-24 10:08:41 +02:00
Dmytro Platov 8ecd350e6f Add Zigbee configuration handling and loading state to ZHA dashboard (#52697)
* Add Zigbee configuration handling and loading state to ZHA dashboard

- Introduced `findActiveZhaConfigEntry` function to filter active Zigbee config entries.
- Updated ZHAConfigDashboard to manage loading state and display a spinner while loading.
- Added UI elements for not configured state with appropriate translations.
- Created tests for `findActiveZhaConfigEntry` to ensure correct functionality.

* fix: remove unused config entry logic and update initialization checks

* Restore active config entry filter in _fetchConfigEntry

* Remove redundant config entry ternary in render
2026-06-24 06:48:18 +00:00
renovate[bot] a26de31a2d Update dependency lint-staged to v17.0.8 (#52825)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-24 07:33:20 +03:00
renovate[bot] 77110afc59 Update Node.js to v24.18.0 (#52827)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-24 07:32:55 +03:00
Paul Bottein 7b6e9ba738 Add by entity suggestions to the badge picker (#52733) 2026-06-23 21:07:14 +02:00
renovate[bot] 1b15bc721b Update babel monorepo (#52814)
* Update babel monorepo

* Migrate Core-JS polyfilling for Babel 8

Babel 8.0.1 removed preset-env's `useBuiltIns`/`corejs` options. Replace
them with the babel-plugin-polyfill-corejs3 provider directly
(`usage-global`), and pin transform-runtime's `moduleName` to
`@babel/runtime` so the provider doesn't redirect helpers to the
uninstalled `@babel/runtime-corejs3`.

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-06-23 15:38:07 +03:00
Bram Kragten 93a8d296a8 Move purpose-specific triggers and conditions out of labs (#52801) 2026-06-23 14:11:32 +02:00
Bram Kragten 978c600236 Merge branch 'rc' 2026-06-19 14:26:47 +02:00
Bram Kragten 29759a6dc6 Bumped version to 20260527.7 2026-06-19 14:26:36 +02:00
Franck Nijhof bf85cb80de Auto-select first voice in required TTS voice picker (#52576)
When a voice was required and no value was set, the picker displayed the
first voice in the dropdown but kept its own value undefined and never
fired a value-changed event. As a result, the parent (for example the TTS
test card in the media browser) never learned the voice: the selected
voice id footer stayed hidden and no voice was sent on synthesis. This was
most noticeable for languages with a single available voice, where the
selection could not be changed to force an event.

Auto-select and emit the first voice when one is required and the current
value is missing or no longer valid for the loaded voices, so the value
matches what the dropdown shows. Non-required usages keep clearing the
value as before.
2026-06-19 14:26:16 +02:00
karwosts 64984cb2ed Harden helpers table against bad labels, fix registry editor (#52516)
* Harden helpers table against bad labels, fix registry editor

* Revert "Harden helpers table against bad labels, fix registry editor"

This reverts commit cf15e1da33.

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

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

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

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

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

* Apply prettier formatting

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

---------

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

* Patch tinykeys v4

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

* add canEdit

* add subEditor

* linter

* linter

* linter

* linter

* Remove div

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

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

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

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

* Update ha-statistic-picker.ts

* Update ha-statistic-picker.ts

---------

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

* add core download progress

* reduce to 2 seconds

* Use round to display full integer as progress percentage

* Use find to get the job object

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

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

---------

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

* Apply suggestions from code review

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

* Apply suggestions from code review

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

---------

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

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

* Keep logo

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

* Add noninteractive for device, fix keyboard nav

* Noninteractive action, conditon

* Remove unsued hass

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

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

* Simplify

* Refactor _getSeries

* Small fix

* Fix merge mistake

* Marker component and wrapper test
2026-05-28 16:45:04 +02:00
192 changed files with 7417 additions and 3684 deletions
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env node
// Fails the check when a pull request carries a label that blocks merging, and
// writes the outcome to the job summary. Invoked from the `check` job in
// .github/workflows/blocking-labels.yaml via actions/github-script:
//
// const { default: checkBlockingLabels } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`);
// await checkBlockingLabels({ github, context, core });
export default async function checkBlockingLabels({ context, core }) {
const blockingLabels = [
"wait for backend",
"Needs UX",
"Do Not Review",
"Blocked",
"has-parent",
];
const prLabels = context.payload.pull_request.labels.map((l) => l.name);
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
if (found.length > 0) {
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
await core.summary
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
.addRaw(message)
.write();
core.setFailed(message);
} else {
await core.summary
.addHeading(
":white_check_mark: Pull Request is clear to merge after review",
2
)
.addRaw(
"This Pull Request is not blocked by any labels which prevent it from being merged."
)
.write();
}
}
@@ -0,0 +1,195 @@
#!/usr/bin/env node
// Checks that a pull request follows the contribution standards: it must use the
// PR template, tick exactly one "Type of change" option, and describe the change.
// Labels and comments the PR when it does not, and fails the check so it blocks
// merging. Invoked from the `check` job in .github/workflows/pull-request-standards.yaml
// via actions/github-script:
//
// const { default: checkStandards } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`);
// await checkStandards({ github, context, core });
export default async function checkPullRequestStandards({
github,
context,
core,
}) {
const pr = context.payload.pull_request;
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
if (pr.user.type === "Bot") {
core.info(`Skipping bot author: ${pr.user.login}`);
return;
}
if (pr.draft) {
core.info("Skipping draft pull request");
return;
}
try {
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: pr.user.login,
});
core.info(`Skipping organization member: ${pr.user.login}`);
return;
} catch (_error) {
core.info(
`${pr.user.login} is not an organization member, checking standards`
);
}
const label = "Needs Template";
const marker = "<!-- pr-standards-check -->";
const { owner, repo } = context.repo;
const issue_number = pr.number;
let body = pr.body || "";
let previous;
do {
previous = body;
body = body.replace(/<!--[\s\S]*?-->/g, "");
} while (body !== previous);
const normalized = body.toLowerCase();
// Ignore 404s from mutations that race manual edits or cancelled runs.
const ignoreMissing = async (fn) => {
try {
await fn();
} catch (error) {
if (error.status === 404) {
core.info("Target already removed, nothing to do");
return;
}
throw error;
}
};
// Hide/restore our comment via GraphQL (REST cannot minimize).
const setMinimized = async (subjectId, minimized) => {
const mutation = minimized
? `mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
clientMutationId
}
}`
: `mutation($id: ID!) {
unminimizeComment(input: { subjectId: $id }) {
clientMutationId
}
}`;
try {
await github.graphql(mutation, { id: subjectId });
} catch (error) {
core.info(
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
);
}
};
// Content of a "## <name>" section, or null when the heading is absent.
const section = (name) => {
const match = body.match(
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
);
return match ? match[1] : null;
};
const problems = [];
const requiredHeadings = [
"## proposed change",
"## type of change",
"## checklist",
];
if (requiredHeadings.some((h) => !normalized.includes(h))) {
problems.push(
"Use the pull request template without removing its sections."
);
}
const typeOfChange = section("type of change");
if (typeOfChange !== null) {
const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length;
if (ticked !== 1) {
problems.push('Select exactly one option under "Type of change".');
}
}
const proposedChange = section("proposed change");
if (proposedChange !== null && proposedChange.trim().length === 0) {
problems.push('Describe your changes under "Proposed change".');
}
const isValid = problems.length === 0;
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
const existing = comments.find((c) => c.body.includes(marker));
const hasLabel = pr.labels.some((l) => l.name === label);
if (isValid) {
core.info("Pull request standards met");
if (hasLabel) {
await ignoreMissing(() =>
github.rest.issues.removeLabel({
owner,
repo,
issue_number,
name: label,
})
);
}
if (existing) {
await setMinimized(existing.node_id, true);
}
return;
}
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
if (!hasLabel) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number,
labels: [label],
});
}
const message =
`${marker}\n` +
`Hey @${pr.user.login}!\n\n` +
`Thank you for your contribution! To help reviewers, please update ` +
`this pull request to follow our pull request standards:\n\n` +
problems.map((p) => `- ${p}`).join("\n") +
`\n\n` +
`Please complete the ` +
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
`for more on creating a great pull request (see point 6).`;
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body: message,
});
await setMinimized(existing.node_id, false);
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: message,
});
}
// Fail this check so it can block the PR from being merged
core.setFailed(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
}
@@ -0,0 +1,58 @@
#!/usr/bin/env node
// Restricts Task issues to organization members: closes and labels the issue with
// an explanatory comment when the author is not an org member. Invoked from the
// `check-authorization` job in .github/workflows/restrict-task-creation.yml via
// actions/github-script:
//
// const { default: checkTaskAuthorization } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`);
// await checkTaskAuthorization({ github, context, core });
export default async function checkTaskAuthorization({
github,
context,
core,
}) {
const issueAuthor = context.payload.issue.user.login;
// Check if user is an organization member
try {
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: issueAuthor,
});
core.info(`${issueAuthor} is an organization member`);
return; // Authorized
} catch (_error) {
core.info(`${issueAuthor} is not authorized to create Task issues`);
}
// Close the issue with a comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body:
`Hi @${issueAuthor}, thank you for your contribution!\n\n` +
`Task issues are restricted to Open Home Foundation staff and authorized contributors.\n\n` +
`If you would like to:\n` +
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/frontend/issues/new?template=bug_report.yml)\n` +
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
`If you believe you should have access to create Task issues, please contact the maintainers.`,
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: "closed",
});
// Add a label to indicate this was auto-closed
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ["auto-closed"],
});
}
+8 -23
View File
@@ -20,31 +20,16 @@ jobs:
name: Check for labels which block the Pull Request from being merged
runs-on: ubuntu-latest
steps:
- name: Check out workflow scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check for blocking labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const blockingLabels = [
"wait for backend",
"Needs UX",
"Do Not Review",
"Blocked",
"has-parent",
];
const prLabels = context.payload.pull_request.labels.map(
(l) => l.name
const { default: checkBlockingLabels } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`
);
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
if (found.length > 0) {
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
await core.summary
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
.addRaw(message)
.write();
core.setFailed(message);
} else {
await core.summary
.addHeading(":white_check_mark: Pull Request is clear to merge after review", 2)
.addRaw("This Pull Request is not blocked by any labels which prevent it from being merged.")
.write();
}
await checkBlockingLabels({ github, context, core });
+2
View File
@@ -105,6 +105,8 @@ jobs:
name: frontend-bundle-stats
path: build/stats/*.json
if-no-files-found: error
- name: Check entrypoint bundle size budget
run: yarn run check-bundlesize
- name: Upload frontend build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
+240
View File
@@ -0,0 +1,240 @@
name: E2E Tests
on:
push:
branches:
- dev
- master
pull_request:
branches:
- dev
- master
workflow_dispatch:
env:
NODE_OPTIONS: --max_old_space_size=6144
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# ── Build the demo once and share it across test jobs via artifact ──────────
build-demo:
name: Build demo
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: demo-dist
path: demo/dist/
if-no-files-found: error
retention-days: 3
# ── Build the e2e test app and share it via artifact ────────────────────────
build-e2e-test-app:
name: Build e2e test app
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build e2e test app
run: ./node_modules/.bin/gulp build-e2e-test-app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
if-no-files-found: error
retention-days: 3
# ── Build the gallery and share it via artifact ─────────────────────────────
build-gallery:
name: Build gallery
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gallery-dist
path: gallery/dist/
if-no-files-found: error
retention-days: 3
# ── Run Playwright tests locally against Chromium ──────────────────────────
e2e-local:
name: E2E (local Chromium)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
# Chromium; anything longer is almost certainly an install or webServer
# hang.
timeout-minutes: 30
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
# Cache the downloaded browser build keyed on the pinned Playwright
# version (yarn.lock), so re-runs skip the ~170 MB download.
- name: Cache Playwright browsers
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
- name: Install Playwright browsers
run: yarn playwright install --with-deps chromium
timeout-minutes: 10
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (local)
run: yarn test:e2e
timeout-minutes: 15
- name: Upload blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-local
path: test/e2e/reports/
retention-days: 3
# ── Merge local blob reports and post PR comment ───────────────────────────
report:
name: Report
needs: [e2e-local]
runs-on: ubuntu-latest
if: ${{ !cancelled() }}
permissions:
contents: read
pull-requests: write
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download blob report (local)
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
continue-on-error: true
with:
name: blob-report-local
path: test/e2e/reports/
- name: Stage blobs for merge
run: node test/e2e/collect-blob-reports.mjs
- name: Merge blob reports
run: npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob
- name: Upload merged HTML report
id: upload-report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: test/e2e/reports/combined/
retention-days: 14
- name: Post report to PR
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { default: postReportComment } = await import(
`${process.env.GITHUB_WORKSPACE}/test/e2e/post-report-comment.mjs`
);
await postReportComment({ github, context, core });
+9 -161
View File
@@ -1,7 +1,7 @@
name: Pull request standards
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, no PR code checkout
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, checks out base repo scripts only, never PR head code
types:
- opened
- edited
@@ -23,168 +23,16 @@ jobs:
permissions:
pull-requests: write # To label and comment on pull requests
steps:
- name: Check out workflow scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check pull request standards
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = context.payload.pull_request;
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
if (pr.user.type === "Bot") {
core.info(`Skipping bot author: ${pr.user.login}`);
return;
}
if (pr.draft) {
core.info("Skipping draft pull request");
return;
}
try {
await github.rest.orgs.checkMembershipForUser({
org: "home-assistant",
username: pr.user.login,
});
core.info(`Skipping organization member: ${pr.user.login}`);
return;
} catch (error) {
core.info(`${pr.user.login} is not an organization member, checking standards`);
}
const label = "Needs Template";
const marker = "<!-- pr-standards-check -->";
const { owner, repo } = context.repo;
const issue_number = pr.number;
const body = (pr.body || "").replace(/<!--[\s\S]*?-->/g, "");
const normalized = body.toLowerCase();
// Ignore 404s from mutations that race manual edits or cancelled runs.
const ignoreMissing = async (fn) => {
try {
await fn();
} catch (error) {
if (error.status === 404) {
core.info("Target already removed, nothing to do");
return;
}
throw error;
}
};
// Hide/restore our comment via GraphQL (REST cannot minimize).
const setMinimized = async (subjectId, minimized) => {
const mutation = minimized
? `mutation($id: ID!) {
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
clientMutationId
}
}`
: `mutation($id: ID!) {
unminimizeComment(input: { subjectId: $id }) {
clientMutationId
}
}`;
try {
await github.graphql(mutation, { id: subjectId });
} catch (error) {
core.info(
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
);
}
};
// Content of a "## <name>" section, or null when the heading is absent.
const section = (name) => {
const match = body.match(
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
);
return match ? match[1] : null;
};
const problems = [];
const requiredHeadings = [
"## proposed change",
"## type of change",
"## checklist",
];
if (requiredHeadings.some((h) => !normalized.includes(h))) {
problems.push(
"Use the pull request template without removing its sections."
);
}
const typeOfChange = section("type of change");
if (typeOfChange !== null) {
const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length;
if (ticked !== 1) {
problems.push(
'Select exactly one option under "Type of change".'
);
}
}
const proposedChange = section("proposed change");
if (proposedChange !== null && proposedChange.trim().length === 0) {
problems.push('Describe your changes under "Proposed change".');
}
const isValid = problems.length === 0;
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number, per_page: 100 }
);
const existing = comments.find((c) => c.body.includes(marker));
const hasLabel = pr.labels.some((l) => l.name === label);
if (isValid) {
core.info("Pull request standards met");
if (hasLabel) {
await ignoreMissing(() =>
github.rest.issues.removeLabel({
owner, repo, issue_number, name: label,
})
);
}
if (existing) {
await setMinimized(existing.node_id, true);
}
return;
}
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
if (!hasLabel) {
await github.rest.issues.addLabels({
owner, repo, issue_number, labels: [label],
});
}
const message =
`${marker}\n` +
`Hey @${pr.user.login}!\n\n` +
`Thank you for your contribution! To help reviewers, please update ` +
`this pull request to follow our pull request standards:\n\n` +
problems.map((p) => `- ${p}`).join("\n") +
`\n\n` +
`Please complete the ` +
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
`for more on creating a great pull request (see point 6).`;
if (existing) {
await github.rest.issues.updateComment({
owner, repo, comment_id: existing.id, body: message,
});
await setMinimized(existing.node_id, false);
} else {
await github.rest.issues.createComment({
owner, repo, issue_number, body: message,
});
}
// Fail this check so it can block the PR from being merged
core.setFailed(
`Pull request standards not met:\n- ${problems.join("\n- ")}`
const { default: checkStandards } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`
);
await checkStandards({ github, context, core });
+10 -41
View File
@@ -36,52 +36,21 @@ jobs:
name: Check authorization
runs-on: ubuntu-latest
permissions:
contents: read # To check out workflow scripts
issues: write # To comment on, label, and close issues
# Only run if this is a Task issue type (from the issue form)
if: github.event.issue.type.name == 'Task'
steps:
- name: Check out workflow scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check if user is authorized
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const issueAuthor = context.payload.issue.user.login;
// Check if user is an organization member
try {
await github.rest.orgs.checkMembershipForUser({
org: 'home-assistant',
username: issueAuthor
});
console.log(`✅ ${issueAuthor} is an organization member`);
return; // Authorized
} catch (error) {
console.log(`❌ ${issueAuthor} is not authorized to create Task issues`);
}
// Close the issue with a comment
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `Hi @${issueAuthor}, thank you for your contribution!\n\n` +
`Task issues are restricted to Open Home Foundation staff and authorized contributors.\n\n` +
`If you would like to:\n` +
`- Report a bug: Please use the [bug report form](https://github.com/home-assistant/frontend/issues/new?template=bug_report.yml)\n` +
`- Request a feature: Please submit to [Feature Requests](https://github.com/orgs/home-assistant/discussions)\n\n` +
`If you believe you should have access to create Task issues, please contact the maintainers.`
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
state: 'closed'
});
// Add a label to indicate this was auto-closed
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
labels: ['auto-closed']
});
const { default: checkTaskAuthorization } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`
);
await checkTaskAuthorization({ github, context, core });
+8
View File
@@ -54,8 +54,16 @@ src/cast/dev_const.ts
# test coverage
test/coverage/
# Playwright e2e output
test/e2e/reports/
test/e2e/test-results/
# E2E test app build output
test/e2e/app/dist/
# AI tooling
.claude
.cursor
.opencode
.serena
test/benchmarks/results/
+1 -1
View File
@@ -1 +1 @@
24.17.0
24.18.0
@@ -1,18 +0,0 @@
diff --git a/lib/cook-raw-quasi.js b/lib/cook-raw-quasi.js
index 3ea8fa7be8e357c1066d7417caeeecd841415208..6bf04ab0bed8897b5ff2898ca835867aec5cee6a 100644
--- a/lib/cook-raw-quasi.js
+++ b/lib/cook-raw-quasi.js
@@ -1,10 +1,11 @@
'use strict';
-function cookRawQuasi({transform}, raw) {
+function cookRawQuasi({transformSync}, raw) {
// This nasty hack is needed until https://github.com/babel/babel/issues/9242 is resolved.
const args = {raw};
- transform('cooked`' + args.raw + '`', {
+ // Babel 8 removed synchronous `transform`; use `transformSync` instead.
+ transformSync('cooked`' + args.raw + '`', {
babelrc: false,
configFile: false,
plugins: [
+15
View File
@@ -0,0 +1,15 @@
{
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 561513,
"core": 54473,
"authorize": 544272,
"onboarding": 647136
},
"frontend-legacy": {
"app": 790323,
"core": 237208,
"authorize": 765464,
"onboarding": 918679
}
}
+36 -33
View File
@@ -1,4 +1,3 @@
/* global require, module, __dirname, process */
const path = require("path");
const env = require("./env.cjs");
const paths = require("./paths.cjs");
@@ -84,12 +83,7 @@ module.exports.swcOptions = () => ({
},
});
module.exports.babelOptions = ({
latestBuild,
isProdBuild,
isTestBuild,
sw,
}) => ({
module.exports.babelOptions = ({ latestBuild, isTestBuild, sw }) => ({
babelrc: false,
compact: false,
assumptions: {
@@ -102,13 +96,22 @@ module.exports.babelOptions = ({
[
"@babel/preset-env",
{
useBuiltIns: "usage",
corejs: dependencies["core-js"],
shippedProposals: true,
},
],
],
plugins: [
// Inject Core-JS polyfills on demand. Babel 8 removed preset-env's
// `useBuiltIns`/`corejs` options, so the equivalent polyfill provider is
// configured directly here (`usage-global` matches the old `useBuiltIns: "usage"`).
[
"babel-plugin-polyfill-corejs3",
{
method: "usage-global",
version: dependencies["core-js"],
shippedProposals: true,
},
],
[
path.join(BABEL_PLUGINS, "inline-constants-plugin.cjs"),
{
@@ -116,32 +119,14 @@ module.exports.babelOptions = ({
ignoreModuleNotFound: true,
},
],
// Minify template literals for production
isProdBuild && [
"template-html-minifier",
{
modules: {
...Object.fromEntries(
["lit", "lit-element", "lit-html"].map((m) => [
m,
[
"html",
{ name: "svg", encapsulation: "svg" },
{ name: "css", encapsulation: "style" },
],
])
),
"@polymer/polymer/lib/utils/html-tag.js": ["html"],
},
strictCSS: true,
htmlMinifier: module.exports.htmlMinifierOptions,
failOnError: false, // we can turn this off in case of false positives
},
],
// Import helpers and regenerator from runtime package
// Import helpers and regenerator from runtime package.
// `moduleName` is pinned so helpers resolve from `@babel/runtime`: the
// corejs3 polyfill provider above otherwise redirects them to the
// (uninstalled) `@babel/runtime-corejs3`, which preset-env used to suppress
// internally when it owned the polyfill injection via `useBuiltIns`.
[
"@babel/plugin-transform-runtime",
{ version: dependencies["@babel/runtime"] },
{ version: dependencies["@babel/runtime"], moduleName: "@babel/runtime" },
],
"@babel/plugin-transform-class-properties",
"@babel/plugin-transform-private-methods",
@@ -320,4 +305,22 @@ module.exports.config = {
isLandingPageBuild: true,
};
},
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
},
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
publicPath: publicPath(latestBuild),
defineOverlay: {
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
__DEMO__: true,
},
isProdBuild,
latestBuild,
isStatsBuild,
};
},
};
+155
View File
@@ -0,0 +1,155 @@
/* global require, process, __dirname */
// Enforce a strict size budget on the initial JS of the most critical
// entrypoints (`app` and `core`). These two are downloaded on every cold load
// before anything interactive can happen, so unintended growth here hurts
// first-load performance directly.
//
// In production rspack does not split initial chunks (splitChunks only operates
// on `!chunk.canBeInitial()`), so each entrypoint resolves to a single initial
// JS asset. We read the per-build stats written by StatsWriterPlugin and compare
// the entrypoint's initial JS size against a committed budget.
//
// Usage:
// node build-scripts/check-bundle-size.cjs # enforce, exit 1 on regression
// node build-scripts/check-bundle-size.cjs --update # rewrite budgets from current sizes
// node build-scripts/check-bundle-size.cjs --update --headroom=3 # current + 3% headroom
const fs = require("fs");
const path = require("path");
const paths = require("./paths.cjs");
// Entrypoints whose initial JS we hold to a strict budget. These are all
// downloaded on a user-facing cold load before anything interactive can happen:
// `app`/`core` for the main app, plus the standalone `authorize` and
// `onboarding` pages. `custom-panel` is intentionally excluded (only loaded
// when a custom panel is opened).
const TRACKED_ENTRYPOINTS = ["app", "core", "authorize", "onboarding"];
// App build stats files, as written by StatsWriterPlugin (`${name}.json`).
const BUILDS = ["frontend-modern", "frontend-legacy"];
const BUDGET_FILE = path.join(__dirname, "bundle-budget.json");
const STATS_DIR = path.join(paths.build_dir, "stats");
const readStats = (build) => {
const file = path.join(STATS_DIR, `${build}.json`);
if (!fs.existsSync(file)) {
throw new Error(
`Missing stats file: ${path.relative(process.cwd(), file)}.\n` +
`Run a production build first (e.g. \`gulp build-app\`), then re-run this check.`
);
}
return JSON.parse(fs.readFileSync(file, "utf8"));
};
// Initial JS bytes for an entrypoint = sum of the .js asset sizes of its initial
// entry chunk(s). Sizes are raw (uncompressed) bytes, matching the stats output.
const entrypointInitialJS = (stats, entrypoint) => {
const assetSize = new Map(stats.assets.map((a) => [a.name, a.size]));
let total = 0;
let found = false;
for (const chunk of stats.chunks) {
if (!chunk.entry || !chunk.initial) {
continue;
}
if (!(chunk.names || []).includes(entrypoint)) {
continue;
}
found = true;
for (const file of chunk.files || []) {
if (file.endsWith(".js") && assetSize.has(file)) {
total += assetSize.get(file);
}
}
}
if (!found) {
throw new Error(`Entrypoint "${entrypoint}" not found in bundle stats.`);
}
return total;
};
const kib = (bytes) => `${(bytes / 1024).toFixed(1)} KiB`;
const main = () => {
const update = process.argv.includes("--update");
const headroomArg = process.argv.find((a) => a.startsWith("--headroom="));
const headroom = headroomArg ? Number(headroomArg.split("=")[1]) : 0;
const current = {};
for (const build of BUILDS) {
const stats = readStats(build);
current[build] = {};
for (const entrypoint of TRACKED_ENTRYPOINTS) {
current[build][entrypoint] = entrypointInitialJS(stats, entrypoint);
}
}
if (update) {
const budget = { _comment: BUDGET_COMMENT };
for (const build of BUILDS) {
budget[build] = {};
for (const entrypoint of TRACKED_ENTRYPOINTS) {
budget[build][entrypoint] = Math.ceil(
current[build][entrypoint] * (1 + headroom / 100)
);
}
}
fs.writeFileSync(BUDGET_FILE, `${JSON.stringify(budget, null, 2)}\n`);
console.log(
`Updated ${path.relative(process.cwd(), BUDGET_FILE)} from current sizes` +
(headroom ? ` (+${headroom}% headroom).` : ".")
);
return;
}
if (!fs.existsSync(BUDGET_FILE)) {
throw new Error(
`Missing budget file ${path.relative(process.cwd(), BUDGET_FILE)}.\n` +
`Seed it from a production build with: node build-scripts/check-bundle-size.cjs --update --headroom=3`
);
}
const budget = JSON.parse(fs.readFileSync(BUDGET_FILE, "utf8"));
let failed = false;
console.log("Initial JS budget (entry chunks, raw bytes):\n");
for (const build of BUILDS) {
for (const entrypoint of TRACKED_ENTRYPOINTS) {
const actual = current[build][entrypoint];
const limit = budget[build] && budget[build][entrypoint];
if (typeof limit !== "number") {
failed = true;
console.log(
`${build} / ${entrypoint}: no budget set (current ${kib(actual)})`
);
continue;
}
const ok = actual <= limit;
const delta = (((actual - limit) / limit) * 100).toFixed(1);
console.log(
` ${ok ? "✓" : "✗"} ${build} / ${entrypoint}: ` +
`${kib(actual)} / ${kib(limit)}${ok ? "" : ` (+${delta}% over budget)`}`
);
if (!ok) {
failed = true;
}
}
}
if (failed) {
console.error(
"\nInitial JS budget exceeded for a critical entrypoint.\n" +
"Investigate what was pulled into the entry chunk (a static import that should be lazy?).\n" +
"If the growth is intentional, re-seed the budget:\n" +
" node build-scripts/check-bundle-size.cjs --update --headroom=3"
);
process.exit(1);
}
console.log("\nAll tracked entrypoints within budget.");
};
const BUDGET_COMMENT =
"Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. " +
"Enforced by build-scripts/check-bundle-size.cjs in CI. " +
"Re-seed after an intentional change with `--update --headroom=<percent>`.";
main();
+4
View File
@@ -1,9 +1,13 @@
// @ts-check
import globals from "globals";
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
languageOptions: {
globals: globals.node,
},
rules: {
"no-console": "off",
"import-x/no-extraneous-dependencies": "off",
@@ -1,4 +1,3 @@
/* global module */
// Browser-only replacement for core-js/internals/get-built-in-node-module.
// The original helper evaluates `Function('return require("...")')()`
// when it detects a Node environment, which causes a runtime
+7
View File
@@ -45,3 +45,10 @@ gulp.task(
])
)
);
gulp.task(
"clean-e2e-test-app",
gulp.parallel("clean-translations", async () =>
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
)
);
+41
View File
@@ -0,0 +1,41 @@
import gulp from "gulp";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./translations.js";
import "./rspack.js";
gulp.task(
"develop-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "development";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
"gen-icons-json",
"gen-pages-e2e-test-app-dev",
"build-translations",
"build-locale-data"
),
"copy-static-e2e-test-app",
"rspack-dev-server-e2e-test-app"
)
);
gulp.task(
"build-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-e2e-test-app",
"rspack-prod-e2e-test-app",
"gen-pages-e2e-test-app-prod"
)
);
+21 -1
View File
@@ -1,4 +1,3 @@
/* global process */
// Tasks to generate entry HTML
import {
@@ -268,3 +267,24 @@ gulp.task(
paths.landingPage_output_es5
)
);
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-e2e-test-app-dev",
genPagesDevTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root
)
);
gulp.task(
"gen-pages-e2e-test-app-prod",
genPagesProdTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root,
paths.e2eTestApp_output_latest
)
);
+20
View File
@@ -201,3 +201,23 @@ gulp.task("copy-static-landing-page", async () => {
copyFonts(paths.landingPage_output_static);
copyTranslations(paths.landingPage_output_static);
});
gulp.task("copy-static-e2e-test-app", async () => {
// Copy app static files (icons, polyfills, etc.)
fs.copySync(
polyPath("public/static"),
path.resolve(paths.e2eTestApp_output_root, "static")
);
// Copy e2e test app public files (manifest, sw stubs)
const e2ePublic = path.resolve(paths.e2eTestApp_dir, "public");
if (fs.existsSync(e2ePublic)) {
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
}
copyPolyfills(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
copyMdiIcons(paths.e2eTestApp_output_static);
});
+1
View File
@@ -4,6 +4,7 @@ import "./clean.js";
import "./compress.js";
import "./demo.js";
import "./download-translations.js";
import "./e2e-test-app.js";
import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
+69 -29
View File
@@ -1,6 +1,6 @@
// Gulp task to generate third-party license notices.
import { readFile, access } from "fs/promises";
import { readFile, access, readdir } from "fs/promises";
import { generateLicenseFile } from "generate-license-file";
import gulp from "gulp";
import path from "path";
@@ -11,58 +11,98 @@ const OUTPUT_FILE = path.join(
"third-party-licenses.txt"
);
const NODE_MODULES = path.resolve(paths.root_dir, "node_modules");
// The echarts package ships an Apache-2.0 NOTICE file that must be
// redistributed alongside the compiled output per Apache License §4(d).
const NOTICE_FILES = [
path.resolve(paths.root_dir, "node_modules/echarts/NOTICE"),
];
const NOTICE_FILES = [path.join(NODE_MODULES, "echarts/NOTICE")];
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
// Some packages need a manual license override (e.g. they ship multiple
// license files and we must pick the right one for the bundled code).
//
// Each entry is pinned to a specific version. If a package is updated,
// this list must be reviewed and the version updated after verifying
// that the new version's license still matches. The build will fail
// if the installed version does not match the pinned version.
// that the new version's license still matches. The build will fail if
// the pinned version is no longer installed.
const LICENSE_OVERRIDES = [
{
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
packageName: "type-fest",
version: "5.7.0",
licensePath: path.resolve(
paths.root_dir,
"node_modules/type-fest/license-mit"
),
licenseFile: "license-mit",
},
];
// Locate the directory of an installed package matching an exact version.
//
// The copy we care about may be hoisted to the top-level node_modules or
// nested under a dependency when a different version occupies the hoisted
// slot (e.g. a build-only dependency pulling in an older release). Searching
// both keeps this check independent of yarn's hoisting decisions, which can
// shift when unrelated dependencies are added.
async function findPackageDir(packageName, version) {
const candidateDirs = [path.join(NODE_MODULES, packageName)];
// Collect one level of nesting: node_modules/<dep>/node_modules/<pkg> and
// node_modules/@scope/<dep>/node_modules/<pkg>.
let topLevel = [];
try {
topLevel = await readdir(NODE_MODULES, { withFileTypes: true });
} catch {
// node_modules unreadable — fall back to the hoisted candidate only.
}
for (const entry of topLevel) {
if (!entry.isDirectory() || entry.name === packageName) {
continue;
}
if (entry.name.startsWith("@")) {
const scopeDir = path.join(NODE_MODULES, entry.name);
// eslint-disable-next-line no-await-in-loop
const scoped = await readdir(scopeDir, { withFileTypes: true }).catch(
() => []
);
for (const dep of scoped) {
if (dep.isDirectory()) {
candidateDirs.push(
path.join(scopeDir, dep.name, "node_modules", packageName)
);
}
}
} else {
candidateDirs.push(
path.join(NODE_MODULES, entry.name, "node_modules", packageName)
);
}
}
for (const dir of candidateDirs) {
// eslint-disable-next-line no-await-in-loop
const pkg = await readFile(path.join(dir, "package.json"), "utf-8")
.then(JSON.parse)
.catch(() => null);
if (pkg?.version === version) {
return dir;
}
}
return null;
}
gulp.task("gen-licenses", async () => {
const licenseOverrides = {};
for (const { packageName, version, licensePath } of LICENSE_OVERRIDES) {
const pkgJsonPath = path.resolve(
paths.root_dir,
`node_modules/${packageName}/package.json`
);
for (const { packageName, version, licenseFile } of LICENSE_OVERRIDES) {
// eslint-disable-next-line no-await-in-loop
const packageDir = await findPackageDir(packageName, version);
let packageJSON;
try {
// eslint-disable-next-line no-await-in-loop
packageJSON = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
} catch {
if (!packageDir) {
throw new Error(
`package.json for "${packageName}" not found or unreadable at ${pkgJsonPath}`
);
}
if (packageJSON.version !== version) {
throw new Error(
`License override for "${packageName}" is pinned to version ${version}, but found version ${packageJSON.version}. ` +
`License override for "${packageName}" is pinned to version ${version}, but that version is not installed. ` +
`Please verify the new version's license and update the override in build-scripts/gulp/licenses.js.`
);
}
const licensePath = path.join(packageDir, licenseFile);
try {
// eslint-disable-next-line no-await-in-loop
await access(licensePath);
+20
View File
@@ -14,6 +14,7 @@ import {
createDemoConfig,
createGalleryConfig,
createLandingPageConfig,
createE2eTestAppConfig,
} from "../rspack.cjs";
const bothBuilds = (createConfigFunc, params) => [
@@ -231,3 +232,22 @@ gulp.task("rspack-prod-landing-page", () =>
})
)
);
gulp.task("rspack-dev-server-e2e-test-app", () =>
runDevServer({
compiler: rspack(
createE2eTestAppConfig({ isProdBuild: false, latestBuild: true })
),
contentBase: paths.e2eTestApp_output_root,
port: 8095,
})
);
gulp.task("rspack-prod-e2e-test-app", () =>
prodBuild(
bothBuilds(createE2eTestAppConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
})
)
);
+10 -4
View File
@@ -48,6 +48,12 @@ for (const buildType of ["Modern", "Legacy"]) {
const browserslistEnv = buildType.toLowerCase();
const babelOpts = babelOptions({ latestBuild: browserslistEnv === "modern" });
const presetEnvOpts = babelOpts.presets[0][1];
// Core-JS polyfills are injected by babel-plugin-polyfill-corejs3 (Babel 8
// removed preset-env's `useBuiltIns`), so read its options here.
const corejsOpts = babelOpts.plugins.find(
(plugin) =>
Array.isArray(plugin) && plugin[0] === "babel-plugin-polyfill-corejs3"
)?.[1];
// Invoking preset-env in debug mode will log the included plugins
console.log(detailsOpen(`${buildType} Build Babel Plugins`));
@@ -59,16 +65,16 @@ for (const buildType of ["Modern", "Legacy"]) {
console.log(detailsClose);
// Manually log the Core-JS polyfills using the same technique
if (presetEnvOpts.useBuiltIns) {
if (corejsOpts) {
console.log(detailsOpen(`${buildType} Build Core-JS Polyfills`));
const targets = compilationTargets.default(babelOpts?.targets, {
browserslistEnv,
});
const polyfillList = coreJSCompat({ targets }).list.filter(
polyfillFilter(
`${presetEnvOpts.useBuiltIns}-global`,
presetEnvOpts?.corejs?.proposals,
presetEnvOpts?.shippedProposals
corejsOpts.method,
corejsOpts.proposals,
corejsOpts.shippedProposals
)
);
console.log(
@@ -0,0 +1,8 @@
/* global module */
module.exports = function litDisableDevModeLoader(source) {
return source.replace(
/\b(const|let|var) DEV_MODE = true;/g,
"$1 DEV_MODE = false;"
);
};
@@ -0,0 +1,68 @@
/* global module, require */
// rspack/webpack loader that minifies the HTML, SVG, and CSS inside lit
// tagged template literals using `minify-literals` (html-minifier-next +
// lightningcss). Replaces the unmaintained babel-plugin-template-html-minifier.
//
// It runs between swc and babel: swc has already stripped TS types and
// decorators (so minify-literals' acorn parser only sees plain ESM), but the
// `html`/`css`/`svg` tagged templates are still intact at ES2021. Running after
// babel instead would miss the legacy build, where babel lowers the templates
// to `_taggedTemplateLiteral()` calls that no longer look like tagged templates.
const remapping = require("@ampproject/remapping");
// minify-literals is ESM-only, so load it via dynamic import from this CJS loader.
let minifyPromise;
const getMinifier = () => {
if (!minifyPromise) {
minifyPromise = import("minify-literals").then((m) => m.minifyHTMLLiterals);
}
return minifyPromise;
};
// HTML options mirror the previous babel-plugin-template-html-minifier config
// (html-minifier-next is option-compatible with html-minifier-terser). CSS in
// css`` templates and inline <style> is handled by minify-literals' lightningcss
// default.
//
// `keepClosingSlash` is required for `svg`` templates: SVG elements such as
// `<path />` and `<circle />` are not void elements in HTML, so dropping the
// trailing slash would break the markup. It is harmless for HTML.
const htmlOptions = {
caseSensitive: true,
collapseWhitespace: true,
conservativeCollapse: true,
decodeEntities: true,
keepClosingSlash: true,
removeComments: true,
removeRedundantAttributes: true,
};
module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
const callback = this.async();
getMinifier()
.then((minifyHTMLLiterals) =>
minifyHTMLLiterals(source, {
fileName: this.resourcePath,
html: htmlOptions,
})
)
.then((result) => {
if (!result) {
// No tagged templates changed; pass through untouched (incl. incoming map).
callback(null, source, map, meta);
return;
}
// minify-literals builds its map from `source` alone, so `result.map`
// describes minified output -> this loader's input (the swc output), not
// the original file. Compose it over the incoming map (swc output ->
// original source) so the map handed downstream still points at the
// original source; otherwise every minified file's source map is wrong.
const outMap =
map && result.map
? remapping([result.map, map], () => null)
: (result.map ?? map);
callback(null, result.code, outMap, meta);
})
.catch(callback);
};
+11
View File
@@ -50,4 +50,15 @@ module.exports = {
),
translations_src: path.resolve(__dirname, "../src/translations"),
e2eTestApp_dir: path.resolve(__dirname, "../test/e2e/app"),
e2eTestApp_output_root: path.resolve(__dirname, "../test/e2e/app/dist"),
e2eTestApp_output_static: path.resolve(
__dirname,
"../test/e2e/app/dist/static"
),
e2eTestApp_output_latest: path.resolve(
__dirname,
"../test/e2e/app/dist/frontend_latest"
),
};
+88 -19
View File
@@ -1,4 +1,3 @@
/* global require, module, __dirname */
const { existsSync } = require("fs");
const path = require("path");
const rspack = require("@rspack/core");
@@ -48,6 +47,12 @@ const createRspackConfig = ({
dontHash = new Set();
}
const ignorePackages = bundle.ignorePackages({ latestBuild });
const litHtmlRoot = path.resolve(__dirname, "../node_modules/lit-html");
const litHtmlDevelopmentRoot = path.join(litHtmlRoot, "development");
const litDisableDevModeLoader = path.join(
__dirname,
"lit-disable-dev-mode-loader.cjs"
);
return {
name,
mode: isProdBuild ? "production" : "development",
@@ -67,25 +72,42 @@ const createRspackConfig = ({
{
test: /\.m?js$|\.ts$/,
exclude: /node_modules[\\/]core-js/,
use: (info) => [
{
loader: "babel-loader",
options: {
...bundle.babelOptions({
latestBuild,
isProdBuild,
isTestBuild,
sw: info.issuerLayer === "sw",
}),
cacheDirectory: !isProdBuild,
cacheCompression: false,
use: (info) =>
[
{
loader: "babel-loader",
options: {
...bundle.babelOptions({
latestBuild,
isTestBuild,
sw: info.issuerLayer === "sw",
}),
cacheDirectory: !isProdBuild,
cacheCompression: false,
},
},
},
{
loader: "builtin:swc-loader",
options: bundle.swcOptions(),
},
],
// Minify lit html/svg/css tagged template literals for production.
// Must run after swc (TS/decorators stripped, but templates kept at
// ES2021) and before babel — otherwise the legacy build lowers
// html`` to _taggedTemplateLiteral() calls that can no longer be
// matched, leaving legacy templates unminified.
isProdBuild && {
loader: path.join(
__dirname,
"minify-template-literals-loader.cjs"
),
},
!latestBuild &&
info.resource.startsWith(
`${litHtmlDevelopmentRoot}${path.sep}`
) && {
loader: litDisableDevModeLoader,
},
{
loader: "builtin:swc-loader",
options: bundle.swcOptions(),
},
].filter(Boolean),
resolve: {
fullySpecified: false,
},
@@ -132,6 +154,47 @@ const createRspackConfig = ({
// Only include the JS of entrypoints
filter: (file) => file.isInitial && !file.name.endsWith(".map"),
}),
// Babel can miscompile Lit's pre-minified runtime when downleveling to
// ES5. Compile lit-html from its development sources for legacy builds,
// then let the normal production minifier handle the final bundle.
!latestBuild &&
new rspack.NormalModuleReplacementPlugin(
/^(?:lit-html(?:\/.*)?|\.{1,2}\/.*\.js)$/,
(resource) => {
if (resource.request === "lit-html") {
resource.request = path.join(
litHtmlDevelopmentRoot,
"lit-html.js"
);
return;
}
if (resource.request.startsWith("lit-html/")) {
if (resource.request.startsWith("lit-html/development/")) {
return;
}
resource.request = path.join(
litHtmlDevelopmentRoot,
resource.request.slice("lit-html/".length)
);
return;
}
if (
resource.context.startsWith(`${litHtmlRoot}${path.sep}`) &&
resource.context !== litHtmlDevelopmentRoot &&
!resource.context.startsWith(
`${litHtmlDevelopmentRoot}${path.sep}`
)
) {
resource.request = path.join(
litHtmlDevelopmentRoot,
path.relative(
litHtmlRoot,
path.resolve(resource.context, resource.request)
)
);
}
}
),
new rspack.DefinePlugin(
bundle.definedVars({ isProdBuild, latestBuild, defineOverlay })
),
@@ -338,6 +401,11 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRspackConfig(
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
);
module.exports = {
createAppConfig,
createDemoConfig,
@@ -345,4 +413,5 @@ module.exports = {
createGalleryConfig,
createRspackConfig,
createLandingPageConfig,
createE2eTestAppConfig,
};
+4 -1
View File
@@ -53,6 +53,7 @@ const CONFIG_PANEL_COMMANDS = [
"config/scene/config",
"search/related",
"tag/list",
"assist_pipeline/",
];
@customElement("ha-demo")
@@ -65,7 +66,9 @@ export class HaDemo extends HomeAssistantAppEl {
this._updateHass(hassUpdate),
};
const hass = provideHass(this, initial, true);
// `false` for contexts: HomeAssistantAppEl already provides them via
// `contextMixin`, so let provideHass skip them to avoid duplicate providers.
const hass = provideHass(this, initial, true, false);
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
+53
View File
@@ -0,0 +1,53 @@
import type { AssistPipeline } from "../../../src/data/assist_pipeline";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const pipelines: AssistPipeline[] = [
{
id: "01home_assistant_cloud",
name: "Home Assistant Cloud",
language: "en",
conversation_engine: "conversation.home_assistant",
conversation_language: "en",
stt_engine: "cloud",
stt_language: "en-US",
tts_engine: "cloud",
tts_language: "en-US",
tts_voice: "JennyNeural",
wake_word_entity: null,
wake_word_id: null,
},
{
id: "01local",
name: "Local",
language: "en",
conversation_engine: "conversation.home_assistant",
conversation_language: "en",
stt_engine: "stt.faster_whisper",
stt_language: "en",
tts_engine: "tts.piper",
tts_language: "en",
tts_voice: null,
wake_word_entity: null,
wake_word_id: null,
},
];
export const mockAssist = (hass: MockHomeAssistant) => {
// Stub for assist pipeline list — returns a cloud and a local pipeline so the
// voice assistants config panel shows configured assistants.
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines,
preferred_pipeline: "01home_assistant_cloud",
}));
// Stub for assist pipeline run — immediately sends run-end event so
// the UI does not hang waiting for a response.
hass.mockWS("assist_pipeline/run", (_msg, _hass, onChange) => {
if (onChange) {
onChange({
type: "run-end",
});
}
return null;
});
};
+2
View File
@@ -1,5 +1,6 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { mockApplicationCredentials } from "./application_credentials";
import { mockAssist } from "./assist";
import { mockAutomation } from "./automation";
import { mockBackup } from "./backup";
import { mockBlueprint } from "./blueprint";
@@ -37,4 +38,5 @@ export const mockConfigPanel = (hass: MockHomeAssistant) => {
mockScene(hass);
mockSearch(hass);
mockTags(hass);
mockAssist(hass);
};
+9 -4
View File
@@ -2,22 +2,27 @@ import type { ExposeEntitySettings } from "../../../src/data/expose";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const exposedEntities: Record<string, ExposeEntitySettings> = {
"light.bed_light": {
"light.floor_lamp": {
conversation: true,
"cloud.alexa": true,
"cloud.google_assistant": true,
},
"light.ceiling_lights": {
"light.living_room_spotlights": {
conversation: true,
"cloud.alexa": true,
"cloud.google_assistant": false,
},
"switch.decorative_lights": {
"light.bar_lamp": {
conversation: true,
"cloud.alexa": false,
"cloud.google_assistant": true,
},
"climate.ecobee": {
"light.kitchen_spotlights": {
conversation: true,
"cloud.alexa": true,
"cloud.google_assistant": true,
},
"light.outdoor_light": {
conversation: true,
"cloud.alexa": true,
"cloud.google_assistant": true,
+5
View File
@@ -0,0 +1,5 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockUpdate = (hass: MockHomeAssistant) => {
hass.mockWS("update/list", () => []);
};
+12
View File
@@ -234,6 +234,18 @@ export default tseslint.config(
globals: globals.serviceworker,
},
},
{
files: ["test/e2e/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
files: [".github/scripts/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
plugins: {
html,
+91
View File
@@ -1,3 +1,4 @@
import { ContextProvider } from "@lit/context";
import { mdiCog, mdiMenu } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@@ -19,6 +20,22 @@ import "../../src/components/ha-svg-icon";
import "../../src/components/ha-top-app-bar-fixed";
import "../../src/managers/notification-manager";
import { haStyle } from "../../src/resources/styles";
import {
apiContext,
areasContext,
configContext,
connectionContext,
devicesContext,
entitiesContext,
floorsContext,
formattersContext,
internationalizationContext,
registriesContext,
servicesContext,
statesContext,
uiContext,
} from "../../src/data/context";
import { updateHassGroups } from "../../src/data/context/updateContext";
import type { HomeAssistant, ThemeSettings } from "../../src/types";
import { PAGES, SIDEBAR } from "../build/import-pages";
import {
@@ -113,6 +130,65 @@ class HaGallery extends LitElement {
@state() private _drawerOpen = !this._narrow;
// Fallback Lit context providers for the whole gallery. The real app's root
// element provides these via `contextMixin`; here we mirror that so demos
// which render context-consuming components without setting up their own hass
// (e.g. bare component demos) still resolve `localize`, formatters, config,
// etc. instead of throwing during init. Demos that call `provideHass`
// register their own providers closer in the tree, which take precedence.
private _contextProviders = {
registries: new ContextProvider(this, { context: registriesContext }),
internationalization: new ContextProvider(this, {
context: internationalizationContext,
}),
api: new ContextProvider(this, { context: apiContext }),
connection: new ContextProvider(this, { context: connectionContext }),
ui: new ContextProvider(this, { context: uiContext }),
config: new ContextProvider(this, { context: configContext }),
formatters: new ContextProvider(this, { context: formattersContext }),
};
// The individual (non-grouped) contexts contextMixin also provides. Components
// such as ha-area-picker / ha-entity-picker consume these directly, so the
// fallback must cover them too.
private _singleContextProviders = {
states: new ContextProvider(this, { context: statesContext }),
services: new ContextProvider(this, { context: servicesContext }),
entities: new ContextProvider(this, { context: entitiesContext }),
devices: new ContextProvider(this, { context: devicesContext }),
areas: new ContextProvider(this, { context: areasContext }),
floors: new ContextProvider(this, { context: floorsContext }),
};
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Refresh the fallback contexts before each render so theme/page changes in
// the gallery hass propagate to consuming components.
const hass = this._galleryHass;
(
Object.keys(
this._contextProviders
) as (keyof typeof this._contextProviders)[]
).forEach((group) => {
const provider = this._contextProviders[group];
provider.setValue(
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
hass,
provider.value
)
);
});
(
Object.keys(
this._singleContextProviders
) as (keyof typeof this._singleContextProviders)[]
).forEach((key) => {
(this._singleContextProviders[key] as ContextProvider<any>).setValue(
hass[key]
);
});
}
render() {
const isSettingsPage = this._page === SETTINGS_PAGE;
const page = isSettingsPage ? undefined : PAGES[this._page];
@@ -576,6 +652,21 @@ class HaGallery extends LitElement {
callWS: async () => undefined,
fetchWithAuth: async () => new Response(),
sendWS: () => undefined,
formatEntityState: (stateObj, stateValue) =>
(stateValue != null ? stateValue : stateObj.state) ?? "",
formatEntityStateToParts: (stateObj, stateValue) => [
{
type: "value",
value: (stateValue != null ? stateValue : stateObj.state) ?? "",
},
],
formatEntityAttributeName: (_stateObj, attribute) => attribute,
formatEntityAttributeValue: (stateObj, attribute, value) =>
value != null ? value : (stateObj.attributes[attribute] ?? ""),
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
} as unknown as HomeAssistant;
}
+1 -28
View File
@@ -1,5 +1,4 @@
import { ContextProvider } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
@@ -15,11 +14,6 @@ import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import {
configContext,
internationalizationContext,
} from "../../../../src/data/context";
import { updateHassGroups } from "../../../../src/data/context/updateContext";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
@@ -528,17 +522,6 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
private data = SCHEMAS.map(() => ({}));
// The date/datetime selectors and the date-picker dialog consume these
// contexts (provided by the root element in the real app). Provide them here
// so they work in the gallery.
private _i18nProvider = new ContextProvider(this, {
context: internationalizationContext,
});
private _configProvider = new ContextProvider(this, {
context: configContext,
});
constructor() {
super();
const hass = provideHass(this);
@@ -560,16 +543,6 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
el.hass = this.hass;
}
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("hass") && this.hass) {
this._i18nProvider.setValue(
updateHassGroups.internationalization(this.hass)
);
this._configProvider.setValue(updateHassGroups.config(this.hass));
}
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
+4 -1
View File
@@ -1,5 +1,6 @@
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
const SHADOWS = ["s", "m", "l"] as const;
@@ -17,7 +18,9 @@ export class DemoMiscBoxShadow extends LitElement {
(size) => html`
<div
class="box"
style="box-shadow: var(--ha-box-shadow-${size})"
style=${styleMap({
boxShadow: `var(--ha-box-shadow-${size})`,
})}
>
${size}
</div>
+19 -9
View File
@@ -22,7 +22,13 @@
"postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts",
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"check-bundlesize": "node build-scripts/check-bundle-size.cjs",
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
"license": "Apache-2.0",
@@ -75,6 +81,7 @@
"@tsparticles/engine": "4.2.1",
"@tsparticles/preset-links": "4.2.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.2.0",
@@ -91,7 +98,6 @@
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.4.2",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
@@ -127,10 +133,11 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "8.0.0",
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.0.1",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "8.0.0",
"@babel/preset-env": "8.0.0",
"@babel/plugin-transform-runtime": "8.0.1",
"@babel/preset-env": "8.0.2",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.62.0",
@@ -138,9 +145,11 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.61.0",
"@rsdoctor/rspack-plugin": "1.5.15",
"@rspack/core": "2.0.8",
"@rspack/dev-server": "2.0.3",
"@rspack/dev-server": "2.1.0",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
@@ -157,7 +166,7 @@
"@types/tar": "7.0.87",
"@vitest/coverage-v8": "4.1.9",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "patch:babel-plugin-template-html-minifier@npm%3A4.1.0#~/.yarn/patches/babel-plugin-template-html-minifier-npm-4.1.0-9a3c00055a.patch",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.5.0",
@@ -182,11 +191,12 @@
"jsdom": "29.1.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.0.7",
"lint-staged": "17.0.8",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
"map-stream": "0.0.7",
"minify-literals": "2.0.2",
"pinst": "3.0.0",
"prettier": "3.8.4",
"rspack-manifest-plugin": "5.2.2",
@@ -216,6 +226,6 @@
},
"packageManager": "yarn@4.17.0",
"volta": {
"node": "24.17.0"
"node": "24.18.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260527.0"
version = "20260624.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+9 -2
View File
@@ -111,7 +111,7 @@ export const DOMAINS_WITH_DYNAMIC_PICTURE = new Set([
]);
/** Domains that use a timestamp for state. */
export const TIMESTAMP_STATE_DOMAINS = new Set([
const TIMESTAMP_STATE_DOMAINS_LIST = [
"ai_task",
"button",
"conversation",
@@ -127,7 +127,14 @@ export const TIMESTAMP_STATE_DOMAINS = new Set([
"tts",
"wake_word",
"datetime",
]);
] as const;
export type TimestampStateDomain =
(typeof TIMESTAMP_STATE_DOMAINS_LIST)[number];
export const TIMESTAMP_STATE_DOMAINS = new Set<string>(
TIMESTAMP_STATE_DOMAINS_LIST
);
/** Temperature units. */
export const UNIT_C = "°C";
@@ -1,4 +1,13 @@
import type { HaDurationData } from "../../components/ha-duration-input";
export default function durationToSeconds(duration: string): number {
const parts = duration.split(":").map(Number);
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
export const durationDataToSeconds = (duration: HaDurationData): number =>
(duration.days || 0) * 86400 +
(duration.hours || 0) * 3600 +
(duration.minutes || 0) * 60 +
(duration.seconds || 0) +
(duration.milliseconds || 0) / 1000;
+2 -2
View File
@@ -1,4 +1,4 @@
import timezones from "google-timezones-json";
import { timeZonesNames } from "@vvo/tzdb";
import { TimeZone } from "../../data/translation";
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
@@ -10,7 +10,7 @@ const RESOLVED_TIME_ZONE =
RESOLVED_RAW &&
(RESOLVED_RAW === "UTC" ||
RESOLVED_RAW === "Etc/UTC" ||
RESOLVED_RAW in timezones)
timeZonesNames.includes(RESOLVED_RAW))
? RESOLVED_RAW
: undefined;
@@ -125,7 +125,15 @@ export interface EntityPickerDisplay {
}
export const computeEntityPickerDisplay = (
hass: HomeAssistant,
hass: Pick<
HomeAssistant,
| "entities"
| "devices"
| "areas"
| "floors"
| "language"
| "translationMetadata"
>,
stateObj: HassEntity
): EntityPickerDisplay => {
const [entityName, deviceName, areaName] = computeEntityNameList(
@@ -1463,6 +1463,12 @@ export class HaDataTable extends LitElement {
flex: 1;
padding: var(--ha-space-3);
}
@media (min-width: 871px) {
ha-input-search {
--ha-input-search-height: 32px;
--ha-input-search-border-radius: 10px;
}
}
slot[name="header"] {
display: block;
}
+1 -9
View File
@@ -9,15 +9,13 @@ import {
} from "../../common/dom/fire_event";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { ValueChangedEvent } from "../../types";
import "../ha-sortable";
import "./ha-entity-picker";
import type { HaEntityPicker } from "./ha-entity-picker";
@customElement("ha-entities-picker")
class HaEntitiesPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Array }) public value?: string[];
@property({ type: Boolean }) public disabled = false;
@@ -87,10 +85,6 @@ class HaEntitiesPicker extends LitElement {
public reorder = false;
protected render() {
if (!this.hass) {
return nothing;
}
const currentEntities = this._currentEntities;
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
@@ -105,7 +99,6 @@ class HaEntitiesPicker extends LitElement {
<div class="entity">
<ha-entity-picker
.curValue=${entityId}
.hass=${this.hass}
.includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains}
.includeEntities=${this.includeEntities}
@@ -133,7 +126,6 @@ class HaEntitiesPicker extends LitElement {
</ha-sortable>
<div>
<ha-entity-picker
.hass=${this.hass}
.includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains}
.includeEntities=${this.includeEntities}
+73 -40
View File
@@ -1,5 +1,5 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume } from "@lit/context";
import { consume, type ContextType } from "@lit/context";
import { mdiPlus, mdiShape } from "@mdi/js";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -8,7 +8,14 @@ import { fireEvent } from "../../common/dom/fire_event";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import type { RelatedIdSets } from "../../common/search/related-context";
import { relatedContext } from "../../data/context";
import type { LocalizeFunc } from "../../common/translations/localize";
import {
configContext,
internationalizationContext,
registriesContext,
relatedContext,
statesContext,
} from "../../data/context";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
import {
entityComboBoxKeys,
@@ -38,7 +45,21 @@ const CREATE_ID = "___create-new-entity___";
@customElement("ha-entity-picker")
export class HaEntityPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries!: ContextType<typeof registriesContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@@ -150,12 +171,11 @@ export class HaEntityPicker extends LitElement {
);
}
protected willUpdate(changedProperties: PropertyValues<this>) {
protected willUpdate(changedProperties: PropertyValues) {
if (
this._pendingEntityId &&
changedProperties.has("hass") &&
this.hass.states !== changedProperties.get("hass")?.states &&
this.hass.states[this._pendingEntityId]
changedProperties.has("_states") &&
this._states[this._pendingEntityId]
) {
this._setValue(this._pendingEntityId);
this._pendingEntityId = undefined;
@@ -165,7 +185,7 @@ export class HaEntityPicker extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
// Load title translations so it is available when the combo-box opens
this.hass.loadBackendTranslation("title");
this._i18n.loadBackendTranslation("title");
}
private _findExtraOption(value: string | undefined) {
@@ -176,7 +196,7 @@ export class HaEntityPicker extends LitElement {
private _renderExtraOptionStart(extraOption: EntitySelectorExtraOption) {
const stateObj = extraOption.entity_id
? this.hass.states[extraOption.entity_id]
? this._states[extraOption.entity_id]
: undefined;
if (stateObj) {
return html`
@@ -212,7 +232,7 @@ export class HaEntityPicker extends LitElement {
`;
}
const stateObj = this.hass.states[entityId];
const stateObj = this._states[entityId];
if (!stateObj) {
return html`
@@ -226,7 +246,11 @@ export class HaEntityPicker extends LitElement {
}
const { primary, secondary } = computeEntityPickerDisplay(
this.hass,
{
...this._registries,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
},
stateObj
);
@@ -238,7 +262,7 @@ export class HaEntityPicker extends LitElement {
};
private get _showEntityId() {
return this.showEntityId || this.hass.userData?.showEntityIdPicker;
return this.showEntityId || this._config.userData?.showEntityIdPicker;
}
private _rowRenderer: RenderItemFunction<EntityComboBoxItem> = (
@@ -286,17 +310,14 @@ export class HaEntityPicker extends LitElement {
};
private _getAdditionalItems = () =>
this._getCreateItems(this.hass.localize, this.createDomains);
this._getCreateItems(this._i18n.localize, this.createDomains);
private _getCreateItems = memoizeOne(
(
localize: this["hass"]["localize"],
createDomains: this["createDomains"]
) => {
(localize: LocalizeFunc, createDomains: this["createDomains"]) => {
if (!createDomains?.length) {
return [];
}
this.hass.loadFragmentTranslation("config");
this._i18n.loadFragmentTranslation("config");
return createDomains.map((domain) => {
const primary = localize(
"ui.components.entity.entity-picker.create_helper",
@@ -321,7 +342,9 @@ export class HaEntityPicker extends LitElement {
private _getEntitiesMemoized = memoizeOne(
(
hass: HomeAssistant,
states: ContextType<typeof statesContext>,
registries: ContextType<typeof registriesContext>,
i18n: ContextType<typeof internationalizationContext>,
includeDomains?: string[],
excludeDomains?: string[],
entityFilter?: HaEntityPickerEntityFilterFunc,
@@ -331,16 +354,25 @@ export class HaEntityPicker extends LitElement {
excludeEntities?: string[],
value?: string
) =>
getEntities(hass, {
includeDomains,
excludeDomains,
entityFilter,
includeDeviceClasses,
includeUnitOfMeasurement,
includeEntities,
excludeEntities,
value,
})
getEntities(
{
states,
...registries,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
localize: i18n.localize,
},
{
includeDomains,
excludeDomains,
entityFilter,
includeDeviceClasses,
includeUnitOfMeasurement,
includeEntities,
excludeEntities,
value,
}
)
);
private _sortByRelatedContext = memoizeOne(
@@ -359,7 +391,9 @@ export class HaEntityPicker extends LitElement {
private _getItems = () => {
const entityItems = this._getEntitiesMemoized(
this.hass,
this._states,
this._registries,
this._i18n,
this.includeDomains,
this.excludeDomains,
this.entityFilter,
@@ -373,15 +407,15 @@ export class HaEntityPicker extends LitElement {
? this._sortByRelatedContext(
entityItems,
this._relatedIdSets!,
this.hass.entities,
this.hass.devices,
this.hass.locale.language
this._registries.entities,
this._registries.devices,
this._i18n.locale.language
)
: entityItems;
if (this.extraOptions?.length) {
const resolvedExtras = this.extraOptions.map((opt) => ({
...opt,
stateObj: opt.entity_id ? this.hass.states[opt.entity_id] : undefined,
stateObj: opt.entity_id ? this._states[opt.entity_id] : undefined,
}));
return [...resolvedExtras, ...sortedItems];
}
@@ -395,11 +429,10 @@ export class HaEntityPicker extends LitElement {
protected render() {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.entity.entity-picker.placeholder");
this._i18n.localize("ui.components.entity.entity-picker.placeholder");
return html`
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
@@ -421,9 +454,9 @@ export class HaEntityPicker extends LitElement {
use-top-label
.addButtonLabel=${this.addButton
? (this.addButtonLabel ??
this.hass.localize("ui.components.entity.entity-picker.add"))
this._i18n.localize("ui.components.entity.entity-picker.add"))
: undefined}
.unknownItemText=${this.hass.localize(
.unknownItemText=${this._i18n.localize(
"ui.components.entity.entity-picker.unknown"
)}
@value-changed=${this._valueChanged}
@@ -476,7 +509,7 @@ export class HaEntityPicker extends LitElement {
domain,
dialogClosedCallback: (item) => {
if (item.entityId) {
if (this.hass.states[item.entityId]) {
if (this._states[item.entityId]) {
this._setValue(item.entityId);
} else {
this._pendingEntityId = item.entityId;
@@ -502,7 +535,7 @@ export class HaEntityPicker extends LitElement {
}
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.entity.entity-picker.no_match", {
this._i18n.localize("ui.components.entity.entity-picker.no_match", {
term: html`<b>${search}</b>`,
});
}
@@ -63,7 +63,6 @@ export class HaEntitySelector extends LitElement {
if (!this.selector.entity?.multiple) {
return html`<ha-entity-picker
.hass=${this.hass}
.value=${typeof this.value === "string" ? this.value : ""}
.label=${this.label}
.placeholder=${this.placeholder}
@@ -80,7 +79,6 @@ export class HaEntitySelector extends LitElement {
return html`
<ha-entities-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.placeholder=${this.placeholder}
@@ -91,7 +91,6 @@ export class HaMediaSelector extends LitElement {
? nothing
: html`
<ha-entity-picker
.hass=${this.hass}
.value=${entityId}
.label=${this.label ||
this.hass.localize(
-1
View File
@@ -558,7 +558,6 @@ export class HaServiceControl extends LitElement {
></ha-settings-row>`
: entityId
? html`<ha-entity-picker
.hass=${this.hass}
.disabled=${this.disabled}
.value=${this._value?.data?.entity_id}
.label=${this.hass.localize(
+25 -23
View File
@@ -1,4 +1,4 @@
import timezones from "google-timezones-json";
import { getTimeZones, timeZonesNames } from "@vvo/tzdb";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -13,38 +13,40 @@ const SEARCH_KEYS = [
{ name: "secondary", weight: 8 },
];
// google-timezones-json is missing the bare "UTC" and "Etc/UTC" zones, even
// though both are valid IANA identifiers and common server defaults. Without
// them a "UTC" configuration shows up as an unknown time zone. Add them back.
// @vvo/tzdb is missing the bare "UTC" zone, even though it is a valid IANA
// identifier and a common server default. Add UTC back so a
// "UTC" configuration can be selected.
const ADDITIONAL_TIMEZONES: PickerComboBoxItem[] = [
{ id: "UTC", primary: "(GMT+00:00) UTC", secondary: "UTC" },
{ id: "Etc/UTC", primary: "(GMT+00:00) UTC", secondary: "Etc/UTC" },
{ id: "UTC", primary: "+00:00 UTC", secondary: "UTC" },
];
// google-timezones-json also ships an invalid IANA identifier. Correct it so
// the zone can be selected (the backend rejects the invalid id).
const TIMEZONE_ID_CORRECTIONS: Record<string, string> = {
"Asia/Yuzhno-Sakhalinsk": "Asia/Sakhalin",
};
export const getTimezoneOptions = (): PickerComboBoxItem[] => {
const options: PickerComboBoxItem[] = Object.entries(
timezones as Record<string, string>
).map(([key, value]) => {
const id = TIMEZONE_ID_CORRECTIONS[key] ?? key;
return {
id,
primary: value,
secondary: id,
};
});
const options: PickerComboBoxItem[] = Array.from(
new Map(
getTimeZones({ includeUtc: true })
.flatMap((timezone) => {
const groupArray = Array.isArray(timezone.group)
? timezone.group
: [timezone.group];
const filteredGroup = groupArray.filter((gName) =>
timeZonesNames.includes(gName)
);
return [timezone.name, ...filteredGroup].map((nameString) => ({
id: nameString,
primary: timezone.rawFormat,
secondary: nameString,
}));
})
.map((item) => [item.id, item])
).values()
);
for (const timezone of ADDITIONAL_TIMEZONES) {
if (!options.some((option) => option.id === timezone.id)) {
options.push(timezone);
}
}
return options;
};
+5 -1
View File
@@ -41,7 +41,11 @@ export class HaInputSearch extends HaInput {
...HaInput.styles,
css`
:host([appearance="outlined"]) wa-input.no-label::part(base) {
height: 40px;
height: var(--ha-input-search-height, 40px);
border-radius: var(
--ha-input-search-border-radius,
var(--ha-border-radius-md)
);
}
`,
];
+1 -5
View File
@@ -6,15 +6,11 @@ export interface AlexaEntity {
interfaces: string[];
}
export interface AlexaEntityConfig {
name?: string | null;
}
export const fetchCloudAlexaEntities = (hass: HomeAssistant) =>
hass.callWS<AlexaEntity[]>({ type: "cloud/alexa/entities" });
export const fetchCloudAlexaEntity = (hass: HomeAssistant, entity_id: string) =>
hass.callWS<AlexaEntityConfig>({
hass.callWS<AlexaEntity>({
type: "cloud/alexa/entities/get",
entity_id,
});
+8 -1
View File
@@ -331,7 +331,14 @@ export interface AutomationElementGroupCollection {
export type AutomationElementGroup = Record<
string,
{ icon?: string; members?: AutomationElementGroup }
{
icon?: string;
members?: AutomationElementGroup;
// Backend element domains (e.g. "calendar", "sun") whose triggers/conditions
// are bundled into this group instead of appearing as their own dynamic
// domain group.
domains?: string[];
}
>;
export type LegacyCondition =
+2 -13
View File
@@ -172,23 +172,12 @@ export const removeCloudData = (hass: HomeAssistant) =>
export const updateCloudGoogleEntityConfig = (
hass: HomeAssistant,
entity_id: string,
values: { disable_2fa?: boolean; name?: string | null; aliases?: string[] }
disable_2fa: boolean
) =>
hass.callWS({
type: "cloud/google_assistant/entities/update",
entity_id,
...values,
});
export const updateCloudAlexaEntityConfig = (
hass: HomeAssistant,
entity_id: string,
name: string | null
) =>
hass.callWS({
type: "cloud/alexa/entities/update",
entity_id,
name,
disable_2fa,
});
export const cloudSyncGoogleAssistant = (hass: HomeAssistant) =>
+12 -7
View File
@@ -1,7 +1,7 @@
import { mdiMapClock, mdiShape } from "@mdi/js";
import { mdiClockOutline, mdiShape, mdiWeatherSunny } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import { computeObjectId } from "../common/entity/compute_object_id";
import type { HomeAssistant } from "../types";
import type { AutomationElementGroupCollection } from "./automation";
import type { Selector, TargetSelector } from "./selector";
@@ -9,9 +9,14 @@ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
dynamicGroups: {},
time_location: {
icon: mdiMapClock,
members: { sun: {}, time: {}, zone: {} },
time: {
icon: mdiClockOutline,
members: { time: {} },
domains: ["calendar", "schedule"],
},
sun: {
icon: mdiWeatherSunny,
domains: ["sun"],
},
helpers: {},
template: {},
@@ -68,10 +73,10 @@ export interface ConditionDescription {
export type ConditionDescriptions = Record<string, ConditionDescription>;
export const subscribeConditions = (
hass: HomeAssistant,
connection: Connection,
callback: (conditions: ConditionDescriptions) => void
) =>
hass.connection.subscribeMessage<ConditionDescriptions>(callback, {
connection.subscribeMessage<ConditionDescriptions>(callback, {
type: "condition_platforms/subscribe",
});
+15
View File
@@ -12,11 +12,13 @@ import type {
HomeAssistantUI,
} from "../../types";
import type { RelatedIdSets } from "../../common/search/related-context";
import type { ConditionDescriptions } from "../condition";
import type { ConfigEntry } from "../config_entries";
import type { EntityRegistryEntry } from "../entity/entity_registry";
import type { DomainManifestLookup } from "../integration";
import type { LabelRegistryEntry } from "../label/label_registry";
import type { ItemType } from "../search";
import type { TriggerDescriptions } from "../trigger";
/**
* Entity, device, area, and floor registries
@@ -131,6 +133,19 @@ export const configEntriesContext =
export const manifestsContext =
createContext<DomainManifestLookup>("manifests");
/**
* Lazy loaded trigger platform descriptions, keyed by trigger key.
*/
export const triggerDescriptionsContext = createContext<TriggerDescriptions>(
"triggerDescriptions"
);
/**
* Lazy loaded condition platform descriptions, keyed by condition key.
*/
export const conditionDescriptionsContext =
createContext<ConditionDescriptions>("conditionDescriptions");
// #endregion lazy-contexts
// #region deprecated-contexts
+42 -7
View File
@@ -42,6 +42,12 @@ import {
export const ENERGY_COLLECTION_KEY_PREFIX = "energy_";
// Collection key for the statistics-based energy dashboard views (Overview,
// Electricity, Gas, Water).
export const DEFAULT_ENERGY_COLLECTION_KEY = "energy_dashboard";
// Collection key for the real-time "Now" view (live power + 5-minute stats).
export const DEFAULT_POWER_COLLECTION_KEY = "energy_dashboard_now";
// All collection keys created this session
const energyCollectionKeys = new Set<string | undefined>();
@@ -787,9 +793,30 @@ const findEnergyDataCollection = (
return (hass.connection as any)[key];
};
// When does the collection's day period need to roll over to the next day?
// With `midnightRollover` (the real-time "Now" view) it rolls over right at
// midnight. Otherwise it waits an hour, until the new day's first hourly
// statistic exists — rolling over at midnight would show an empty graph.
export const getNextEnergyPeriodStart = (
midnightRollover: boolean,
now: Date,
locale: HomeAssistant["locale"],
config: HomeAssistant["config"]
): Date => {
const dayEnd = calcDate(now, endOfDay, locale, config);
return midnightRollover ? addMilliseconds(dayEnd, 1) : addHours(dayEnd, 1);
};
export const getEnergyDataCollection = (
hass: HomeAssistant,
options: { prefs?: EnergyPreferences; key?: string } = {}
options: {
prefs?: EnergyPreferences;
key?: string;
// The real-time "Now" view opts in to rolling its day period over at
// midnight rather than an hour later (it shows live data, so it always
// tracks today and never falls back to yesterday in the first hour).
midnightRollover?: boolean;
} = {}
): EnergyCollection => {
const [key, collectionKey] = convertCollectionKeyToConnection(
hass,
@@ -799,6 +826,8 @@ export const getEnergyDataCollection = (
return (hass.connection as any)[key];
}
const midnightRollover = options.midnightRollover ?? false;
energyCollectionKeys.add(collectionKey);
const collection = getCollection<EnergyData>(
@@ -857,12 +886,16 @@ export const getEnergyDataCollection = (
const now = new Date();
const hour = formatTime24h(now, hass.locale, hass.config).split(":")[0];
// Set start to start of today if we have data for today, otherwise yesterday
// Set start to start of today if we have data for today, otherwise yesterday.
// The real-time "Now" view always tracks today; it shows live data even
// before today's first statistic exists, so it never falls back to yesterday.
const preferredPeriod =
(localStorage.getItem(`energy-default-period-${key}`) as DateRange) ||
"today";
const period =
preferredPeriod === "today" && hour === "0" ? "yesterday" : preferredPeriod;
preferredPeriod === "today" && hour === "0" && !midnightRollover
? "yesterday"
: preferredPeriod;
const [start, end] = calcDateRange(hass.locale, hass.config, period);
collection.start = calcDate(start, startOfDay, hass.locale, hass.config);
@@ -886,10 +919,12 @@ export const getEnergyDataCollection = (
collection.refresh();
scheduleUpdatePeriod();
},
addHours(
calcDate(new Date(), endOfDay, hass.locale, hass.config),
1
).getTime() - Date.now() // Switch to next day an hour after the day changed
getNextEnergyPeriodStart(
midnightRollover,
new Date(),
hass.locale,
hass.config
).getTime() - Date.now()
);
};
scheduleUpdatePeriod();
+11 -1
View File
@@ -58,7 +58,17 @@ export interface GetEntitiesOptions {
}
export const getEntities = (
hass: HomeAssistant,
hass: Pick<
HomeAssistant,
| "states"
| "entities"
| "devices"
| "areas"
| "floors"
| "language"
| "translationMetadata"
| "localize"
>,
options?: GetEntitiesOptions
): EntityComboBoxItem[] => {
const {
-2
View File
@@ -5,8 +5,6 @@ export interface GoogleEntity {
traits: string[];
might_2fa: boolean;
disable_2fa?: boolean;
name?: string | null;
aliases?: string[] | null;
}
export const fetchCloudGoogleEntities = (hass: HomeAssistant) =>
+42 -4
View File
@@ -1,5 +1,6 @@
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { DOMAINS_WITH_DYNAMIC_PICTURE } from "../common/const";
import type { TimestampStateDomain } from "../common/const";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import type { LocalizeFunc } from "../common/translations/localize";
@@ -239,16 +240,53 @@ export const parseTriggerSource = (source: string): ParsedTriggerSource => {
return {};
};
// Short label shown instead of the bare timestamp for each timestamp-state
// domain. Typed to TIMESTAMP_STATE_DOMAINS minus datetime (a real value), so a
// new timestamp domain won't compile until it gets a label here.
type LogbookActionMessage =
| "pressed"
| "activated"
| "scanned"
| "detected_event_no_type"
| "updated"
| "sent"
| "detected"
| "transcribed"
| "spoke"
| "responded"
| "ran"
| "command_sent";
const STATE_ACTION_MESSAGES: Record<
Exclude<TimestampStateDomain, "datetime">,
LogbookActionMessage
> = {
button: "pressed",
input_button: "pressed",
scene: "activated",
tag: "scanned",
event: "detected_event_no_type",
image: "updated",
notify: "sent",
wake_word: "detected",
stt: "transcribed",
tts: "spoke",
conversation: "responded",
ai_task: "ran",
infrared: "command_sent",
radio_frequency: "command_sent",
};
export const localizeStateMessage = (
hass: HomeAssistant,
state: string,
stateObj: HassEntity,
domain: string
): string => {
// Events expose a timestamp as their state, which has no meaningful display
// value, so keep a dedicated phrase.
if (domain === "event") {
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.detected_event_no_type`);
const actionKey: LogbookActionMessage | undefined =
STATE_ACTION_MESSAGES[domain as keyof typeof STATE_ACTION_MESSAGES];
if (actionKey) {
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.${actionKey}`);
}
// Every other domain reuses the backend state translation, so the logbook
// speaks the same vocabulary as the rest of the UI.
+12
View File
@@ -1,6 +1,7 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import type { LovelaceCardFeatureContext } from "../panels/lovelace/card-features/types";
import type { LovelaceBadgeConfig } from "./lovelace/config/badge";
import type { LovelaceCardConfig } from "./lovelace/config/card";
export interface CustomCardSuggestion<
@@ -10,6 +11,13 @@ export interface CustomCardSuggestion<
config: T;
}
export interface CustomBadgeSuggestion<
T extends LovelaceBadgeConfig = LovelaceBadgeConfig,
> {
label?: string;
config: T;
}
export interface CustomCardEntry {
type: string;
name?: string;
@@ -28,6 +36,10 @@ export interface CustomBadgeEntry {
description?: string;
preview?: boolean;
documentationURL?: string;
getEntitySuggestion?: (
hass: HomeAssistant,
entityId: string
) => CustomBadgeSuggestion | CustomBadgeSuggestion[] | null;
}
export interface CustomCardFeatureEntry {
+1 -1
View File
@@ -13,7 +13,7 @@ import {
import { isComponentLoaded } from "../common/config/is_component_loaded";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import { configSections } from "../panels/config/ha-panel-config";
import { configSections } from "../panels/config/config-sections";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import type { HassioAddonInfo } from "./hassio/addon";
+40
View File
@@ -1,3 +1,5 @@
import { formatNumber } from "../common/number/format_number";
import type { FrontendLocaleData } from "./translation";
import type { HomeAssistant } from "../types";
export const DOMAIN = "radio_frequency";
@@ -20,3 +22,41 @@ export const fetchRadioFrequencyTransmitters = (
hass.callWS({
type: "radio_frequency/list",
});
const FREQUENCY_UNITS: [number, string][] = [
[1e9, "GHz"],
[1e6, "MHz"],
[1e3, "kHz"],
[1, "Hz"],
];
// Format a frequency in hertz using the largest unit that keeps the value >= 1.
export const formatFrequency = (
hz: number,
locale: FrontendLocaleData
): string => {
const [divisor, unit] = FREQUENCY_UNITS.find(
([threshold]) => Math.abs(hz) >= threshold
) ?? [1, "Hz"];
return `${formatNumber(hz / divisor, locale, {
maximumFractionDigits: 3,
})} ${unit}`;
};
// Format a single [min, max] range; collapses to a single value when min === max.
export const formatFrequencyRange = (
range: readonly [number, number],
locale: FrontendLocaleData
): string => {
const [min, max] = range;
return min === max
? formatFrequency(min, locale)
: `${formatFrequency(min, locale)} ${formatFrequency(max, locale)}`;
};
// Format a list of frequency ranges into a human-readable, comma-separated string.
export const formatFrequencyRanges = (
ranges: readonly (readonly [number, number])[],
locale: FrontendLocaleData
): string =>
ranges.map((range) => formatFrequencyRange(range, locale)).join(", ");
+15
View File
@@ -153,6 +153,21 @@ export const getRecorderInfo = (conn: Connection) =>
type: "recorder/info",
});
export type EntityRecordingDisabler = "user";
export interface RecorderEntityOptions {
recording_disabled_by: EntityRecordingDisabler | null;
}
export const getRecorderEntityOptions = (
hass: Pick<HomeAssistant, "callWS">,
entity_id: string
) =>
hass.callWS<RecorderEntityOptions>({
type: "recorder/entity_options/get",
entity_id,
});
export const getStatisticIds = (
hass: Pick<HomeAssistant, "callWS">,
statistic_type?: "mean" | "sum"
+11 -9
View File
@@ -1,8 +1,8 @@
import { mdiMapClock, mdiShape } from "@mdi/js";
import { mdiClockOutline, mdiShape, mdiWeatherSunny } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import { computeObjectId } from "../common/entity/compute_object_id";
import type { HomeAssistant } from "../types";
import type {
AutomationElementGroupCollection,
Trigger,
@@ -14,15 +14,17 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
dynamicGroups: {},
time_location: {
icon: mdiMapClock,
time: {
icon: mdiClockOutline,
members: {
calendar: {},
sun: {},
time: {},
time_pattern: {},
zone: {},
},
domains: ["calendar", "schedule"],
},
sun: {
icon: mdiWeatherSunny,
domains: ["sun"],
},
event: {},
geo_location: {},
@@ -73,10 +75,10 @@ export interface TriggerDescription {
export type TriggerDescriptions = Record<string, TriggerDescription>;
export const subscribeTriggers = (
hass: HomeAssistant,
connection: Connection,
callback: (triggers: TriggerDescriptions) => void
) =>
hass.connection.subscribeMessage<TriggerDescriptions>(callback, {
connection.subscribeMessage<TriggerDescriptions>(callback, {
type: "trigger_platforms/subscribe",
});
@@ -1,33 +0,0 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { ExtEntityRegistryEntry } from "../../../../data/entity/entity_registry";
import "../../../../panels/config/voice-assistants/voice-assistant-settings";
import type { HomeAssistant } from "../../../../types";
@customElement("ha-more-info-view-voice-assistant-settings")
class MoreInfoViewVoiceAssistantSettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entry!: ExtEntityRegistryEntry;
@property({ attribute: false }) public params?: { assistant: string };
protected render() {
if (!this.params || !this.entry) {
return nothing;
}
return html`<voice-assistant-settings
.hass=${this.hass}
.entityId=${this.entry.entity_id}
.assistant=${this.params.assistant}
.entry=${this.entry}
></voice-assistant-settings>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-view-voice-assistant-settings": MoreInfoViewVoiceAssistantSettings;
}
}
@@ -7,7 +7,6 @@ import type { ExposeEntitySettings } from "../../../../data/expose";
import { voiceAssistants } from "../../../../data/expose";
import "../../../../panels/config/voice-assistants/entity-voice-settings";
import type { HomeAssistant } from "../../../../types";
import { showVoiceAssistantSettingsView } from "./show-view-voice-assistant-settings";
@customElement("ha-more-info-view-voice-assistants")
class MoreInfoViewVoiceAssistants extends LitElement {
@@ -34,19 +33,9 @@ class MoreInfoViewVoiceAssistants extends LitElement {
.entityId=${this.entry.entity_id}
.entry=${this.entry}
.exposed=${this._calculateExposed(this.entry)}
@edit-assistant=${this._editAssistant}
></entity-voice-settings>`;
}
private _editAssistant(ev: CustomEvent) {
const assistant = ev.detail.assistant;
showVoiceAssistantSettingsView(
this,
voiceAssistants[assistant].name,
assistant
);
}
static get styles(): CSSResultGroup {
return [
css`
@@ -1,17 +0,0 @@
import { fireEvent } from "../../../../common/dom/fire_event";
export const loadVoiceAssistantSettingsView = () =>
import("./ha-more-info-view-voice-assistant-settings");
export const showVoiceAssistantSettingsView = (
element: HTMLElement,
title: string,
assistant: string
): void => {
fireEvent(element, "show-child-view", {
viewTag: "ha-more-info-view-voice-assistant-settings",
viewImport: loadVoiceAssistantSettingsView,
viewTitle: title,
viewParams: { assistant },
});
};
+3 -21
View File
@@ -59,14 +59,12 @@ import {
getExtendedEntityRegistryEntry,
updateEntityRegistryEntry,
} from "../../data/entity/entity_registry";
import { subscribeLabFeature } from "../../data/labs";
import type { ItemType } from "../../data/search";
import { SearchableDomains } from "../../data/search";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import type { EntitySettingsState } from "../../panels/config/entities/entity-registry-settings-editor";
import type { Helper } from "../../panels/config/helpers/const";
import { ScrollableFadeMixin } from "../../mixins/scrollable-fade-mixin";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import {
haStyleDialog,
haStyleDialogFixedTop,
@@ -126,7 +124,7 @@ const DEFAULT_VIEW: MoreInfoView = "info";
export class MoreInfoDialog extends DirtyStateProviderMixin<
EntitySettingsState | Helper | Record<string, string[]> | null,
"entity-registry" | "helper" | "vacuum-segment-mapping"
>()(SubscribeMixin(ScrollableFadeMixin(LitElement))) {
>()(ScrollableFadeMixin(LitElement)) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public large = false;
@@ -163,8 +161,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
@state() private _isEscapeEnabled = true;
@state() private _newTriggersAndConditions = false;
protected scrollFadeThreshold = 24;
protected get scrollableElement(): HTMLElement | null {
@@ -260,24 +256,11 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
private _shouldShowAddEntityTo(): boolean {
return (
(this._newTriggersAndConditions && !!this.hass.user?.is_admin) ||
!!this.hass.user?.is_admin ||
!!this.hass.auth.external?.config.hasEntityAddTo
);
}
protected hassSubscribe() {
return [
subscribeLabFeature(
this.hass.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersAndConditions = feature.enabled;
}
),
];
}
private _getDeviceId(): string | null {
const entity = this.hass.entities[this._entityId!] as
| EntityRegistryEntry
@@ -1061,8 +1044,7 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
ha-more-info-history-and-logbook {
padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-6)
var(--ha-space-6);
padding: var(--ha-space-2) 0 var(--ha-space-6) 0;
display: block;
}
@@ -278,6 +278,7 @@ export class MoreInfoHistory extends LitElement {
justify-content: space-between;
align-items: center;
margin-bottom: var(--ha-space-2);
padding-inline: var(--ha-space-6);
}
.header > a,
a:visited {
@@ -290,6 +291,12 @@ export class MoreInfoHistory extends LitElement {
h2 {
margin: 0;
}
ha-alert,
state-history-charts,
statistics-chart {
display: block;
padding-inline: var(--ha-space-6);
}
`,
];
}
@@ -70,6 +70,7 @@ export class MoreInfoLogbook extends LitElement {
css`
ha-logbook {
--logbook-max-height: 250px;
--logbook-horizontal-padding: var(--ha-space-6);
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-logbook {
@@ -82,6 +83,7 @@ export class MoreInfoLogbook extends LitElement {
justify-content: space-between;
align-items: center;
margin-bottom: var(--ha-space-2);
padding-inline: var(--ha-space-6);
}
.header > a,
a:visited {
+1 -1
View File
@@ -159,7 +159,7 @@ class DialogEditSidebar extends DirtyStateProviderMixin<SidebarState>()(
value: panel.url_path,
label:
(getPanelTitle(this.hass, panel) || panel.url_path) +
`${defaultPanel === panel.url_path ? " (default)" : ""}`,
`${defaultPanel === panel.url_path ? ` (${this.hass.localize("ui.sidebar.default")})` : ""}`,
icon: getPanelIcon(panel),
iconPath: getPanelIconPath(panel),
disableHiding: panel.url_path === defaultPanel,
+94 -1
View File
@@ -1,3 +1,4 @@
import { ContextProvider } from "@lit/context";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import {
applyThemesOnElement,
@@ -6,6 +7,22 @@ import {
import { fireEvent } from "../common/dom/fire_event";
import { computeFormatFunctions } from "../common/translations/entity-state";
import { computeLocalize } from "../common/translations/localize";
import {
apiContext,
areasContext,
configContext,
connectionContext,
devicesContext,
entitiesContext,
floorsContext,
formattersContext,
internationalizationContext,
registriesContext,
servicesContext,
statesContext,
uiContext,
} from "../data/context";
import { updateHassGroups } from "../data/context/updateContext";
import type { IconCategory } from "../data/icons";
import type { EntityRegistryDisplayEntry } from "../data/entity/entity_registry";
import {
@@ -85,13 +102,84 @@ export interface MockHomeAssistant extends HomeAssistant {
export const provideHass = (
elements,
overrideData: Partial<HomeAssistant> = {},
setHassProperty = false
setHassProperty = false,
// Provide the grouped Lit contexts (registries, internationalization, api,
// connection, ui, config, formatters) that the real app's root element
// provides via `contextMixin`. On by default so that any standalone hass root
// (e.g. a gallery demo) automatically feeds context-consuming components the
// same way the real app does, instead of each demo wiring up a partial set by
// hand. Pass `false` for hosts that already provide these contexts themselves
// via `contextMixin` (the full app shell — `ha-demo`, `ha-test`), to avoid
// registering duplicate providers on the same element.
provideContexts = true
): MockHomeAssistant => {
elements = ensureArray(elements);
// Can happen because we store sidebar, more info etc on hass.
const baseEl = () => elements[0];
const hass = (): MockHomeAssistant => baseEl().hass;
const contextProviders = provideContexts
? {
registries: new ContextProvider(baseEl(), {
context: registriesContext,
}),
internationalization: new ContextProvider(baseEl(), {
context: internationalizationContext,
}),
api: new ContextProvider(baseEl(), { context: apiContext }),
connection: new ContextProvider(baseEl(), {
context: connectionContext,
}),
ui: new ContextProvider(baseEl(), { context: uiContext }),
config: new ContextProvider(baseEl(), { context: configContext }),
formatters: new ContextProvider(baseEl(), {
context: formattersContext,
}),
}
: undefined;
// The individual (non-grouped) contexts that contextMixin also provides.
// Components such as ha-area-picker / ha-entity-picker consume these directly
// (e.g. `Object.values(areas)`), so they must be provided alongside the
// grouped contexts or those components throw once they render.
const singleContextProviders = provideContexts
? {
states: new ContextProvider(baseEl(), { context: statesContext }),
services: new ContextProvider(baseEl(), { context: servicesContext }),
entities: new ContextProvider(baseEl(), { context: entitiesContext }),
devices: new ContextProvider(baseEl(), { context: devicesContext }),
areas: new ContextProvider(baseEl(), { context: areasContext }),
floors: new ContextProvider(baseEl(), { context: floorsContext }),
}
: undefined;
const updateContextProviders = (newHass: HomeAssistant) => {
if (contextProviders) {
(
Object.keys(contextProviders) as (keyof typeof contextProviders)[]
).forEach((group) => {
const provider = contextProviders[group];
provider.setValue(
(updateHassGroups[group] as (h: HomeAssistant, v?: any) => any)(
newHass,
provider.value
)
);
});
}
if (singleContextProviders) {
(
Object.keys(
singleContextProviders
) as (keyof typeof singleContextProviders)[]
).forEach((key) => {
(singleContextProviders[key] as ContextProvider<any>).setValue(
newHass[key]
);
});
}
};
const wsCommands = {};
const restResponses: [string | RegExp, MockRestCallback][] = [];
@@ -396,6 +484,7 @@ export const provideHass = (
elements.forEach((el) => {
el.hass = newHass;
});
updateContextProviders(newHass);
},
updateStates,
updateTranslations,
@@ -457,6 +546,10 @@ export const provideHass = (
value: value !== null ? value : (stateObj.attributes[attribute] ?? ""),
},
],
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
...overrideData,
};
@@ -793,6 +793,12 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
ha-input-search {
flex: 1;
}
@media (min-width: 871px) {
ha-input-search {
--ha-input-search-height: 32px;
--ha-input-search-border-radius: 10px;
}
}
.search-toolbar {
display: flex;
align-items: center;
@@ -228,7 +228,6 @@ class DialogCalendarEventEditor extends DirtyStateProviderMixin<CalendarEventFor
></ha-textarea>
<ha-entity-picker
name="calendar"
.hass=${this.hass}
.label=${this.hass.localize("ui.components.calendar.label")}
.value=${this._calendarId!}
.includeDomains=${CALENDAR_DOMAINS}
@@ -28,7 +28,7 @@ import {
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
import type { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { showAddApplicationCredentialDialog } from "./show-dialog-add-application-credential";
@customElement("ha-config-application-credentials")
@@ -251,7 +251,6 @@ class DialogAreaDetail
>
<div class="content">
<ha-entity-picker
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.areas.editor.temperature_entity"
)}
@@ -266,7 +265,6 @@ class DialogAreaDetail
></ha-entity-picker>
<ha-entity-picker
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.areas.editor.humidity_entity"
)}
+22 -52
View File
@@ -14,10 +14,7 @@ import {
mdiShape,
mdiTools,
} from "@mdi/js";
import type {
HassEntity,
UnsubscribeFunc,
} from "home-assistant-js-websocket/dist/types";
import type { HassEntity } from "home-assistant-js-websocket/dist/types";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -59,7 +56,6 @@ import {
computeEntityRegistryName,
sortEntityRegistryByName,
} from "../../../data/entity/entity_registry";
import { subscribeLabFeature } from "../../../data/labs";
import type { SceneEntity } from "../../../data/scene";
import type { ScriptEntity } from "../../../data/script";
import type { RelatedResult } from "../../../data/search";
@@ -69,7 +65,6 @@ import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { isHelperDomain } from "../helpers/const";
@@ -143,7 +138,7 @@ const NAVIGATION_ACTIONS: {
const MAX_COLUMNS = 3;
@customElement("ha-config-area-page")
class HaConfigAreaPage extends SubscribeMixin(LitElement) {
class HaConfigAreaPage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public areaId!: string;
@@ -158,8 +153,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
@state() private _related?: RelatedResult;
@state() private _newTriggersConditions = false;
private _logbookTime = { recent: 86400 };
private _columnsController = createColumnsController(this, MAX_COLUMNS);
@@ -255,23 +248,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
}
}
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
if (!isComponentLoaded(this.hass!.config, "automation")) {
return [];
}
return [
subscribeLabFeature(
this.hass!.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersConditions = feature.enabled;
}
),
];
}
protected render() {
if (!this.hass.areas || !this.hass.devices || !this.hass.entities) {
return nothing;
@@ -377,32 +353,26 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
></ha-icon-button>
</div>`
: nothing}
${area.picture && !this._newTriggersConditions
? nothing
: html`<div class="action-buttons">
${area.picture
? nothing
: html`<ha-button
appearance="filled"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon .path=${mdiImagePlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.add_picture")}
</ha-button>`}
${this._newTriggersConditions
? html`<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
)}
</ha-button>`
: nothing}
</div>`}
<div class="action-buttons">
${area.picture
? nothing
: html`<ha-button
appearance="filled"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon .path=${mdiImagePlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.add_picture")}
</ha-button>`}
<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.dialogs.more_info_control.add_to.item")}
</ha-button>
</div>
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.devices.caption")}
@@ -54,7 +54,7 @@ import {
import "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { showToast } from "../../../util/toast";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import {
loadAreaRegistryDetailDialog,
showAreaRegistryDetailDialog,
@@ -1,3 +1,4 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -21,10 +22,9 @@ import {
CONDITION_BUILDING_BLOCKS,
getConditionDomain,
getConditionObjectId,
subscribeConditions,
} from "../../../../../data/condition";
import { conditionDescriptionsContext } from "../../../../../data/context";
import { domainToName } from "../../../../../data/integration";
import { SubscribeMixin } from "../../../../../mixins/subscribe-mixin";
import type { HomeAssistant, ValueChangedEvent } from "../../../../../types";
import "../../condition/ha-automation-condition-editor";
import type HaAutomationConditionEditor from "../../condition/ha-automation-condition-editor";
@@ -42,10 +42,7 @@ import "../../condition/types/ha-automation-condition-zone";
import type { ActionElement } from "../ha-automation-action-row";
@customElement("ha-automation-action-condition")
export class HaConditionAction
extends SubscribeMixin(LitElement)
implements ActionElement
{
export class HaConditionAction extends LitElement implements ActionElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public disabled = false;
@@ -58,7 +55,9 @@ export class HaConditionAction
@property({ type: Boolean, attribute: "indent" }) public indent = false;
@state() private _conditionDescriptions: ConditionDescriptions = {};
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions: ConditionDescriptions = {};
@query("ha-automation-condition-editor")
private _conditionEditor?: HaAutomationConditionEditor;
@@ -67,21 +66,6 @@ export class HaConditionAction
return { condition: "state" };
}
protected hassSubscribe() {
return [
subscribeConditions(this.hass, (conditions) =>
this._addConditions(conditions)
),
];
}
private _addConditions(conditions: ConditionDescriptions) {
this._conditionDescriptions = {
...this._conditionDescriptions,
...conditions,
};
}
protected render() {
const buildingBlock = CONDITION_BUILDING_BLOCKS.includes(
this.action.condition
@@ -7,10 +7,7 @@ import {
mdiHelpCircleOutline,
mdiPlus,
} from "@mdi/js";
import type {
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -80,13 +77,16 @@ import {
CONDITION_COLLECTIONS,
getConditionDomain,
getConditionObjectId,
subscribeConditions,
} from "../../../data/condition";
import {
getConfigEntries,
type ConfigEntry,
} from "../../../data/config_entries";
import { labelsContext } from "../../../data/context";
import {
conditionDescriptionsContext,
labelsContext,
triggerDescriptionsContext,
} from "../../../data/context";
import { getDeviceEntityLookup } from "../../../data/device/device_registry";
import type { EntityComboBoxItem } from "../../../data/entity/entity_picker";
import { getFloorAreaLookup } from "../../../data/floor_registry";
@@ -101,7 +101,6 @@ import {
fetchIntegrationManifests,
} from "../../../data/integration";
import type { LabelRegistryEntry } from "../../../data/label/label_registry";
import { subscribeLabFeature } from "../../../data/labs";
import { filterSelectorEntities } from "../../../data/selector";
import {
TARGET_SEPARATOR,
@@ -116,7 +115,6 @@ import {
TRIGGER_COLLECTIONS,
getTriggerDomain,
getTriggerObjectId,
subscribeTriggers,
} from "../../../data/trigger";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
@@ -194,6 +192,11 @@ const DYNAMIC_KEYWORDS = [
const DYNAMIC_TO_GENERIC = new Set([`${DYNAMIC_PREFIX}event`]);
// Group keys surfaced as their own section in the "by target" tab because
// their elements have no target (time/calendar/schedule, sun). Picking one
// drills into its items, like selecting the matching group in the "by type" tab.
const TIME_LOCATION_GROUPS = ["time", "sun"];
type CollectionGroupType = "helper" | "other" | "dynamic" | "customDynamic";
@customElement("add-automation-element-dialog")
@@ -227,7 +230,13 @@ class DialogAddAutomationElement
@state() private _narrow = false;
@state() private _triggerDescriptions: TriggerDescriptions = {};
@state()
@consume({ context: triggerDescriptionsContext, subscribe: true })
private _triggerDescriptions: TriggerDescriptions = {};
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions: ConditionDescriptions = {};
@state() private _targetItems?: {
title: string;
@@ -236,12 +245,8 @@ class DialogAddAutomationElement
@state() private _loadItemsError = false;
@state() private _newTriggersAndConditions = false;
@state() private _openedFromQuery = false;
@state() private _conditionDescriptions: ConditionDescriptions = {};
@state()
@consume({ context: labelsContext, subscribe: true })
private _labelRegistry!: LabelRegistryEntry[];
@@ -259,10 +264,6 @@ class DialogAddAutomationElement
// #region variables
private _unsub?: Promise<UnsubscribeFunc>;
private _unsubscribeLabFeatures?: Promise<UnsubscribeFunc>;
private _configEntryLookup: Record<string, ConfigEntry> = {};
private _closing = false;
@@ -278,31 +279,6 @@ class DialogAddAutomationElement
) {
this._calculateUsedDomains();
}
if (changedProps.has("_newTriggersAndConditions")) {
this._subscribeDescriptions();
}
}
private _subscribeDescriptions() {
this._unsubscribe();
if (this._params?.type === "trigger") {
this._triggerDescriptions = {};
this._unsub = subscribeTriggers(this.hass, (triggers) => {
this._triggerDescriptions = {
...this._triggerDescriptions,
...triggers,
};
});
} else if (this._params?.type === "condition") {
this._conditionDescriptions = {};
this._unsub = subscribeConditions(this.hass, (conditions) => {
this._conditionDescriptions = {
...this._conditionDescriptions,
...conditions,
};
});
}
}
public showDialog(params: AddAutomationElementDialogParams): void {
@@ -334,28 +310,9 @@ class DialogAddAutomationElement
this._loadConfigEntries();
this._unsubscribe();
this._fetchManifests();
this._calculateUsedDomains();
this._unsubscribeLabFeatures = subscribeLabFeature(
this.hass.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersAndConditions = feature.enabled;
this._tab = this._newTriggersAndConditions ? "targets" : "groups";
if (
queryTarget &&
this._newTriggersAndConditions &&
!this._selectedTarget
) {
this._selectedTarget = queryTarget;
this._getItemsByTarget();
}
}
);
if (!queryTarget) {
// add initial dialog view state to history
mainWindow.history.pushState(
@@ -372,11 +329,9 @@ class DialogAddAutomationElement
} else if (this._params?.type === "trigger") {
this.hass.loadBackendTranslation("triggers");
getTriggerIcons(this.hass.connection, this.hass.config);
this._subscribeDescriptions();
} else if (this._params?.type === "condition") {
this.hass.loadBackendTranslation("conditions");
getConditionIcons(this.hass.connection, this.hass.config);
this._subscribeDescriptions();
}
window.addEventListener("resize", this._updateNarrow);
@@ -385,11 +340,7 @@ class DialogAddAutomationElement
// prevent view mode switch when resizing window
this._bottomSheetMode = this._narrow;
if (
queryTarget &&
this._newTriggersAndConditions &&
!this._selectedTarget
) {
if (queryTarget && !this._selectedTarget) {
this._selectedTarget = queryTarget;
this._tab = "targets";
this._getItemsByTarget();
@@ -434,7 +385,6 @@ class DialogAddAutomationElement
}
this.removeKeyboardShortcuts();
this._unsubscribe();
if (this._params) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -450,7 +400,7 @@ class DialogAddAutomationElement
this._selectedCollectionIndex = undefined;
this._selectedGroup = undefined;
this._selectedTarget = undefined;
this._tab = this._newTriggersAndConditions ? "targets" : "groups";
this._tab = "targets";
this._filter = "";
this._manifests = undefined;
this._domains = undefined;
@@ -589,7 +539,6 @@ class DialogAddAutomationElement
public disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener("resize", this._updateNarrow);
this._unsubscribe();
}
protected supportedShortcuts(): SupportedShortcuts {
@@ -598,39 +547,10 @@ class DialogAddAutomationElement
};
}
private _unsubscribe() {
if (this._unsub) {
this._unsub.then((unsub) => unsub());
this._unsub = undefined;
}
if (this._unsubscribeLabFeatures) {
this._unsubscribeLabFeatures.then((unsub) => unsub());
this._unsubscribeLabFeatures = undefined;
}
}
// #endregion lifecycle
// #region render
private _getEmptyNote(automationElementType: string) {
if (
automationElementType !== "trigger" &&
automationElementType !== "condition"
) {
return undefined;
}
return this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.no_items_for_target_note`,
{
labs_link: html`<a href="/config/labs" @click=${this._close}
>${this.hass.localize("ui.panel.config.labs.caption")}</a
>`,
}
);
}
protected render() {
if (!this._params) {
return nothing;
@@ -664,6 +584,12 @@ class DialogAddAutomationElement
const automationElementType = this._params!.type;
const tabButtons = [
{
label: this.hass.localize(
"ui.panel.config.automation.editor.tabs.target"
),
value: "targets",
},
{
label: this.hass.localize(
"ui.panel.config.automation.editor.tabs.type"
@@ -672,15 +598,6 @@ class DialogAddAutomationElement
},
];
if (this._newTriggersAndConditions) {
tabButtons.unshift({
label: this.hass.localize(
"ui.panel.config.automation.editor.tabs.target"
),
value: "targets",
});
}
if (this._params?.type !== "trigger") {
tabButtons.push({
label: this.hass.localize("ui.panel.config.automation.editor.blocks"),
@@ -763,7 +680,6 @@ class DialogAddAutomationElement
this._manifests
)}
.convertToItem=${this._convertToItem}
.newTriggersAndConditions=${this._newTriggersAndConditions}
@search-element-picked=${this._searchItemSelected}
>
</ha-automation-add-search>`
@@ -772,13 +688,28 @@ class DialogAddAutomationElement
.hass=${this.hass}
.value=${this._selectedTarget}
@value-changed=${this._handleTargetSelected}
@time-location-group-selected=${this
._handleTimeLocationGroupSelected}
.narrow=${this._narrow}
.timeLocationLabel=${this._getTimeLocationLabel(
automationElementType
)}
.timeLocationGroups=${this._getTimeLocationGroups(
automationElementType,
this.hass.localize,
automationElementType === "condition"
? this._conditionDescriptions
: this._triggerDescriptions
)}
.selectedGroup=${this._selectedGroup}
class=${classMap({
"ha-scrollbar": true,
hidden: !!this._getAddFromTargetHidden(
this._narrow,
this._selectedTarget
),
hidden:
!!this._getAddFromTargetHidden(
this._narrow,
this._selectedTarget
) ||
(this._narrow && !!this._selectedGroup),
})}
.manifests=${this._manifests}
></ha-automation-add-from-target>`
@@ -884,13 +815,13 @@ class DialogAddAutomationElement
)
: undefined}
.selectLabel=${this.hass.localize(
`ui.panel.config.automation.editor.${this._tab === "groups" ? `${automationElementType}s.select` : "select_target"}` as LocalizeKeys
`ui.panel.config.automation.editor.${this._tab === "groups" || this._selectedGroup ? `${automationElementType}s.select` : "select_target"}` as LocalizeKeys
)}
.emptyLabel=${this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.no_items_for_target`
)}
.emptyNote=${this._getEmptyNote(automationElementType)}
.tooltipDescription=${this._tab === "targets"}
.tooltipDescription=${this._tab === "targets" &&
!this._selectedGroup}
.target=${(this._tab === "targets" &&
this._selectedTarget &&
([
@@ -1050,7 +981,7 @@ class DialogAddAutomationElement
items: this._getBlockItems(this._params!.type, this.hass.localize),
},
]
: !this._filter && this._tab === "groups" && this._selectedGroup
: !this._filter && this._selectedGroup
? [
{
title: this.hass.localize(
@@ -1108,7 +1039,10 @@ class DialogAddAutomationElement
Object.entries(grp).map(([key, options]) =>
options.members
? flattenGroups(options.members)
: this._convertToItem(key, options, type, localize)
: options.domains
? // domain elements are appended below from the backend descriptions
[]
: this._convertToItem(key, options, type, localize)
);
const items = flattenGroups(groups).flat();
@@ -1149,6 +1083,8 @@ class DialogAddAutomationElement
let genericCollectionIndex = -1;
let dynamicCollectionIndex = -1;
const exclusiveDomains = this._getExclusiveDomains(type);
collections.forEach((collection, index) => {
let collectionGroups = Object.entries(collection.groups);
const groups: AddAutomationElementListItem[] = [];
@@ -1179,7 +1115,8 @@ class DialogAddAutomationElement
triggerDescriptions,
manifests,
domains,
types
types,
exclusiveDomains
)
);
@@ -1198,7 +1135,8 @@ class DialogAddAutomationElement
conditionDescriptions,
manifests,
domains,
types
types,
exclusiveDomains
)
);
@@ -1231,9 +1169,19 @@ class DialogAddAutomationElement
}
groups.push(
...collectionGroups.map(([key, options]) =>
this._convertToItem(key, options, type, localize)
)
...collectionGroups
.filter(([, options]) =>
this._groupHasItems(
type,
options,
type === "condition"
? conditionDescriptions
: triggerDescriptions
)
)
.map(([key, options]) =>
this._convertToItem(key, options, type, localize)
)
);
if (groups.length) {
@@ -1330,11 +1278,28 @@ class DialogAddAutomationElement
return this._services(localize, services, manifests, group);
}
const groups = this._getGroups(type, group, collectionIndex);
const groupDef =
TYPES[type].collections[collectionIndex]?.groups[group] ??
TYPES[type].collections.find((collection) => group in collection.groups)
?.groups[group];
const result = Object.entries(groups).map(([key, options]) =>
this._convertToItem(key, options, type, localize)
);
let result: AddAutomationElementListItem[];
if (groupDef?.domains && !groupDef.members) {
// Curated group whose items come solely from backend domains (e.g. Sun).
result = this._getDomainElementItems(type, groupDef.domains, localize);
} else {
const groups = this._getGroups(type, group, collectionIndex);
result = Object.entries(groups).map(([key, options]) =>
this._convertToItem(key, options, type, localize)
);
if (groupDef?.domains) {
// Curated group with both static members and backend domains (Time).
result.push(
...this._getDomainElementItems(type, groupDef.domains, localize)
);
}
}
if (type === "action") {
if (!this._selectedGroup) {
@@ -1454,7 +1419,8 @@ class DialogAddAutomationElement
triggers: TriggerDescriptions,
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
types: CollectionGroupType[]
types: CollectionGroupType[],
exclusiveDomains: Set<string>
): AddAutomationElementListItem[] => {
if (!triggers || !manifests) {
return [];
@@ -1464,7 +1430,7 @@ class DialogAddAutomationElement
Object.keys(triggers).forEach((trigger) => {
const domain = getTriggerDomain(trigger);
if (addedDomains.has(domain)) {
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
return;
}
addedDomains.add(domain);
@@ -1526,7 +1492,8 @@ class DialogAddAutomationElement
conditions: ConditionDescriptions,
manifests: DomainManifestLookup | undefined,
domains: Set<string> | undefined,
types: CollectionGroupType[]
types: CollectionGroupType[],
exclusiveDomains: Set<string>
): AddAutomationElementListItem[] => {
if (!conditions || !manifests) {
return [];
@@ -1536,7 +1503,7 @@ class DialogAddAutomationElement
Object.keys(conditions).forEach((condition) => {
const domain = getConditionDomain(condition);
if (addedDomains.has(domain)) {
if (addedDomains.has(domain) || exclusiveDomains.has(domain)) {
return;
}
addedDomains.add(domain);
@@ -1796,22 +1763,93 @@ class DialogAddAutomationElement
options,
type: AddAutomationElementDialogParams["type"],
localize: LocalizeFunc
): AddAutomationElementListItem => ({
key,
name: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
options.members ? "groups" : "type"
}.${key}.label`
),
description: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
options.members ? "groups" : "type"
}.${key}.description${options.members ? "" : ".picker"}`
),
iconPath: options.icon || TYPES[type].icons[key],
});
): AddAutomationElementListItem => {
// A group either lists explicit members or bundles backend element domains.
const isGroup = !!(options.members || options.domains);
return {
key,
name: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
isGroup ? "groups" : "type"
}.${key}.label`
),
description: localize(
// @ts-ignore
`ui.panel.config.automation.editor.${type}s.${
isGroup ? "groups" : "type"
}.${key}.description${isGroup ? "" : ".picker"}`
),
iconPath: options.icon || TYPES[type].icons[key],
};
};
// Domains owned exclusively by a curated group, i.e. a group that bundles
// only domains and no static members (e.g. "sun" under the Sun group). Those
// are hidden from the generic dynamic domain grouping so they don't appear
// both standalone and inside the curated group. Domains of a mixed group
// (static members + domains, e.g. "calendar"/"schedule" under Time) are NOT
// hidden — they still surface as their own domain group as well.
private _getExclusiveDomains = memoizeOne(
(type: AddAutomationElementDialogParams["type"]): Set<string> => {
const domains = new Set<string>();
TYPES[type].collections.forEach((collection) =>
Object.values(collection.groups).forEach((group) => {
if (group.domains && !group.members) {
group.domains.forEach((domain) => domains.add(domain));
}
})
);
return domains;
}
);
private _getDomainElementItems(
type: AddAutomationElementDialogParams["type"],
domains: string[],
localize: LocalizeFunc
): AddAutomationElementListItem[] {
const domainSet = new Set(domains);
if (type === "trigger") {
return Object.keys(this._triggerDescriptions)
.filter((trigger) => domainSet.has(getTriggerDomain(trigger)))
.map((trigger) =>
this._getTriggerListItem(localize, getTriggerDomain(trigger), trigger)
);
}
if (type === "condition") {
return Object.keys(this._conditionDescriptions)
.filter((condition) => domainSet.has(getConditionDomain(condition)))
.map((condition) =>
this._getConditionListItem(
localize,
getConditionDomain(condition),
condition
)
);
}
return [];
}
private _groupHasItems(
type: AddAutomationElementDialogParams["type"],
options: { members?: object; domains?: string[] },
descriptions: TriggerDescriptions | ConditionDescriptions
): boolean {
if (options.members && Object.keys(options.members).length) {
return true;
}
if (options.domains) {
const domainSet = new Set(options.domains);
const getDomain =
type === "condition" ? getConditionDomain : getTriggerDomain;
return Object.keys(descriptions).some((key) =>
domainSet.has(getDomain(key))
);
}
// plain single-element group
return true;
}
private _getDomainGroupedListItems(
localize: LocalizeFunc,
@@ -2055,6 +2093,8 @@ class DialogAddAutomationElement
) => {
this._targetItems = undefined;
this._loadItemsError = false;
this._selectedGroup = undefined;
this._selectedCollectionIndex = undefined;
this._selectedTarget = ev.detail.value;
mainWindow.history.pushState(
{
@@ -2076,6 +2116,67 @@ class DialogAddAutomationElement
this._getItemsByTarget();
};
// Time & location groups have no target; picking one drills into its items
// (the same list as the matching group in the "by type" tab).
private _handleTimeLocationGroupSelected = (
ev: ValueChangedEvent<string>
) => {
this._targetItems = undefined;
this._loadItemsError = false;
this._selectedTarget = undefined;
this._selectedGroup = ev.detail.value;
this._selectedCollectionIndex = 0;
mainWindow.history.pushState(
{
dialogData: {
group: this._selectedGroup,
collectionIndex: this._selectedCollectionIndex,
},
},
""
);
requestAnimationFrame(() => {
if (this._narrow) {
this._contentElement?.scrollTo(0, 0);
} else {
this._itemsListElement?.scrollTo(0, 0);
}
});
};
private _getTimeLocationLabel(
type: AddAutomationElementDialogParams["type"]
): string | undefined {
if (type !== "trigger" && type !== "condition") {
return undefined;
}
return this.hass.localize("ui.panel.config.automation.editor.time_sun");
}
private _getTimeLocationGroups = memoizeOne(
(
type: AddAutomationElementDialogParams["type"],
localize: LocalizeFunc,
descriptions: TriggerDescriptions | ConditionDescriptions
): AddAutomationElementListItem[] => {
if (type !== "trigger" && type !== "condition") {
return [];
}
return TIME_LOCATION_GROUPS.map(
(group) => [group, TYPES[type].collections[0].groups[group]] as const
)
.filter(
([, options]) =>
options && this._groupHasItems(type, options, descriptions)
)
.map(([group, options]) =>
this._convertToItem(group, options, type, localize)
)
.filter((item) => item.name);
}
);
private _getDefaultStateItems(
type: "trigger" | "condition"
): AddAutomationElementListItem[] {
@@ -63,6 +63,7 @@ import {
} from "../../../../data/target";
import type { HomeAssistant } from "../../../../types";
import { brandsUrl } from "../../../../util/brands-url";
import type { AddAutomationElementListItem } from "../add-automation-element-dialog";
interface Level1Entries {
open: boolean;
@@ -93,6 +94,16 @@ export default class HaAutomationAddFromTarget extends LitElement {
@property({ attribute: false }) public manifests?: DomainManifestLookup;
// Section title + group rows (Time, Location) for the targetless element
// groups. Picking a row drills into that group's items, just like selecting
// the matching group in the "by type" tab.
@property({ attribute: false }) public timeLocationLabel?: string;
@property({ attribute: false })
public timeLocationGroups?: AddAutomationElementListItem[];
@property({ attribute: false }) public selectedGroup?: string;
// #endregion properties
// #region context
@@ -182,8 +193,20 @@ export default class HaAutomationAddFromTarget extends LitElement {
? this._renderNarrow(this._entries, this.value)
: html`
${this._renderFloors(this.narrow, this._entries, this.value)}
${this._renderTimeLocation(
this.narrow,
this.timeLocationLabel,
this.timeLocationGroups,
this.selectedGroup
)}
${this._renderUnassigned(this.narrow, this._entries, this.value)}
${this._renderLabels(this.narrow, this.value)}
${this._renderLabels(
this.narrow,
this.states,
this._registries,
this._labelRegistry,
this.value
)}
`}
${this.narrow && this._showShowMoreButton && !this._fullHeight
? html`
@@ -343,14 +366,58 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
);
private _renderTimeLocation = memoizeOne(
(
narrow: boolean,
label?: string,
groups?: AddAutomationElementListItem[],
selectedGroup?: string
) => {
if (!label || !groups?.length) {
return nothing;
}
return html`<ha-section-title>${label}</ha-section-title>
<ha-list-base>
${groups.map(
(group) =>
html`<ha-list-item-button
.value=${group.key}
@click=${this._selectTimeLocationGroup}
class=${group.key === selectedGroup ? "selected" : ""}
>
${group.icon
? html`<span slot="start">${group.icon}</span>`
: group.iconPath
? html`<ha-svg-icon
slot="start"
.path=${group.iconPath}
></ha-svg-icon>`
: nothing}
<div slot="headline">${group.name}</div>
${narrow
? html`<ha-icon-next slot="end"></ha-icon-next>`
: nothing}
</ha-list-item-button>`
)}
</ha-list-base>`;
}
);
private _renderLabels = memoizeOne(
(narrow: boolean, value?: SingleHassServiceTarget) => {
(
narrow: boolean,
states: ContextType<typeof statesContext>,
registries: ContextType<typeof registriesContext>,
labelRegistry: LabelRegistryEntry[],
value?: SingleHassServiceTarget
) => {
const labels = this._getLabelsMemoized(
this.states,
this._registries.areas,
this._registries.devices,
this._registries.entities,
this._labelRegistry,
states,
registries.areas,
registries.devices,
registries.entities,
labelRegistry,
undefined,
undefined,
undefined,
@@ -1173,6 +1240,13 @@ export default class HaAutomationAddFromTarget extends LitElement {
}
}
private _selectTimeLocationGroup(ev: CustomEvent) {
const value = (ev.currentTarget as any).value;
if (value) {
fireEvent(this, "time-location-group-selected", { value });
}
}
private async _valueChanged(itemId: string, expand = false) {
const [type, id] = itemId.split(TARGET_SEPARATOR, 2);
@@ -1512,4 +1586,7 @@ declare global {
interface HTMLElementTagNameMap {
"ha-automation-add-from-target": HaAutomationAddFromTarget;
}
interface HASSDomEvents {
"time-location-group-selected": { value: string };
}
}
@@ -1,5 +1,5 @@
import { mdiInformationOutline, mdiPlus } from "@mdi/js";
import { LitElement, css, html, nothing, type TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import {
customElement,
eventOptions,
@@ -40,8 +40,6 @@ export class HaAutomationAddItems extends LitElement {
@property({ attribute: "empty-label" }) public emptyLabel!: string;
@property({ attribute: false }) public emptyNote?: string | TemplateResult;
@property({ attribute: false }) public target?: Target;
@property({ attribute: false }) public getLabel!: (
@@ -83,9 +81,6 @@ export class HaAutomationAddItems extends LitElement {
? html`${this.emptyLabel}
${this.target
? html`<div>${this._renderTarget(this.target)}</div>`
: nothing}
${this.emptyNote
? html`<div class="empty-note">${this.emptyNote}</div>`
: nothing}`
: repeat(
this.items,
@@ -232,17 +227,6 @@ export class HaAutomationAddItems extends LitElement {
justify-content: center;
}
.empty-note {
color: var(--ha-color-text-secondary);
margin-top: var(--ha-space-2);
text-align: center;
}
.empty-note a {
color: currentColor;
text-decoration: underline;
}
.items.error {
background-color: var(--ha-color-fill-danger-quiet-resting);
color: var(--ha-color-on-danger-normal);
@@ -117,9 +117,6 @@ export class HaAutomationAddSearch extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, attribute: "new-triggers-and-conditions" })
public newTriggersAndConditions = false;
@property({ attribute: false })
public convertToItem!: (
key: string,
@@ -209,7 +206,6 @@ export class HaAutomationAddSearch extends LitElement {
this.filter,
this.configEntryLookup,
this.items,
this.newTriggersAndConditions,
this._selectedSearchSection,
this._relatedIdSets
);
@@ -260,19 +256,13 @@ export class HaAutomationAddSearch extends LitElement {
}
private _renderSections() {
if (this.addElementType === "trigger" && !this.newTriggersAndConditions) {
return nothing;
}
const searchSections: ("separator" | SearchSection)[] = ["item"];
if (this.addElementType !== "trigger") {
searchSections.push("block");
}
if (this.newTriggersAndConditions) {
searchSections.push(...TARGET_SEARCH_SECTIONS);
}
searchSections.push(...TARGET_SEARCH_SECTIONS);
return html`
<ha-chip-set class="sections">
${searchSections.map((section) =>
@@ -502,7 +492,6 @@ export class HaAutomationAddSearch extends LitElement {
searchTerm: string,
configEntryLookup: Record<string, ConfigEntry>,
automationItems: AddAutomationElementListItem[],
newTriggersAndConditions: boolean,
selectedSection?: SearchSection,
relatedIdSets?: RelatedIdSets
) => {
@@ -570,191 +559,185 @@ export class HaAutomationAddSearch extends LitElement {
resultItems.push(...blocks);
}
if (newTriggersAndConditions) {
if (!selectedSection || selectedSection === "entity") {
let entityItems = this._getEntitiesMemoized(
this.hass,
`entity${TARGET_SEPARATOR}`
);
if (!selectedSection || selectedSection === "entity") {
let entityItems = this._getEntitiesMemoized(
this.hass,
`entity${TARGET_SEPARATOR}`
);
if (relatedIdSets?.entities.size) {
entityItems = entityItems.map((item) => ({
...item,
isRelated: relatedIdSets.entities.has(
(item as EntityComboBoxItem).stateObj?.entity_id || ""
),
})) as EntityComboBoxItem[];
}
if (searchTerm) {
entityItems = sortRelatedFirst(
this._filterGroup(
"entity",
entityItems,
searchTerm,
entityComboBoxKeys
)
) as EntityComboBoxItem[];
} else if (relatedIdSets?.entities.size) {
entityItems = sortRelatedFirst(entityItems) as EntityComboBoxItem[];
}
if (!selectedSection && entityItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.entities")
);
}
resultItems.push(...entityItems);
}
if (!selectedSection || selectedSection === "device") {
let deviceItems = this._getDevicesMemoized(
this.hass,
configEntryLookup,
`device${TARGET_SEPARATOR}`
);
if (relatedIdSets?.devices.size) {
deviceItems = deviceItems.map((item) => ({
...item,
isRelated: relatedIdSets.devices.has(
item.id.split(TARGET_SEPARATOR)[1] || ""
),
}));
}
if (searchTerm) {
deviceItems = sortRelatedFirst(
this._filterGroup(
"device",
deviceItems,
searchTerm,
deviceComboBoxKeys
)
);
} else if (relatedIdSets?.devices.size) {
deviceItems = sortRelatedFirst(deviceItems);
}
if (!selectedSection && deviceItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.devices")
);
}
resultItems.push(...deviceItems);
}
if (!selectedSection || selectedSection === "area") {
let areasAndFloors = this._getAreasAndFloorsMemoized(
this.hass.states,
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(TARGET_SEPARATOR)
if (relatedIdSets?.entities.size) {
entityItems = entityItems.map((item) => ({
...item,
isRelated: relatedIdSets.entities.has(
(item as EntityComboBoxItem).stateObj?.entity_id || ""
),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined
);
if (relatedIdSets?.areas.size) {
areasAndFloors = areasAndFloors.map((item) => ({
...item,
isRelated:
item.type === "area"
? relatedIdSets.areas.has(
item.id.split(TARGET_SEPARATOR)[1] || ""
)
: false,
})) as FloorComboBoxItem[];
}
if (searchTerm) {
areasAndFloors = sortRelatedFirst(
this._filterGroup(
"area",
areasAndFloors,
searchTerm,
areaFloorComboBoxKeys,
false
)
) as FloorComboBoxItem[];
} else if (relatedIdSets?.areas.size) {
areasAndFloors = sortRelatedFirst(
areasAndFloors
) as FloorComboBoxItem[];
}
if (!selectedSection && areasAndFloors.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.areas")
);
}
resultItems.push(
...areasAndFloors.map((item, index) => {
const nextItem = areasAndFloors[index + 1];
if (
!nextItem ||
(item.type === "area" && nextItem.type === "floor")
) {
return {
...item,
last: true,
};
}
return item;
})
);
})) as EntityComboBoxItem[];
}
if (!selectedSection || selectedSection === "label") {
let labels = this._getLabelsMemoized(
this.hass.states,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this._labelRegistry,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
`label${TARGET_SEPARATOR}`
);
if (searchTerm) {
labels = this._filterGroup(
"label",
labels,
if (searchTerm) {
entityItems = sortRelatedFirst(
this._filterGroup(
"entity",
entityItems,
searchTerm,
labelComboBoxKeys
);
}
if (!selectedSection && labels.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.labels")
);
}
resultItems.push(...labels);
entityComboBoxKeys
)
) as EntityComboBoxItem[];
} else if (relatedIdSets?.entities.size) {
entityItems = sortRelatedFirst(entityItems) as EntityComboBoxItem[];
}
if (!selectedSection && entityItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.entities")
);
}
resultItems.push(...entityItems);
}
if (!selectedSection || selectedSection === "device") {
let deviceItems = this._getDevicesMemoized(
this.hass,
configEntryLookup,
`device${TARGET_SEPARATOR}`
);
if (relatedIdSets?.devices.size) {
deviceItems = deviceItems.map((item) => ({
...item,
isRelated: relatedIdSets.devices.has(
item.id.split(TARGET_SEPARATOR)[1] || ""
),
}));
}
if (searchTerm) {
deviceItems = sortRelatedFirst(
this._filterGroup(
"device",
deviceItems,
searchTerm,
deviceComboBoxKeys
)
);
} else if (relatedIdSets?.devices.size) {
deviceItems = sortRelatedFirst(deviceItems);
}
if (!selectedSection && deviceItems.length) {
// show group title
resultItems.push(
localize("ui.components.target-picker.type.devices")
);
}
resultItems.push(...deviceItems);
}
if (!selectedSection || selectedSection === "area") {
let areasAndFloors = this._getAreasAndFloorsMemoized(
this.hass.states,
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(TARGET_SEPARATOR)
),
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined
);
if (relatedIdSets?.areas.size) {
areasAndFloors = areasAndFloors.map((item) => ({
...item,
isRelated:
item.type === "area"
? relatedIdSets.areas.has(
item.id.split(TARGET_SEPARATOR)[1] || ""
)
: false,
})) as FloorComboBoxItem[];
}
if (searchTerm) {
areasAndFloors = sortRelatedFirst(
this._filterGroup(
"area",
areasAndFloors,
searchTerm,
areaFloorComboBoxKeys,
false
)
) as FloorComboBoxItem[];
} else if (relatedIdSets?.areas.size) {
areasAndFloors = sortRelatedFirst(
areasAndFloors
) as FloorComboBoxItem[];
}
if (!selectedSection && areasAndFloors.length) {
// show group title
resultItems.push(localize("ui.components.target-picker.type.areas"));
}
resultItems.push(
...areasAndFloors.map((item, index) => {
const nextItem = areasAndFloors[index + 1];
if (
!nextItem ||
(item.type === "area" && nextItem.type === "floor")
) {
return {
...item,
last: true,
};
}
return item;
})
);
}
if (!selectedSection || selectedSection === "label") {
let labels = this._getLabelsMemoized(
this.hass.states,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this._labelRegistry,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
`label${TARGET_SEPARATOR}`
);
if (searchTerm) {
labels = this._filterGroup(
"label",
labels,
searchTerm,
labelComboBoxKeys
);
}
if (!selectedSection && labels.length) {
// show group title
resultItems.push(localize("ui.components.target-picker.type.labels"));
}
resultItems.push(...labels);
}
return resultItems;
@@ -1,9 +1,7 @@
import { consume } from "@lit/context";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import deepClone from "deep-clone-simple";
import type {
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators";
@@ -19,12 +17,8 @@ import {
type Condition,
} from "../../../../data/automation";
import type { ConditionDescriptions } from "../../../../data/condition";
import {
CONDITION_BUILDING_BLOCKS,
subscribeConditions,
} from "../../../../data/condition";
import { subscribeLabFeature } from "../../../../data/labs";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
import { conditionDescriptionsContext } from "../../../../data/context";
import { EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET } from "../editor-toast";
import {
getAddAutomationElementTargetFromQuery,
@@ -38,7 +32,7 @@ import type HaAutomationConditionRow from "./ha-automation-condition-row";
@customElement("ha-automation-condition")
export default class HaAutomationCondition extends AutomationSortableListMixin<Condition>(
SubscribeMixin(LitElement)
LitElement
) {
@property({ attribute: false }) public conditions!: Condition[];
@@ -48,16 +42,13 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
@property({ type: Boolean, attribute: false }) public editorDirty = false;
@state() private _conditionDescriptions: ConditionDescriptions = {};
@state()
@consume({ context: conditionDescriptionsContext, subscribe: true })
private _conditionDescriptions: ConditionDescriptions = {};
@queryAll("ha-automation-condition-row")
private _conditionRowElements?: HaAutomationConditionRow[];
// @ts-ignore
@state() private _newTriggersAndConditions = false;
private _unsub?: Promise<UnsubscribeFunc>;
private _openedAddDialogFromQuery = false;
protected get items(): Condition[] {
@@ -72,49 +63,6 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
this.highlightedConditions = items;
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribe();
}
protected hassSubscribe() {
return [
subscribeLabFeature(
this.hass!.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersAndConditions = feature.enabled;
}
),
];
}
private _subscribeDescriptions() {
this._unsubscribe();
this._conditionDescriptions = {};
this._unsub = subscribeConditions(this.hass, (descriptions) => {
this._conditionDescriptions = {
...this._conditionDescriptions,
...descriptions,
};
});
}
private _unsubscribe() {
if (this._unsub) {
this._unsub.then((unsub) => unsub());
this._unsub = undefined;
}
}
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (changedProperties.has("_newTriggersAndConditions")) {
this._subscribeDescriptions();
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("conditions");
@@ -1,13 +1,21 @@
import { mdiHelpCircleOutline } from "@mdi/js";
import { mdiAlertOutline, mdiHelpCircleOutline } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
import type { PlatformCondition } from "../../../../../data/automation";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-tooltip";
import type {
ForDict,
PlatformCondition,
} from "../../../../../data/automation";
import {
getConditionDomain,
getConditionObjectId,
@@ -15,11 +23,21 @@ import {
} from "../../../../../data/condition";
import type { IntegrationManifest } from "../../../../../data/integration";
import { fetchIntegrationManifest } from "../../../../../data/integration";
import { getRecorderEntityOptions } from "../../../../../data/recorder";
import type { TargetSelector } from "../../../../../data/selector";
import { getTargetEntityCount } from "../../../../../data/target";
import {
extractFromTarget,
getTargetEntityCount,
} from "../../../../../data/target";
import type { HomeAssistant } from "../../../../../types";
import { documentationUrl } from "../../../../../util/documentation-url";
// Mirrors `MAX_HISTORY_PRIMING_LOOKBACK` in homeassistant/helpers/condition.py:
// when a condition has a `for:` duration, the recorder is only queried this far
// back to prime it at setup, so longer durations can't be fully satisfied from
// history after a restart or reload.
const MAX_HISTORY_PRIMING_LOOKBACK_HOURS = 6;
const showOptionalToggle = (field: ConditionDescription["fields"][string]) =>
field.selector &&
!field.required &&
@@ -41,6 +59,11 @@ export class HaPlatformCondition extends LitElement {
@state() private _resolvedTargetEntityCount?: number;
@state() private _targetHasUnrecordedEntity = false;
// Incremented on each recording check so stale async responses are ignored.
private _recordingCheckId = 0;
public static get defaultConfig(): PlatformCondition {
return { condition: "" };
}
@@ -51,6 +74,26 @@ export class HaPlatformCondition extends LitElement {
this.hass.loadBackendTranslation("conditions");
this.hass.loadBackendTranslation("selector");
}
// The `for:` priming info depends on both the condition (target + duration)
// and the description (whether the condition targets entities at all), which
// can arrive in separate updates.
if (
changedProperties.has("condition") ||
changedProperties.has("description")
) {
const previousCondition = changedProperties.get("condition") as
| undefined
| this["condition"];
if (
changedProperties.has("description") ||
previousCondition?.target !== this.condition?.target ||
previousCondition?.options?.for !== this.condition?.options?.for
) {
this._updateDurationPrimingInfo();
}
}
if (!changedProperties.has("condition")) {
return;
}
@@ -263,7 +306,7 @@ export class HaPlatformCondition extends LitElement {
@click=${showOptional ? this._toggleCheckbox : undefined}
>${this.hass.localize(
`component.${domain}.conditions.${conditionName}.fields.${fieldName}.name`
) || fieldName}</span
) || fieldName}${this._renderForPrimingInfo(fieldName)}</span
>
${description
? html`<span
@@ -472,6 +515,118 @@ export class HaPlatformCondition extends LitElement {
}
}
// Shows a small info icon beside the `for` duration field's label, with a
// tooltip explaining when history priming can't fully cover the duration.
private _renderForPrimingInfo(fieldName: string) {
if (fieldName !== "for") {
return nothing;
}
const text = this._durationPrimingInfoText();
if (!text) {
return nothing;
}
return html`<ha-svg-icon
id="for-priming-info"
tabindex="0"
class="priming-info-icon"
.path=${mdiAlertOutline}
@click=${stopPropagation}
></ha-svg-icon>
<ha-tooltip for="for-priming-info">${text}</ha-tooltip>`;
}
private _durationPrimingInfoText(): string | undefined {
const forValue = this.condition.options?.for;
// Priming only happens for entity conditions that have a `for:` duration.
if (
forValue === undefined ||
forValue === "" ||
!this.description?.target
) {
return undefined;
}
if (this._targetHasUnrecordedEntity) {
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.duration_priming.entity_not_recorded"
);
}
if (this._durationExceedsLookback(forValue)) {
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.duration_priming.history_capped",
{ hours: MAX_HISTORY_PRIMING_LOOKBACK_HOURS }
);
}
return undefined;
}
private _durationExceedsLookback(forValue: unknown): boolean {
const duration = createDurationData(
forValue as string | number | ForDict | undefined
);
if (!duration) {
return false;
}
return (
durationDataToSeconds(duration) >
MAX_HISTORY_PRIMING_LOOKBACK_HOURS * 3600
);
}
private async _updateDurationPrimingInfo(): Promise<void> {
const forValue = this.condition.options?.for;
const target = this.condition.target;
// Recording status only matters for an entity condition that has both a
// target and a `for:` duration.
const checkId = ++this._recordingCheckId;
if (
forValue === undefined ||
forValue === "" ||
!this.description?.target ||
!target ||
!this.hass.config.components.includes("recorder")
) {
this._targetHasUnrecordedEntity = false;
return;
}
try {
const { referenced_entities } = await extractFromTarget(
this.hass.callWS,
target
);
// Ignore if a newer check superseded this one.
if (checkId !== this._recordingCheckId) {
return;
}
if (!referenced_entities.length) {
this._targetHasUnrecordedEntity = false;
return;
}
const recordingDisabled = await Promise.all(
referenced_entities.map((entityId) =>
getRecorderEntityOptions(this.hass, entityId)
.then((options) => options.recording_disabled_by !== null)
// Unknown entity or command unavailable on older cores: don't warn.
.catch(() => false)
)
);
if (checkId !== this._recordingCheckId) {
return;
}
this._targetHasUnrecordedEntity = recordingDisabled.some(Boolean);
} catch (_err) {
// Target resolution failed; fall back to no warning rather than guessing.
if (checkId === this._recordingCheckId) {
this._targetHasUnrecordedEntity = false;
}
}
}
static styles = css`
:host {
display: block;
@@ -527,6 +682,15 @@ export class HaPlatformCondition extends LitElement {
.clickable {
cursor: pointer;
}
.priming-info-icon {
--mdc-icon-size: 16px;
width: 16px;
height: 16px;
color: var(--warning-color);
margin-inline-start: var(--ha-space-1);
vertical-align: middle;
cursor: help;
}
`;
}
@@ -38,7 +38,6 @@ export class HaZoneCondition extends LitElement {
)}
.value=${entity_id}
@value-changed=${this._entityPicked}
.hass=${this.hass}
.disabled=${this.disabled}
.entityFilter=${zoneAndLocationFilter}
></ha-entity-picker>
@@ -48,7 +47,6 @@ export class HaZoneCondition extends LitElement {
)}
.value=${zone}
@value-changed=${this._zonePicked}
.hass=${this.hass}
.disabled=${this.disabled}
.includeDomains=${includeDomains}
></ha-entity-picker>
@@ -120,7 +120,7 @@ import {
getLabelsTableColumn,
getTriggeredAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import {
getAssistantsSortableKey,
@@ -1,9 +1,7 @@
import { consume } from "@lit/context";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import deepClone from "deep-clone-simple";
import type {
HassServiceTarget,
UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -19,10 +17,9 @@ import {
type Trigger,
type TriggerList,
} from "../../../../data/automation";
import { subscribeLabFeature } from "../../../../data/labs";
import { triggerDescriptionsContext } from "../../../../data/context";
import type { TriggerDescriptions } from "../../../../data/trigger";
import { isTriggerList, subscribeTriggers } from "../../../../data/trigger";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { isTriggerList } from "../../../../data/trigger";
import { EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET } from "../editor-toast";
import {
getAddAutomationElementTargetFromQuery,
@@ -36,7 +33,7 @@ import type HaAutomationTriggerRow from "./ha-automation-trigger-row";
@customElement("ha-automation-trigger")
export default class HaAutomationTrigger extends AutomationSortableListMixin<Trigger>(
SubscribeMixin(LitElement)
LitElement
) {
@property({ attribute: false }) public triggers!: Trigger[];
@@ -46,12 +43,9 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
@property({ type: Boolean, attribute: false }) public editorDirty = false;
@state() private _triggerDescriptions: TriggerDescriptions = {};
// @ts-ignore
@state() private _newTriggersAndConditions = false;
private _unsub?: Promise<UnsubscribeFunc>;
@state()
@consume({ context: triggerDescriptionsContext, subscribe: true })
private _triggerDescriptions: TriggerDescriptions = {};
private _openedAddDialogFromQuery = false;
@@ -67,49 +61,6 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
this.highlightedTriggers = items;
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribe();
}
protected hassSubscribe() {
return [
subscribeLabFeature(
this.hass!.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersAndConditions = feature.enabled;
}
),
];
}
private _subscribeDescriptions() {
this._unsubscribe();
this._triggerDescriptions = {};
this._unsub = subscribeTriggers(this.hass, (descriptions) => {
this._triggerDescriptions = {
...this._triggerDescriptions,
...descriptions,
};
});
}
private _unsubscribe() {
if (this._unsub) {
this._unsub.then((unsub) => unsub());
this._unsub = undefined;
}
}
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (changedProperties.has("_newTriggersAndConditions")) {
this._subscribeDescriptions();
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("triggers");
@@ -1,17 +1,189 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { hasTemplate } from "../../../../../common/string/has-template";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { NumericStateTrigger } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
const SCHEMA = [
{
name: "entity_id",
required: true,
selector: { entity: { multiple: true } },
},
{
name: "attribute",
context: { filter_entity: "entity_id" },
selector: {
attribute: {
hide_attributes: [
"access_token",
"auto_update",
"available_modes",
"away_mode",
"changed_by",
"code_arm_required",
"code_format",
"color_mode",
"color_modes",
"current_activity",
"device_class",
"editable",
"effect_list",
"effect",
"entity_id",
"entity_picture",
"event_type",
"event_types",
"fan_mode",
"fan_modes",
"fan_speed_list",
"forecast",
"friendly_name",
"frontend_stream_type",
"has_date",
"has_time",
"hs_color",
"hvac_mode",
"hvac_modes",
"icon",
"id",
"latest_version",
"max_color_temp_kelvin",
"max_mireds",
"max_temp",
"media_album_name",
"media_artist",
"media_content_type",
"media_position_updated_at",
"media_title",
"min_color_temp_kelvin",
"min_mireds",
"min_temp",
"mode",
"next_dawn",
"next_dusk",
"next_midnight",
"next_noon",
"next_rising",
"next_setting",
"operation_list",
"operation_mode",
"options",
"percentage_step",
"precipitation_unit",
"preset_mode",
"preset_modes",
"pressure_unit",
"release_notes",
"release_summary",
"release_url",
"restored",
"rgb_color",
"rgbw_color",
"shuffle",
"skipped_version",
"sound_mode_list",
"sound_mode",
"source_list",
"source_type",
"source",
"state_class",
"step",
"supported_color_modes",
"supported_features",
"swing_mode",
"swing_modes",
"target_temp_step",
"temperature_unit",
"title",
"token",
"unit_of_measurement",
"user_id",
"uuid",
"visibility_unit",
"wind_speed_unit",
"xy_color",
],
},
},
},
{
name: "above",
selector: {
choose: {
translation_key:
"ui.panel.config.automation.editor.triggers.type.numeric_state.threshold_type",
choices: {
value: {
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
input: {
selector: {
entity: { domain: ["input_number", "number", "sensor"] },
},
},
},
},
},
},
{
name: "below",
selector: {
choose: {
translation_key:
"ui.panel.config.automation.editor.triggers.type.numeric_state.threshold_type",
choices: {
value: {
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
input: {
selector: {
entity: { domain: ["input_number", "number", "sensor"] },
},
},
},
},
},
},
{
name: "value_template",
selector: { template: {} },
},
{
name: "for",
selector: {
choose: {
translation_key:
"ui.panel.config.automation.editor.triggers.type.numeric_state.for_type",
choices: {
duration: { selector: { duration: {} } },
template: { selector: { template: {} } },
},
},
},
},
] as const;
@customElement("ha-automation-trigger-numeric_state")
export class HaNumericStateTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -20,236 +192,6 @@ export class HaNumericStateTrigger extends LitElement {
@property({ type: Boolean }) public disabled = false;
@state() private _inputAboveIsEntity?: boolean;
@state() private _inputBelowIsEntity?: boolean;
private _schema = memoizeOne(
(
localize: LocalizeFunc,
inputAboveIsEntity?: boolean,
inputBelowIsEntity?: boolean
) =>
[
{
name: "entity_id",
required: true,
selector: { entity: { multiple: true } },
},
{
name: "attribute",
context: { filter_entity: "entity_id" },
selector: {
attribute: {
hide_attributes: [
"access_token",
"auto_update",
"available_modes",
"away_mode",
"changed_by",
"code_arm_required",
"code_format",
"color_mode",
"color_modes",
"current_activity",
"device_class",
"editable",
"effect_list",
"effect",
"entity_id",
"entity_picture",
"event_type",
"event_types",
"fan_mode",
"fan_modes",
"fan_speed_list",
"forecast",
"friendly_name",
"frontend_stream_type",
"has_date",
"has_time",
"hs_color",
"hvac_mode",
"hvac_modes",
"icon",
"id",
"latest_version",
"max_color_temp_kelvin",
"max_mireds",
"max_temp",
"media_album_name",
"media_artist",
"media_content_type",
"media_position_updated_at",
"media_title",
"min_color_temp_kelvin",
"min_mireds",
"min_temp",
"mode",
"next_dawn",
"next_dusk",
"next_midnight",
"next_noon",
"next_rising",
"next_setting",
"operation_list",
"operation_mode",
"options",
"percentage_step",
"precipitation_unit",
"preset_mode",
"preset_modes",
"pressure_unit",
"release_notes",
"release_summary",
"release_url",
"restored",
"rgb_color",
"rgbw_color",
"shuffle",
"skipped_version",
"sound_mode_list",
"sound_mode",
"source_list",
"source_type",
"source",
"state_class",
"step",
"supported_color_modes",
"supported_features",
"swing_mode",
"swing_modes",
"target_temp_step",
"temperature_unit",
"title",
"token",
"unit_of_measurement",
"user_id",
"uuid",
"visibility_unit",
"wind_speed_unit",
"xy_color",
],
},
},
},
{
name: "lower_limit",
type: "select",
required: true,
options: [
[
"value",
localize(
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_value"
),
],
[
"input",
localize(
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_input"
),
],
],
},
...(inputAboveIsEntity
? ([
{
name: "above",
selector: {
entity: { domain: ["input_number", "number", "sensor"] },
},
},
] as const)
: ([
{
name: "above",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
] as const)),
{
name: "upper_limit",
type: "select",
required: true,
options: [
[
"value",
localize(
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_value"
),
],
[
"input",
localize(
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_input"
),
],
],
},
...(inputBelowIsEntity
? ([
{
name: "below",
selector: {
entity: { domain: ["input_number", "number", "sensor"] },
},
},
] as const)
: ([
{
name: "below",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
] as const)),
{
name: "value_template",
selector: { template: {} },
},
{ name: "for", selector: { duration: {} } },
] as const
);
public willUpdate(changedProperties: PropertyValues<this>) {
this._inputAboveIsEntity =
this._inputAboveIsEntity ??
(typeof this.trigger.above === "string" &&
((this.trigger.above as string).startsWith("input_number.") ||
(this.trigger.above as string).startsWith("number.") ||
(this.trigger.above as string).startsWith("sensor.")));
this._inputBelowIsEntity =
this._inputBelowIsEntity ??
(typeof this.trigger.below === "string" &&
((this.trigger.below as string).startsWith("input_number.") ||
(this.trigger.below as string).startsWith("number.") ||
(this.trigger.below as string).startsWith("sensor.")));
if (!changedProperties.has("trigger")) {
return;
}
// Check for templates in trigger. If found, revert to YAML mode.
if (this.trigger && hasTemplate(this.trigger.for)) {
fireEvent(
this,
"ui-mode-not-available",
Error(this.hass.localize("ui.errors.config.no_template_editor_support"))
);
}
}
public static get defaultConfig(): NumericStateTrigger {
return {
trigger: "numeric_state",
@@ -257,39 +199,61 @@ export class HaNumericStateTrigger extends LitElement {
};
}
private _data = memoizeOne(
(
inputAboveIsEntity: boolean,
inputBelowIsEntity: boolean,
trigger: NumericStateTrigger
) => ({
lower_limit: inputAboveIsEntity ? "input" : "value",
upper_limit: inputBelowIsEntity ? "input" : "value",
...trigger,
entity_id: ensureArray(trigger.entity_id),
for: createDurationData(trigger.for),
})
);
private _wrapForValue(
forValue: NumericStateTrigger["for"]
): Record<string, unknown> | undefined {
if (forValue === undefined) {
return undefined;
}
if (typeof forValue === "string" && hasTemplate(forValue)) {
return { active_choice: "template", template: forValue };
}
return {
active_choice: "duration",
duration: createDurationData(forValue),
};
}
private _unwrapForValue(
forValue: Record<string, unknown> | undefined
): NumericStateTrigger["for"] {
if (!forValue || !forValue.active_choice) {
return forValue as NumericStateTrigger["for"];
}
if (forValue.active_choice === "template") {
return forValue.template as string;
}
return forValue.duration as NumericStateTrigger["for"];
}
private _unwrapThresholdValue(
value: Record<string, unknown> | number | string | undefined
): number | string | undefined {
if (value === undefined || typeof value !== "object") {
return value as number | string | undefined;
}
if (!value.active_choice) {
return undefined;
}
return value[value.active_choice as string] as number | string | undefined;
}
private _data = memoizeOne((trigger: NumericStateTrigger) => ({
...trigger,
entity_id: ensureArray(trigger.entity_id),
for: this._wrapForValue(trigger.for),
}));
public render() {
const schema = this._schema(
this.hass.localize,
this._inputAboveIsEntity,
this._inputBelowIsEntity
);
const data = this._data(
this._inputAboveIsEntity!,
this._inputBelowIsEntity!,
this.trigger
);
const data = this._data(this.trigger);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.schema=${SCHEMA}
.disabled=${this.disabled}
.localizeValue=${this.hass.localize}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
></ha-form>
@@ -300,11 +264,9 @@ export class HaNumericStateTrigger extends LitElement {
ev.stopPropagation();
const newTrigger = { ...ev.detail.value };
this._inputAboveIsEntity = newTrigger.lower_limit === "input";
this._inputBelowIsEntity = newTrigger.upper_limit === "input";
delete newTrigger.lower_limit;
delete newTrigger.upper_limit;
newTrigger.above = this._unwrapThresholdValue(newTrigger.above);
newTrigger.below = this._unwrapThresholdValue(newTrigger.below);
newTrigger.for = this._unwrapForValue(newTrigger.for);
if (newTrigger.value_template === "") {
delete newTrigger.value_template;
@@ -314,7 +276,7 @@ export class HaNumericStateTrigger extends LitElement {
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
schema: SchemaUnion<typeof SCHEMA>
): string => {
switch (schema.name) {
case "entity_id":
@@ -174,7 +174,19 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
},
},
},
{ name: "for", selector: { duration: {} } },
{
name: "for",
selector: {
choose: {
translation_key:
"ui.panel.config.automation.editor.triggers.type.state.for_type",
choices: {
duration: { selector: { duration: {} } },
template: { selector: { template: {} } },
},
},
},
},
] as const satisfies HaFormSchema[]
);
@@ -190,7 +202,9 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
delete this.trigger.for.milliseconds;
}
// Check for templates in trigger. If found, revert to YAML mode.
if (this.trigger && hasTemplate(this.trigger)) {
// Exclude "for" since the UI now supports templates there via choose.
const { for: _for, ...triggerWithoutFor } = this.trigger;
if (triggerWithoutFor && hasTemplate(triggerWithoutFor)) {
fireEvent(
this,
"ui-mode-not-available",
@@ -207,13 +221,38 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
return true;
}
protected render() {
const trgFor = createDurationData(this.trigger.for);
private _wrapForValue(
forValue: StateTrigger["for"]
): Record<string, unknown> | undefined {
if (forValue === undefined) {
return undefined;
}
if (typeof forValue === "string" && hasTemplate(forValue)) {
return { active_choice: "template", template: forValue };
}
return {
active_choice: "duration",
duration: createDurationData(forValue),
};
}
private _unwrapForValue(
forValue: Record<string, unknown> | undefined
): StateTrigger["for"] {
if (!forValue || !forValue.active_choice) {
return forValue as StateTrigger["for"];
}
if (forValue.active_choice === "template") {
return forValue.template as string;
}
return forValue.duration as StateTrigger["for"];
}
protected render() {
const data = {
...this.trigger,
entity_id: ensureArray(this.trigger.entity_id),
for: trgFor,
for: this._wrapForValue(this.trigger.for),
};
data.to = this._normalizeStates(this.trigger.to, data.attribute);
@@ -230,6 +269,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
.hass=${this.hass}
.data=${data}
.schema=${schema}
.localizeValue=${this.hass.localize}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
.disabled=${this.disabled}
@@ -241,6 +281,8 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
ev.stopPropagation();
const newTrigger = ev.detail.value;
newTrigger.for = this._unwrapForValue(newTrigger.for);
newTrigger.to = this._applyAnyStateExclusive(
newTrigger.to,
newTrigger.attribute
@@ -1,4 +1,3 @@
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { TemplateTrigger } from "../../../../../data/automation";
@@ -11,7 +10,19 @@ import type { SchemaUnion } from "../../../../../components/ha-form/types";
const SCHEMA = [
{ name: "value_template", required: true, selector: { template: {} } },
{ name: "for", selector: { duration: {} } },
{
name: "for",
selector: {
choose: {
translation_key:
"ui.panel.config.automation.editor.triggers.type.template.for_type",
choices: {
duration: { selector: { duration: {} } },
template: { selector: { template: {} } },
},
},
},
},
] as const;
@customElement("ha-automation-trigger-template")
@@ -26,26 +37,37 @@ export class HaTemplateTrigger extends LitElement {
return { trigger: "template", value_template: "" };
}
public willUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("trigger")) {
return;
private _wrapForValue(
forValue: TemplateTrigger["for"]
): Record<string, unknown> | undefined {
if (forValue === undefined) {
return undefined;
}
// Check for templates in trigger. If found, revert to YAML mode.
if (this.trigger && hasTemplate(this.trigger.for)) {
fireEvent(
this,
"ui-mode-not-available",
Error(this.hass.localize("ui.errors.config.no_template_editor_support"))
);
if (typeof forValue === "string" && hasTemplate(forValue)) {
return { active_choice: "template", template: forValue };
}
return {
active_choice: "duration",
duration: createDurationData(forValue),
};
}
private _unwrapForValue(
forValue: Record<string, unknown> | undefined
): TemplateTrigger["for"] {
if (!forValue || !forValue.active_choice) {
return forValue as TemplateTrigger["for"];
}
if (forValue.active_choice === "template") {
return forValue.template as string;
}
return forValue.duration as TemplateTrigger["for"];
}
protected render() {
const trgFor = createDurationData(this.trigger.for);
const data = {
...this.trigger,
for: trgFor,
for: this._wrapForValue(this.trigger.for),
};
return html`
@@ -53,6 +75,7 @@ export class HaTemplateTrigger extends LitElement {
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA}
.localizeValue=${this.hass.localize}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
.disabled=${this.disabled}
@@ -64,8 +87,11 @@ export class HaTemplateTrigger extends LitElement {
ev.stopPropagation();
const newTrigger = ev.detail.value;
newTrigger.for = this._unwrapForValue(newTrigger.for);
if (
newTrigger.for &&
typeof newTrigger.for === "object" &&
Object.values(newTrigger.for).every((value) => value === 0)
) {
delete newTrigger.for;
@@ -45,7 +45,6 @@ export class HaZoneTrigger extends LitElement {
.value=${entity_id ? ensureArray(entity_id) : []}
.disabled=${this.disabled}
@value-changed=${this._entityPicked}
.hass=${this.hass}
.entityFilter=${zoneAndLocationFilter}
></ha-entities-picker>
<ha-entity-picker
@@ -55,7 +54,6 @@ export class HaZoneTrigger extends LitElement {
.value=${zone}
.disabled=${this.disabled}
@value-changed=${this._zonePicked}
.hass=${this.hass}
.includeDomains=${includeDomains}
></ha-entity-picker>
@@ -52,7 +52,7 @@ import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { showAddBlueprintDialog } from "./show-dialog-import-blueprint";
type BlueprintMetaDataPath = BlueprintMetaData & {
+555
View File
@@ -0,0 +1,555 @@
import {
mdiAccount,
mdiBackupRestore,
mdiBadgeAccountHorizontal,
mdiBluetooth,
mdiCellphoneCog,
mdiCog,
mdiDatabase,
mdiDevices,
mdiFlask,
mdiHammer,
mdiInformationOutline,
mdiLabel,
mdiLightningBolt,
mdiMapMarkerRadius,
mdiMemory,
mdiMicrophone,
mdiNetwork,
mdiNfcVariant,
mdiPalette,
mdiPaletteSwatch,
mdiPuzzle,
mdiRadioTower,
mdiRemote,
mdiRobot,
mdiScrewdriver,
mdiScriptText,
mdiShape,
mdiSofa,
mdiStarFourPoints,
mdiTextBoxOutline,
mdiTools,
mdiUpdate,
mdiViewDashboard,
mdiZigbee,
mdiZWave,
} from "@mdi/js";
import memoizeOne from "memoize-one";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../types";
const getHasDomainCheck = (domain: string) => {
const prefix = `${domain}.`;
const checkRegistry = memoizeOne((entries: HomeAssistant["entities"]) =>
Object.values(entries).some((entry) => entry.entity_id.startsWith(prefix))
);
return (hass: HomeAssistant) => checkRegistry(hass.entities);
};
export const configSections: Record<string, PageNavigation[]> = {
dashboard: [
{
path: "/config/integrations",
translationKey: "devices",
iconPath: mdiDevices,
iconColor: "#0D47A1",
core: true,
adminOnly: true,
},
{
path: "/config/automation",
translationKey: "automations",
iconPath: mdiRobot,
iconColor: "#518C43",
core: true,
adminOnly: true,
},
{
path: "/config/areas",
translationKey: "areas",
iconPath: mdiSofa,
iconColor: "#E48629",
component: "zone",
adminOnly: true,
},
{
path: "/config/apps",
translationKey: "apps",
iconPath: mdiPuzzle,
iconColor: "#F1C447",
core: true,
adminOnly: true,
},
{
path: "/config/lovelace/dashboards",
translationKey: "dashboards",
iconPath: mdiViewDashboard,
iconColor: "#B1345C",
component: "lovelace",
adminOnly: true,
},
{
path: "/config/voice-assistants",
translationKey: "voice_assistants",
iconPath: mdiMicrophone,
iconColor: "#3263C3",
adminOnly: true,
},
],
dashboard_external_settings: [
{
path: "#external-app-configuration",
translationKey: "companion",
iconPath: mdiCellphoneCog,
iconColor: "#8E24AA",
},
],
dashboard_2: [
{
path: "/config/matter",
iconPath:
"M7.228375 6.41685c0.98855 0.80195 2.16365 1.3412 3.416275 1.56765V1.30093l1.3612 -0.7854275 1.360125 0.7854275V7.9845c1.252875 -0.226675 2.4283 -0.765875 3.41735 -1.56765l2.471225 1.4293c-4.019075 3.976275 -10.490025 3.976275 -14.5091 0l2.482925 -1.4293Zm3.00335 17.067575c1.43325 -5.47035 -1.8052 -11.074775 -7.2604 -12.564675v2.859675c1.189125 0.455 2.244125 1.202875 3.0672 2.174275L0.25 19.2955v1.5719l1.3611925 0.781175L7.39865 18.3068c0.430175 1.19825 0.550625 2.48575 0.35015 3.743l2.482925 1.434625ZM21.034 10.91975c-5.452225 1.4932 -8.6871 7.09635 -7.254025 12.564675l2.47655 -1.43035c-0.200025 -1.257275 -0.079575 -2.544675 0.35015 -3.743025l5.7832 3.337525L23.75 20.86315V19.2955L17.961475 15.9537c0.8233 -0.97115 1.878225 -1.718975 3.0672 -2.174275l0.005325 -2.859675Z",
iconViewBox: "0 1 24 24",
iconColor: "#2458B3",
component: "matter",
translationKey: "matter",
adminOnly: true,
},
{
path: "/config/zha",
iconPath: mdiZigbee,
iconColor: "#E74011",
component: "zha",
translationKey: "zha",
adminOnly: true,
},
{
path: "/config/zwave_js",
iconPath: mdiZWave,
iconColor: "#153163",
component: "zwave_js",
translationKey: "zwave_js",
adminOnly: true,
},
{
path: "/knx",
iconPath:
"M 3.9861338,14.261456 3.7267552,13.934877 6.3179131,11.306266 H 4.466374 l -2.6385205,2.68258 V 11.312882 H 0.00440574 L 0,17.679803 l 1.8278535,5.43e-4 v -1.818482 l 0.7225444,-0.732459 2.1373588,2.543782 2.1869324,-5.44e-4 M 24,17.680369 21.809238,17.669359 19.885559,15.375598 17.640262,17.68037 h -1.828407 l 3.236048,-3.302138 -2.574075,-3.067547 2.135161,0.0016 1.610309,1.87687 1.866403,-1.87687 h 1.828429 l -2.857742,2.87478 m -10.589867,-2.924898 2.829625,3.990552 -0.01489,-3.977887 1.811889,-0.0044 0.0011,6.357564 -2.093314,-5.44e-4 -2.922133,-3.947594 -0.0314,3.947594 H 8.2581097 V 11.261677 M 11.971203,6.3517488 c 0,0 2.800714,-0.093203 6.172001,1.0812045 3.462393,1.0898845 5.770926,3.4695627 5.770926,3.4695627 l -1.823898,-5.43e-4 C 22.088532,10.900273 20.577938,9.4271528 17.660223,8.5024618 15.139256,7.703366 12.723057,7.645835 12.111178,7.6449876 l -9.71e-4,0.0011 c 0,0 -0.0259,-6.4e-4 -0.07527,-9.714e-4 -0.04726,3.33e-4 -0.07201,9.714e-4 -0.07201,9.714e-4 v -0.00113 C 11.337007,7.6453728 8.8132091,7.7001736 6.2821829,8.5024618 3.3627914,9.4276738 1.8521646,10.901973 1.8521646,10.901973 l -1.82398708,5.43e-4 C 0.03128403,10.899322 2.339143,8.5221038 5.799224,7.4329533 9.170444,6.2585642 11.971203,6.3517488 11.971203,6.3517488 Z",
iconColor: "#4EAA66",
component: "knx",
translationKey: "knx",
adminOnly: true,
},
{
path: "/config/thread",
iconPath:
"m 17.126982,8.0730792 c 0,-0.7297242 -0.593746,-1.32357 -1.323637,-1.32357 -0.729454,0 -1.323199,0.5938458 -1.323199,1.32357 v 1.3234242 l 1.323199,1.458e-4 c 0.729891,0 1.323637,-0.5937006 1.323637,-1.32357 z M 11.999709,0 C 5.3829818,0 0,5.3838955 0,12.001455 0,18.574352 5.3105455,23.927406 11.865164,24 V 12.012075 l -3.9275642,-2.91e-4 c -1.1669814,0 -2.1169453,0.949979 -2.1169453,2.118323 0,1.16718 0.9499639,2.116868 2.1169453,2.116868 v 2.615717 c -2.6093089,0 -4.732218,-2.12327 -4.732218,-4.732585 0,-2.61048 2.1229091,-4.7343308 4.732218,-4.7343308 l 3.9275642,5.82e-4 v -1.323279 c 0,-2.172296 1.766691,-3.9395777 3.938181,-3.9395777 2.171928,0 3.9392,1.7672817 3.9392,3.9395777 0,2.1721498 -1.767272,3.9395768 -3.9392,3.9395768 l -1.323199,-1.45e-4 V 23.744102 C 19.911127,22.597726 24,17.768833 24,12.001455 24,5.3838955 18.616727,0 11.999709,0 Z",
iconColor: "#ED7744",
component: "thread",
translationKey: "thread",
adminOnly: true,
},
{
path: "/config/bluetooth",
iconPath: mdiBluetooth,
iconColor: "#0082FC",
component: "bluetooth",
translationKey: "bluetooth",
adminOnly: true,
},
{
path: "/config/infrared",
iconPath: mdiRemote,
iconColor: "#9C27B0",
translationKey: "infrared",
adminOnly: true,
filter: getHasDomainCheck("infrared"),
},
{
path: "/config/radio-frequency",
iconPath: mdiRadioTower,
iconColor: "#E74011",
component: "radio_frequency",
translationKey: "radio_frequency",
adminOnly: true,
filter: getHasDomainCheck("radio_frequency"),
},
{
path: "/insteon",
iconPath:
"m 12.001571,6.3842473 h 0.02973 c 3.652189,0 6.767389,-2.29456 7.987462,-5.5177193 L 15.389382,0 Z m 0,0 h -0.02972 c -3.6522186,0 -6.7673314,-2.2918546 -7.9874477,-5.5177193 h -0.00271 L 8.6111273,0 Z M 6.3840436,11.999287 v -0.02972 c 0,-3.6524074 -2.2944727,-6.7675928 -5.51754469,-7.9877383 L 0,8.6114473 Z m 0,0 v 0.02964 c 0,3.652378 -2.2917818,6.767578 -5.51754469,7.987796 v 0.0026 L 0,15.389818 Z M 24,8.6114473 23.133527,3.9818327 v 0.00269 C 19.907636,5.2046836 17.616,8.3198691 17.616,11.972276 v 0.02966 0.02972 0.0027 c 0,3.65232 2.2944,6.76752 5.517527,7.987738 L 24,15.392436 17.616,12.001935 Z M 20.018618,23.133527 15.389091,24 11.99872,17.615709 h 0.02964 c 3.652218,0 6.767418,2.291927 7.987491,5.517818 z M 11.99872,17.615709 8.6082618,24 3.9788364,23.133527 C 5.1989527,19.9104 8.3140655,17.615709 11.966284,17.615709 h 0.0027 z",
iconColor: "#E4002C",
component: "insteon",
translationKey: "insteon",
adminOnly: true,
},
{
path: "/config/tags",
translationKey: "tags",
iconPath: mdiNfcVariant,
iconColor: "#616161",
component: "tag",
adminOnly: true,
},
],
dashboard_3: [
{
path: "/config/person",
translationKey: "people",
iconPath: mdiAccount,
iconColor: "#5A87FA",
component: ["person", "users"],
adminOnly: true,
},
{
path: "/config/system",
translationKey: "system",
iconPath: mdiCog,
iconColor: "#301ABE",
core: true,
adminOnly: true,
},
{
path: "/config/developer-tools",
translationKey: "developer_tools",
iconPath: mdiHammer,
iconColor: "#7A5AA6",
core: true,
adminOnly: true,
},
{
path: "/config/info",
translationKey: "about",
iconPath: mdiInformationOutline,
iconColor: "#4A5963",
core: true,
adminOnly: true,
},
],
backup: [
{
path: "/config/backup",
translationKey: "ui.panel.config.backup.caption",
iconPath: mdiBackupRestore,
iconColor: "#4084CD",
component: "backup",
adminOnly: true,
},
],
devices: [
{
component: "integrations",
path: "/config/integrations",
translationKey: "ui.panel.config.integrations.caption",
iconPath: mdiPuzzle,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "devices",
path: "/config/devices",
translationKey: "ui.panel.config.devices.caption",
iconPath: mdiDevices,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "entities",
path: "/config/entities",
translationKey: "ui.panel.config.entities.caption",
iconPath: mdiShape,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "helpers",
path: "/config/helpers",
translationKey: "ui.panel.config.helpers.caption",
iconPath: mdiTools,
iconColor: "#4D2EA4",
core: true,
adminOnly: true,
},
],
automations: [
{
component: "automation",
path: "/config/automation",
translationKey: "ui.panel.config.automation.caption",
iconPath: mdiRobot,
iconColor: "#518C43",
adminOnly: true,
},
{
component: "scene",
path: "/config/scene",
translationKey: "ui.panel.config.scene.caption",
iconPath: mdiPalette,
iconColor: "#518C43",
adminOnly: true,
},
{
component: "script",
path: "/config/script",
translationKey: "ui.panel.config.script.caption",
iconPath: mdiScriptText,
iconColor: "#518C43",
adminOnly: true,
},
{
component: "blueprint",
path: "/config/blueprint",
translationKey: "ui.panel.config.blueprint.caption",
iconPath: mdiPaletteSwatch,
iconColor: "#518C43",
adminOnly: true,
},
],
tags: [
{
component: "tag",
path: "/config/tags",
translationKey: "ui.panel.config.tag.caption",
iconPath: mdiNfcVariant,
iconColor: "#616161",
adminOnly: true,
},
],
voice_assistants: [
{
path: "/config/voice-assistants",
translationKey: "ui.panel.config.dashboard.voice_assistants.main",
iconPath: mdiMicrophone,
iconColor: "#3263C3",
adminOnly: true,
},
],
developer_tools: [
{
path: "/config/developer-tools",
translationKey: "ui.panel.config.dashboard.developer_tools.main",
iconPath: mdiHammer,
iconColor: "#7A5AA6",
core: true,
adminOnly: true,
},
],
// Not used as a tab, but this way it will stay in the quick bar
energy: [
{
component: "energy",
path: "/config/energy",
translationKey: "ui.panel.config.energy.caption",
iconPath: mdiLightningBolt,
iconColor: "#F1C447",
adminOnly: true,
},
],
// Not used as a tab, but this way it will stay in the quick bar
network_discovery: [
{
component: "dhcp",
path: "/config/dhcp",
translationKey: "ui.panel.config.network.discovery.dhcp",
iconPath: mdiNetwork,
iconColor: "#B1345C",
adminOnly: true,
},
{
component: "ssdp",
path: "/config/ssdp",
translationKey: "ui.panel.config.network.discovery.ssdp",
iconPath: mdiNetwork,
iconColor: "#B1345C",
adminOnly: true,
},
{
component: "zeroconf",
path: "/config/zeroconf",
translationKey: "ui.panel.config.network.discovery.zeroconf",
iconPath: mdiNetwork,
iconColor: "#B1345C",
adminOnly: true,
},
],
// Not used as a tab, but this way it will stay in the quick bar
integration_credentials: [
{
path: "/config/application_credentials",
translationKey: "ui.panel.config.application_credentials.caption",
iconPath: mdiPuzzle,
iconColor: "#2D338F",
adminOnly: true,
},
],
// Not used as a tab, but this way it will stay in the quick bar
integration_mqtt: [
{
component: "mqtt",
path: "/config/mqtt",
translationKey: "ui.panel.config.mqtt.title",
iconPath: mdiPuzzle,
iconColor: "#2D338F",
adminOnly: true,
},
],
lovelace: [
{
component: "lovelace",
path: "/config/lovelace/dashboards",
translationKey: "ui.panel.config.lovelace.caption",
iconPath: mdiViewDashboard,
iconColor: "#B1345C",
adminOnly: true,
},
],
persons: [
{
component: "person",
path: "/config/person",
translationKey: "ui.panel.config.person.caption",
iconPath: mdiAccount,
iconColor: "#5A87FA",
adminOnly: true,
},
{
component: "users",
path: "/config/users",
translationKey: "ui.panel.config.users.caption",
iconPath: mdiBadgeAccountHorizontal,
iconColor: "#5A87FA",
core: true,
adminOnly: true,
},
],
areas: [
{
component: "areas",
path: "/config/areas",
translationKey: "ui.panel.config.areas.caption",
iconPath: mdiSofa,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "labels",
path: "/config/labels",
translationKey: "ui.panel.config.labels.caption",
iconPath: mdiLabel,
iconColor: "#2D338F",
core: true,
adminOnly: true,
},
{
component: "zone",
path: "/config/zone",
translationKey: "ui.panel.config.zone.caption",
iconPath: mdiMapMarkerRadius,
iconColor: "#E48629",
adminOnly: true,
},
],
general: [
{
path: "/config/general",
translationKey: "core",
iconPath: mdiCog,
iconColor: "#653249",
core: true,
adminOnly: true,
},
{
path: "/config/updates",
translationKey: "updates",
iconPath: mdiUpdate,
iconColor: "#3B808E",
adminOnly: true,
},
{
path: "/config/repairs",
translationKey: "repairs",
iconPath: mdiScrewdriver,
iconColor: "#5c995c",
adminOnly: true,
},
{
component: "logs",
path: "/config/logs",
translationKey: "logs",
iconPath: mdiTextBoxOutline,
iconColor: "#C65326",
core: true,
adminOnly: true,
},
{
path: "/config/backup",
translationKey: "backup",
iconPath: mdiBackupRestore,
iconColor: "#0D47A1",
component: "backup",
adminOnly: true,
},
{
path: "/config/analytics",
translationKey: "analytics",
iconPath: mdiShape,
iconColor: "#f1c447",
adminOnly: true,
},
{
path: "/config/ai-tasks",
translationKey: "ai_tasks",
iconPath: mdiStarFourPoints,
iconColor: "#8B69E3",
core: true,
adminOnly: true,
},
{
path: "/config/labs",
translationKey: "labs",
iconPath: mdiFlask,
iconColor: "#b1b134",
core: true,
adminOnly: true,
},
{
path: "/config/network",
translationKey: "network",
iconPath: mdiNetwork,
iconColor: "#B1345C",
adminOnly: true,
},
{
path: "/config/storage",
translationKey: "storage",
iconPath: mdiDatabase,
iconColor: "#518C43",
component: "hassio",
adminOnly: true,
},
{
path: "/config/hardware",
translationKey: "hardware",
iconPath: mdiMemory,
iconColor: "#301A8E",
component: ["hassio", "hardware"],
adminOnly: true,
},
],
about: [
{
component: "info",
path: "/config/info",
translationKey: "ui.panel.config.info.caption",
iconPath: mdiInformationOutline,
iconColor: "#4A5963",
core: true,
adminOnly: true,
},
],
};
-2
View File
@@ -99,7 +99,6 @@ export class AITaskPref extends LitElement {
</span>
<ha-entity-picker
data-name="gen_data_entity_id"
.hass=${this.hass}
.disabled=${this._prefs === undefined &&
isComponentLoaded(this.hass.config, "ai_task")}
.value=${this._gen_data_entity_id ||
@@ -119,7 +118,6 @@ export class AITaskPref extends LitElement {
</span>
<ha-entity-picker
data-name="gen_image_entity_id"
.hass=${this.hass}
.disabled=${this._prefs === undefined &&
isComponentLoaded(this.hass.config, "ai_task")}
.value=${this._gen_image_entity_id ||
@@ -30,7 +30,7 @@ import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import "../components/ha-config-navigation-list";
import "../ha-config-section";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
@customElement("ha-config-system-navigation")
class HaConfigSystemNavigation extends LitElement {
@@ -43,7 +43,7 @@ import { documentationUrl } from "../../../util/documentation-url";
import { isMac } from "../../../util/is_mac";
import { isMobileClient } from "../../../util/is_mobile";
import "../ha-config-section";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import "../repairs/ha-config-repairs";
import "./ha-config-navigation";
import "./ha-config-updates";
@@ -50,7 +50,6 @@ class HaPanelDevDebug extends SubscribeMixin(LitElement) {
>
<div class="card-content">
<ha-entity-picker
.hass=${this.hass}
.helper=${this.hass.localize(
"ui.panel.config.developer-tools.tabs.debug.entity_diagnostic.description"
)}
@@ -220,7 +220,6 @@ class HaPanelDevState extends LitElement {
<div class="inputs">
<ha-entity-picker
autofocus
.hass=${this.hass}
.value=${this._entityId}
@value-changed=${this._entityIdChanged}
show-entity-id

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