Compare commits

..

79 Commits

Author SHA1 Message Date
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 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
70 changed files with 2244 additions and 1029 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:
+6 -10
View File
@@ -189,7 +189,7 @@ jobs:
name: Report
needs: [e2e-local]
runs-on: ubuntu-latest
if: always()
if: ${{ !cancelled() }}
permissions:
contents: read
pull-requests: write
@@ -229,16 +229,12 @@ jobs:
path: test/e2e/reports/combined/
retention-days: 14
- name: Post report link to PR
- 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 runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `## Playwright E2E tests failed\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
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 });
+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
}
}
+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();
+2 -2
View File
@@ -1,7 +1,7 @@
import fs from "fs";
import { glob } from "glob";
import gulp from "gulp";
import { load as loadYaml } from "js-yaml";
import yaml from "js-yaml";
import { marked } from "marked";
import path from "path";
import paths from "../paths.cjs";
@@ -47,7 +47,7 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
if (descriptionContent.startsWith("---")) {
const metadataEnd = descriptionContent.indexOf("---", 3);
metadata = loadYaml(descriptionContent.substring(3, metadataEnd));
metadata = yaml.load(descriptionContent.substring(3, metadataEnd));
descriptionContent = descriptionContent
.substring(metadataEnd + 3)
.trim();
@@ -24,11 +24,16 @@ const getMinifier = () => {
// (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,
};
+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(
+36 -4
View File
@@ -1,11 +1,43 @@
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 empty so developer tools assist
// tab loads without errors.
// 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: null,
pipelines,
preferred_pipeline: "01home_assistant_cloud",
}));
// Stub for assist pipeline run — immediately sends run-end event so
+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,
+6
View File
@@ -240,6 +240,12 @@ export default tseslint.config(
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);
@@ -248,7 +248,7 @@ class DemoThermostatEntity extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot, {}, false, true);
const hass = provideHass(this._demoRoot);
hass.updateTranslations(null, "en");
hass.updateTranslations("lovelace", "en");
hass.addEntities(ENTITIES);
+1 -1
View File
@@ -151,7 +151,7 @@ class DemoMoreInfoClimate extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot, {}, false, true);
const hass = provideHass(this._demoRoot);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
+1 -1
View File
@@ -54,7 +54,7 @@ class DemoMoreInfoHumidifier extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot, {}, false, true);
const hass = provideHass(this._demoRoot);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
+5 -3
View File
@@ -23,6 +23,7 @@
"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",
"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",
@@ -102,7 +103,7 @@
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.5",
"intl-messageformat": "11.2.8",
"js-yaml": "5.0.0",
"js-yaml": "4.2.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -144,16 +145,17 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.60.0",
"@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",
"@types/culori": "4.0.1",
"@types/html-minifier-terser": "7.0.2",
"@types/js-yaml": "4.0.9",
"@types/leaflet": "1.9.21",
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
+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"
@@ -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;
+3 -2
View File
@@ -1,5 +1,5 @@
import type { Schema } from "js-yaml";
import { dump, load, YAML11_SCHEMA } from "js-yaml";
import { DEFAULT_SCHEMA, dump, load } from "js-yaml";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -30,7 +30,7 @@ const isEmpty = (obj: Record<string, unknown>): boolean => {
export class HaYamlEditor extends LitElement {
@property() public value?: any;
@property({ attribute: false }) public yamlSchema: Schema = YAML11_SCHEMA;
@property({ attribute: false }) public yamlSchema: Schema = DEFAULT_SCHEMA;
@property({ attribute: false }) public defaultValue?: any;
@@ -70,6 +70,7 @@ export class HaYamlEditor extends LitElement {
this._yaml = !isEmpty(value)
? dump(value, {
schema: this.yamlSchema,
quotingType: '"',
noRefs: true,
})
: "";
+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 =
+9 -4
View File
@@ -1,4 +1,4 @@
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";
@@ -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: {},
+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"
+8 -6
View File
@@ -1,4 +1,4 @@
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";
@@ -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: {},
+53 -18
View File
@@ -9,11 +9,17 @@ 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";
@@ -97,11 +103,15 @@ export const provideHass = (
elements,
overrideData: Partial<HomeAssistant> = {},
setHassProperty = false,
// Opt-in to providing the grouped Lit contexts (config, formatters, api, …)
// that the real app's root element provides via `contextMixin`. Needed for
// gallery demos that render context-consuming components (e.g. the climate
// temperature control) without the full app shell.
provideContexts = 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.
@@ -128,21 +138,46 @@ export const provideHass = (
}
: 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) {
return;
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]
);
});
}
(
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
)
);
});
};
const wsCommands = {};
@@ -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")
@@ -1,5 +1,5 @@
import { mdiDotsVertical } from "@mdi/js";
import { defineScalarTag, YAML11_SCHEMA } from "js-yaml";
import { DEFAULT_SCHEMA, Type } from "js-yaml";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -47,11 +47,12 @@ const SUPPORTED_UI_TYPES = [
"schema",
];
const secretTag = defineScalarTag("!secret", {
resolve: (data) => `!secret ${data}`,
});
const ADDON_YAML_SCHEMA = YAML11_SCHEMA.withTags(secretTag);
const ADDON_YAML_SCHEMA = DEFAULT_SCHEMA.extend([
new Type("!secret", {
kind: "scalar",
construct: (data) => `!secret ${data}`,
}),
]);
const MASKED_FIELDS = ["password", "secret", "token"];
@@ -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,
@@ -192,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")
@@ -683,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>`
@@ -795,12 +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`
)}
.tooltipDescription=${this._tab === "targets"}
.tooltipDescription=${this._tab === "targets" &&
!this._selectedGroup}
.target=${(this._tab === "targets" &&
this._selectedTarget &&
([
@@ -960,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(
@@ -1018,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();
@@ -1059,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[] = [];
@@ -1089,7 +1115,8 @@ class DialogAddAutomationElement
triggerDescriptions,
manifests,
domains,
types
types,
exclusiveDomains
)
);
@@ -1108,7 +1135,8 @@ class DialogAddAutomationElement
conditionDescriptions,
manifests,
domains,
types
types,
exclusiveDomains
)
);
@@ -1141,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) {
@@ -1240,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) {
@@ -1364,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 [];
@@ -1374,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);
@@ -1436,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 [];
@@ -1446,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);
@@ -1706,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,
@@ -1965,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(
{
@@ -1986,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,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;
}
`;
}
@@ -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,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { load, YAML11_SCHEMA } from "js-yaml";
import { load } from "js-yaml";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
@@ -224,7 +224,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
let loaded: any;
try {
loaded = load(paste, { schema: YAML11_SCHEMA });
loaded = load(paste);
} catch (_err: any) {
showEditorToast(this, {
message: this.hass.localize(
@@ -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,
},
],
};
@@ -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";
@@ -93,7 +93,7 @@ import {
getLabelsTableColumn,
getModifiedAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import "../integrations/ha-integration-overflow-menu";
import { showAddIntegrationDialog } from "../integrations/show-add-integration-dialog";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
@@ -118,7 +118,7 @@ import {
getLabelsTableColumn,
getModifiedAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import type { Helper } from "../helpers/const";
import { isHelperDomain } from "../helpers/const";
import "../integrations/ha-integration-overflow-menu";
-554
View File
@@ -1,43 +1,5 @@
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 type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { CloudStatus } from "../../data/cloud";
@@ -48,7 +10,6 @@ import {
} from "../../data/entity/entity_registry";
import type { RouterOptions } from "../../layouts/hass-router-page";
import { HassRouterPage } from "../../layouts/hass-router-page";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../types";
declare global {
@@ -58,521 +19,6 @@ declare global {
}
}
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,
},
],
};
@customElement("ha-panel-config")
class HaPanelConfig extends HassRouterPage {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -122,7 +122,7 @@ import {
getEntityIdTableColumn,
getLabelsTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { renderConfigEntryError } from "../integrations/ha-config-integration-page";
import "../integrations/ha-integration-overflow-menu";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
@@ -58,7 +58,7 @@ import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { isHelperDomain } from "../helpers/const";
import "./ha-config-flow-card";
import type { DataEntryFlowProgressExtended } from "./ha-config-integrations";
@@ -12,7 +12,10 @@ import type {
} from "../../../../../components/data-table/ha-data-table";
import "../../../../../components/ha-relative-time";
import { UNAVAILABLE, UNKNOWN } from "../../../../../data/entity/entity";
import type { RadioFrequencyTransmitter } from "../../../../../data/radio_frequency";
import {
formatFrequencyRanges,
type RadioFrequencyTransmitter,
} from "../../../../../data/radio_frequency";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage";
import { haStyle } from "../../../../../resources/styles";
@@ -22,6 +25,7 @@ interface RadioFrequencyTransmitterRow {
id: string;
name: string;
type: string;
frequencies: string;
last_used?: string;
device_id: string | null;
}
@@ -64,6 +68,13 @@ export class RadioFrequencyDevicesPage extends LitElement {
filterable: true,
groupable: true,
},
frequencies: {
title: localize("ui.panel.config.radio_frequency.frequencies"),
sortable: true,
filterable: true,
flex: 2,
template: (transmitter) => transmitter.frequencies || "—",
},
last_used: {
title: localize("ui.panel.config.radio_frequency.last_used"),
sortable: true,
@@ -86,7 +97,8 @@ export class RadioFrequencyDevicesPage extends LitElement {
(
transmitters: RadioFrequencyTransmitter[],
states: HomeAssistant["states"],
localize: LocalizeFunc
localize: LocalizeFunc,
locale: HomeAssistant["locale"]
): RadioFrequencyTransmitterRow[] =>
transmitters.map((transmitter) => {
const stateObj = states[transmitter.entity_id];
@@ -104,6 +116,10 @@ export class RadioFrequencyDevicesPage extends LitElement {
id: transmitter.entity_id,
name: stateObj ? computeStateName(stateObj) : transmitter.entity_id,
type: localize("component.radio_frequency.entity_component._.name"),
frequencies: formatFrequencyRanges(
transmitter.supported_frequency_ranges,
locale
),
last_used,
device_id: transmitter.device_id,
};
@@ -122,7 +138,8 @@ export class RadioFrequencyDevicesPage extends LitElement {
.data=${this._data(
this.transmitters,
this.hass.states,
this.hass.localize
this.hass.localize,
this.hass.locale
)}
.noDataText=${this.hass.localize(
"ui.panel.config.radio_frequency.no_devices"
+1 -1
View File
@@ -57,7 +57,7 @@ import {
getCreatedAtTableColumn,
getModifiedAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { showLabelDetailDialog } from "./show-dialog-label-detail";
type ConfigTranslationKey = FlattenObjectKeys<
+1 -1
View File
@@ -27,7 +27,7 @@ import "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import "../ha-config-section";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import {
loadPersonDetailDialog,
showPersonDetailDialog,
@@ -108,7 +108,7 @@ import {
getLabelsTableColumn,
renderRelativeTimeColumn,
} 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 -1
View File
@@ -113,7 +113,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,5 +1,5 @@
import { mdiHelpCircleOutline } from "@mdi/js";
import { load, YAML11_SCHEMA } from "js-yaml";
import { load } from "js-yaml";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, query, queryAll } from "lit/decorators";
@@ -197,7 +197,7 @@ export class HaManualScriptEditor extends ManualEditorMixin<ScriptConfig>(
let loaded: any;
try {
loaded = load(paste, { schema: YAML11_SCHEMA });
loaded = load(paste);
} catch (_err: any) {
showEditorToast(this, {
message: this.hass.localize(
+1 -1
View File
@@ -37,7 +37,7 @@ import "../../../layouts/hass-tabs-subpage-data-table";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant, Route } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { showTagDetailDialog } from "./show-dialog-tag-detail";
import "./tag-image";
+1 -1
View File
@@ -23,7 +23,7 @@ import {
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { showAddUserDialog } from "./show-dialog-add-user";
import { showUserDetailDialog } from "./show-dialog-user-detail";
import { storage } from "../../../common/decorators/storage";
+1 -1
View File
@@ -43,7 +43,7 @@ import "../../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant, Route } from "../../../types";
import "../ha-config-section";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import { showHomeZoneDetailDialog } from "./show-dialog-home-zone-detail";
import { showZoneDetailDialog } from "./show-dialog-zone-detail";
+70 -23
View File
@@ -27,6 +27,7 @@ import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { UndoRedoController } from "../../common/controllers/undo-redo-controller";
import { fireEvent } from "../../common/dom/fire_event";
import { isNavigationClick } from "../../common/dom/is-navigation-click";
import { goBack, navigate } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
@@ -472,6 +473,22 @@ class HUIRoot extends LitElement {
const title_only = !icon_only && !icon_and_title;
const hidden =
!this._editMode && (view.subview || _isTabHiddenForUser(view));
const tabContent = html`
${icon_only || icon_and_title
? html`<ha-icon
class=${classMap({
"child-view-icon": Boolean(view.subview),
})}
title=${ifDefined(view.title)}
.icon=${view.icon}
></ha-icon>`
: nothing}
${icon_and_title ? view.title : nothing}
${title_only
? view.title ||
this.hass.localize("ui.panel.lovelace.views.unnamed_view")
: nothing}
`;
return html`
<ha-tab-group-tab
slot="nav"
@@ -479,6 +496,9 @@ class HUIRoot extends LitElement {
.active=${this._curView === index}
.disabled=${hidden}
aria-label=${ifDefined(view.title)}
data-path=${view.path || index}
@auxclick=${this._handleViewTabNewTabClick}
@click=${this._handleViewTabNewTabClick}
class=${classMap({
"icon-only": Boolean(icon_only),
"icon-and-title": Boolean(icon_and_title),
@@ -495,24 +515,7 @@ class HUIRoot extends LitElement {
@click=${this._moveViewLeft}
.disabled=${this._curView === 0}
></ha-icon-button-arrow-prev>
`
: nothing}
${icon_only || icon_and_title
? html`<ha-icon
class=${classMap({
"child-view-icon": Boolean(view.subview),
})}
title=${ifDefined(view.title)}
.icon=${view.icon}
></ha-icon>`
: nothing}
${icon_and_title ? view.title : nothing}
${title_only
? view.title ||
this.hass.localize("ui.panel.lovelace.views.unnamed_view")
: nothing}
${this._editMode
? html`
${tabContent}
<ha-icon-button
.title=${this.hass!.localize(
"ui.panel.lovelace.editor.edit_view.edit"
@@ -530,7 +533,7 @@ class HUIRoot extends LitElement {
.disabled=${(this._curView! as number) + 1 === views.length}
></ha-icon-button-arrow-next>
`
: nothing}
: tabContent}
</ha-tab-group-tab>
`;
})}
@@ -571,7 +574,8 @@ class HUIRoot extends LitElement {
? html`
<ha-icon-button-arrow-prev
slot="navigationIcon"
@click=${this._goBack}
.href=${this._backPath}
@click=${this._handleBackClick}
></ha-icon-button-arrow-prev>
`
: html`
@@ -882,6 +886,27 @@ class HUIRoot extends LitElement {
}
}
private _handleBackClick(ev: MouseEvent): void {
if (this._backPath && !isNavigationClick(ev)) {
return;
}
this._goBack();
}
private get _backPath(): string | undefined {
const views = this.lovelace?.config.views ?? [];
const curViewConfig =
typeof this._curView === "number" ? views[this._curView] : undefined;
if (curViewConfig?.back_path != null) {
return curViewConfig.back_path;
}
if (this.backPath) {
return this.backPath;
}
return curViewConfig?.subview ? this.route!.prefix : undefined;
}
private _addDevice = async () => {
await this.hass.loadFragmentTranslation("config");
showAddIntegrationDialog(this, { navigateToResult: true });
@@ -1071,9 +1096,7 @@ class HUIRoot extends LitElement {
}
private _navigateToView(path: string | number, replace?: boolean) {
const url = this.lovelace!.editMode
? `${this.route!.prefix}/${path}?${addSearchParam({ edit: "1" })}`
: `${this.route!.prefix}/${path}${location.search}`;
const url = this._viewUrl(path);
const currentUrl = `${location.pathname}${location.search}`;
if (currentUrl !== url) {
@@ -1081,6 +1104,30 @@ class HUIRoot extends LitElement {
}
}
private _viewUrl(path: string | number): string {
return this.lovelace!.editMode
? `${this.route!.prefix}/${path}?${addSearchParam({ edit: "1" })}`
: `${this.route!.prefix}/${path}${location.search}`;
}
private _handleViewTabNewTabClick(ev: MouseEvent): void {
if (
this._editMode ||
(ev.button !== 1 && !ev.metaKey && !ev.ctrlKey && !ev.shiftKey)
) {
return;
}
ev.preventDefault();
ev.stopPropagation();
const tab = ev.currentTarget as HTMLElement;
const path = tab.dataset.path;
if (path) {
window.open(this._viewUrl(path), "_blank", "noreferrer");
}
}
private _editView() {
showEditViewDialog(this, {
lovelace: this.lovelace!,
+11 -1
View File
@@ -56,8 +56,18 @@ export { tags } from "@lezer/highlight";
const _yamlWithJinja = jinja({ base: yaml() });
// The jinja2 mode is rendered on a YAML base, whose line comment is "#". In a
// template that is meaningless, so toggle-comment (Ctrl+/) should use the Jinja
// block comment "{# #}" instead. Scope this to the jinja2 language only so the
// plain YAML mode keeps its "#" comment.
const _jinjaCommentTokens = Prec.highest(
EditorState.languageData.of(() => [
{ commentTokens: { block: { open: "{#", close: "#}" } } },
])
);
export const langs = {
jinja2: _yamlWithJinja,
jinja2: [_yamlWithJinja, _jinjaCommentTokens],
yaml: _yamlWithJinja,
};
+12
View File
@@ -3,6 +3,7 @@ import { css, unsafeCSS } from "lit";
export const fontStyles = css`
@font-face {
font-family: "Roboto";
font-display: swap;
src:
local("Roboto Thin"),
local("Roboto-Thin"),
@@ -13,6 +14,7 @@ export const fontStyles = css`
}
@font-face {
font-family: "Roboto";
font-display: swap;
src:
local("Roboto Thin Italic"),
local("Roboto-ThinItalic"),
@@ -23,6 +25,7 @@ export const fontStyles = css`
}
@font-face {
font-family: "Roboto";
font-display: swap;
src:
local("Roboto Light"),
local("Roboto-Light"),
@@ -33,6 +36,7 @@ export const fontStyles = css`
}
@font-face {
font-family: "Roboto";
font-display: swap;
src:
local("Roboto Light Italic"),
local("Roboto-LightItalic"),
@@ -43,6 +47,7 @@ export const fontStyles = css`
}
@font-face {
font-family: "Roboto";
font-display: swap;
src:
local("Roboto Regular"),
local("Roboto-Regular"),
@@ -53,6 +58,7 @@ export const fontStyles = css`
}
@font-face {
font-family: "Roboto";
font-display: swap;
src:
local("Roboto Italic"),
local("Roboto-Italic"),
@@ -63,6 +69,7 @@ export const fontStyles = css`
}
@font-face {
font-family: "Roboto";
font-display: swap;
src:
local("Roboto Medium"),
local("Roboto-Medium"),
@@ -73,6 +80,7 @@ export const fontStyles = css`
}
@font-face {
font-family: "Roboto";
font-display: swap;
src:
local("Roboto Medium Italic"),
local("Roboto-MediumItalic"),
@@ -83,6 +91,7 @@ export const fontStyles = css`
}
@font-face {
font-family: "Roboto";
font-display: swap;
src:
local("Roboto Bold"),
local("Roboto-Bold"),
@@ -93,6 +102,7 @@ export const fontStyles = css`
}
@font-face {
font-family: "Roboto";
font-display: swap;
src:
local("Roboto Bold Italic"),
local("Roboto-BoldItalic"),
@@ -103,6 +113,7 @@ export const fontStyles = css`
}
@font-face {
font-family: "Roboto";
font-display: swap;
src:
local("Roboto Black"),
local("Roboto-Black"),
@@ -113,6 +124,7 @@ export const fontStyles = css`
}
@font-face {
font-family: "Roboto";
font-display: swap;
src:
local("Roboto Black Italic"),
local("Roboto-BlackItalic"),
+1 -1
View File
@@ -1,6 +1,6 @@
import type { PropertyValues } from "lit";
import { getConfigSubpageTitle, getPanelTitleFromUrlPath } from "../data/panel";
import { configSections } from "../panels/config/ha-panel-config";
import { configSections } from "../panels/config/config-sections";
import type { Constructor, HomeAssistant } from "../types";
import type { HassBaseEl } from "./hass-base-mixin";
+16 -4
View File
@@ -5107,6 +5107,7 @@
"select_target": "Select a target",
"home": "Home",
"unassigned": "Unassigned",
"time_sun": "Time and sun",
"blocks": "Blocks",
"tabs": {
"target": "By target",
@@ -5182,8 +5183,11 @@
"entity": {
"label": "Entity"
},
"time_location": {
"label": "Time and location"
"time": {
"label": "Time"
},
"sun": {
"label": "Sun"
},
"generic": {
"label": "Generic"
@@ -5436,6 +5440,10 @@
"invalid_condition": "Invalid condition configuration",
"validation_failed": "Condition validation failed",
"test_failed": "Error occurred while testing condition",
"duration_priming": {
"entity_not_recorded": "One or more of the selected entities aren''t being recorded, so their history can''t be used. After a restart or reload, this condition only becomes true once they''ve been in the matching state for the full duration.",
"history_capped": "Only the last {hours} hours of history are checked. For longer durations, after a restart or reload this condition only becomes true once the entities have been in the matching state for the full duration."
},
"duplicate": "[%key:ui::common::duplicate%]",
"re_order": "[%key:ui::panel::config::automation::editor::triggers::re_order%]",
"rename": "[%key:ui::panel::config::automation::editor::triggers::rename%]",
@@ -5460,8 +5468,11 @@
"entity": {
"label": "Entity"
},
"time_location": {
"label": "Time and location"
"time": {
"label": "Time"
},
"sun": {
"label": "Sun"
},
"generic": {
"label": "Generic"
@@ -7157,6 +7168,7 @@
"loading_error": "Failed to load radio frequency devices",
"name": "Name",
"type": "Type",
"frequencies": "Frequencies",
"last_used": "Last used",
"devices_navigation": "Devices"
},
@@ -1,6 +1,8 @@
import { assert, describe, it } from "vitest";
import durationToSeconds from "../../../src/common/datetime/duration_to_seconds";
import durationToSeconds, {
durationDataToSeconds,
} from "../../../src/common/datetime/duration_to_seconds";
describe("durationToSeconds", () => {
it("works", () => {
@@ -8,3 +10,23 @@ describe("durationToSeconds", () => {
assert.strictEqual(durationToSeconds("11:01:05"), 39665);
});
});
describe("durationDataToSeconds", () => {
it("sums all duration fields", () => {
assert.strictEqual(
durationDataToSeconds({
days: 1,
hours: 2,
minutes: 3,
seconds: 4,
milliseconds: 500,
}),
93784.5
);
});
it("treats missing fields as zero", () => {
assert.strictEqual(durationDataToSeconds({}), 0);
assert.strictEqual(durationDataToSeconds({ hours: 6 }), 21600);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
formatFrequency,
formatFrequencyRange,
formatFrequencyRanges,
} from "../../src/data/radio_frequency";
import {
DateFormat,
FirstWeekday,
type FrontendLocaleData,
NumberFormat,
TimeFormat,
TimeZone,
} from "../../src/data/translation";
const locale: FrontendLocaleData = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.local,
first_weekday: FirstWeekday.language,
};
describe("formatFrequency", () => {
it("picks the largest unit that keeps the value >= 1", () => {
expect(formatFrequency(50, locale)).toBe("50 Hz");
expect(formatFrequency(2400, locale)).toBe("2.4 kHz");
expect(formatFrequency(433_920_000, locale)).toBe("433.92 MHz");
expect(formatFrequency(2_400_000_000, locale)).toBe("2.4 GHz");
});
it("rounds to at most three fractional digits", () => {
expect(formatFrequency(868_300_000, locale)).toBe("868.3 MHz");
expect(formatFrequency(123_456_789, locale)).toBe("123.457 MHz");
});
});
describe("formatFrequencyRange", () => {
it("collapses a range to a single value when min equals max", () => {
expect(formatFrequencyRange([433_920_000, 433_920_000], locale)).toBe(
"433.92 MHz"
);
});
it("formats a range with both bounds", () => {
expect(formatFrequencyRange([863_000_000, 870_000_000], locale)).toBe(
"863 MHz 870 MHz"
);
});
});
describe("formatFrequencyRanges", () => {
it("joins multiple ranges with commas", () => {
expect(
formatFrequencyRanges(
[
[433_920_000, 433_920_000],
[868_000_000, 870_000_000],
],
locale
)
).toBe("433.92 MHz, 868 MHz 870 MHz");
});
it("returns an empty string for no ranges", () => {
expect(formatFrequencyRanges([], locale)).toBe("");
});
});
+3 -1
View File
@@ -66,7 +66,9 @@ export class HaTest 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(
+4 -17
View File
@@ -54,29 +54,16 @@ async function assertPageLoads(page: Page, hash: string, selector: string) {
}
// Errors that are gallery-harness artifacts rather than bugs in the component
// under test. The gallery feeds demos a synchronous mock `hass`, but migrated
// components now read `localize`/formatters from Lit context, which resolves
// asynchronously — so a demo can render one frame before the context lands and
// throw a transient "undefined" error during init. These don't prevent the
// demo from rendering (the toBeAttached check above still has to pass), and
// they're timing-dependent, so we filter the whole init-error family here.
// under test. The Lit-context init-error family that used to live here is gone:
// ha-gallery now provides fallback contexts for every demo (mirroring the real
// app's root), so context-consuming components resolve `localize`, formatters,
// config, etc. synchronously instead of throwing during init.
const IGNORED_ERRORS: RegExp[] = [
/ResizeObserver/,
/Non-Error/,
/Extension context/,
// Plain objects thrown by mock WebSocket/data-fetch show up as "Object".
/^Object$/,
// localize consumed from context before it resolves (`this.localize` /
// `this._localize is not a function`, or `hass.localize` read as undefined).
/_?localize is not a function/,
/Cannot read properties of undefined \(reading 'localize'\)/,
// Formatters consumed from context before it resolves.
/Cannot read properties of undefined \(reading 'format[A-Za-z]+'\)/,
// hass API methods consumed from context before it resolves (e.g. the
// update demo fetches release notes via callWS during init).
/Cannot read properties of undefined \(reading 'call(WS|Api|Service)'\)/,
// locale fields read before the mock locale is wired up.
/Cannot read properties of undefined \(reading '(time|number|date)_format'\)/,
// hui-group-entity-row calls .some() on a possibly-undefined entity_id array
// from mock state data — pre-existing gallery data issue.
/Cannot read properties of undefined \(reading 'some'\)/,
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
// Builds and posts a PR comment summarising Playwright E2E failures from the
// merged JSON report. Invoked from the `report` job in .github/workflows/e2e.yaml
// via actions/github-script:
//
// const { default: postReportComment } =
// await import("${{ github.workspace }}/test/e2e/post-report-comment.mjs");
// await postReportComment({ github, context, core });
import { readFileSync } from "fs";
const REPORT_PATH = "test/e2e/reports/combined/results.json";
// GitHub comment bodies cap at 65536 chars; leave headroom.
const MAX_BODY = 60000;
// Strip ANSI colour codes that Playwright bakes into error messages.
// eslint-disable-next-line no-control-regex
const stripAnsi = (s) => s.replace(/\u001b\[[0-9;]*m/g, "");
// Walk the JSON report tree and collect every failing spec with its error
// output, so the comment shows the actual test failures.
const collectFailures = (report) => {
const failures = [];
const walk = (suite, titlePath) => {
const here = suite.title ? [...titlePath, suite.title] : titlePath;
for (const spec of suite.specs ?? []) {
if (spec.ok) continue;
const errors = [];
for (const test of spec.tests ?? []) {
for (const result of test.results ?? []) {
for (const err of result.errors ?? []) {
if (err.message) errors.push(stripAnsi(err.message));
}
}
}
failures.push({
title: [...here, spec.title].join(" "),
location: `${spec.file ?? suite.file ?? ""}:${spec.line ?? ""}`,
errors,
});
}
for (const child of suite.suites ?? []) walk(child, here);
};
for (const suite of report.suites ?? []) walk(suite, []);
return failures;
};
const formatFailure = (failure) => {
const output =
failure.errors.join("\n\n").trim() || "(no error output captured)";
return [
`<details><summary>❌ ${failure.title} <code>${failure.location}</code></summary>`,
"",
"```ts",
output,
"```",
"",
"</details>",
].join("\n");
};
export default async function postReportComment({ github, context, core }) {
const { owner, repo } = context.repo;
const runUrl = `${context.serverUrl}/${owner}/${repo}/actions/runs/${context.runId}`;
let stats = { expected: 0, unexpected: 0, flaky: 0, skipped: 0 };
let failures = [];
try {
const report = JSON.parse(readFileSync(REPORT_PATH, "utf8"));
stats = report.stats ?? stats;
failures = collectFailures(report);
} catch (err) {
core.warning(`Could not parse Playwright JSON report: ${err.message}`);
}
const summaryLine =
`**${stats.unexpected} failed**, ${stats.expected} passed` +
(stats.flaky ? `, ${stats.flaky} flaky` : "") +
(stats.skipped ? `, ${stats.skipped} skipped` : "");
const details = failures.length
? failures.map(formatFailure).join("\n")
: "_No failing tests were captured in the report._";
let body = [
"## Playwright E2E tests failed",
"",
summaryLine,
"",
details,
"",
"The combined HTML report is available as a workflow artifact.",
"",
`[View workflow run](${runUrl})`,
].join("\n");
if (body.length > MAX_BODY) {
body = `${body.slice(0, MAX_BODY)}\n\n_…report truncated, see the full HTML report artifact._`;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: context.issue.number,
body,
});
}
+33 -36
View File
@@ -4194,14 +4194,14 @@ __metadata:
languageName: node
linkType: hard
"@playwright/test@npm:1.60.0":
version: 1.60.0
resolution: "@playwright/test@npm:1.60.0"
"@playwright/test@npm:1.61.0":
version: 1.61.0
resolution: "@playwright/test@npm:1.61.0"
dependencies:
playwright: "npm:1.60.0"
playwright: "npm:1.61.0"
bin:
playwright: cli.js
checksum: 10/a13d369014e1934b0aa484c5d59537f5249af0fe006ac4ecbcbe14c673221412706193ea2d9cf3b2c0cc69e3ddbb4daddb006f0bedfdeb05f687776ed8c35f5f
checksum: 10/359a9a4a59a87361d416818966bb810a38970d9f2b8364349d22099fb23eb043530714374a83010f9f3471c7e758df43c49bf32128745af13277adfb211aa752
languageName: node
linkType: hard
@@ -4856,7 +4856,7 @@ __metadata:
languageName: node
linkType: hard
"@rspack/dev-middleware@npm:^2.0.1":
"@rspack/dev-middleware@npm:^2.0.3":
version: 2.0.3
resolution: "@rspack/dev-middleware@npm:2.0.3"
peerDependencies:
@@ -4868,18 +4868,18 @@ __metadata:
languageName: node
linkType: hard
"@rspack/dev-server@npm:2.0.3":
version: 2.0.3
resolution: "@rspack/dev-server@npm:2.0.3"
"@rspack/dev-server@npm:2.1.0":
version: 2.1.0
resolution: "@rspack/dev-server@npm:2.1.0"
dependencies:
"@rspack/dev-middleware": "npm:^2.0.1"
"@rspack/dev-middleware": "npm:^2.0.3"
peerDependencies:
"@rspack/core": ^2.0.0-0
"@rspack/core": ^2.0.0
selfsigned: ^5.0.0
peerDependenciesMeta:
selfsigned:
optional: true
checksum: 10/39ec36029e849cb5799c5cc8041b14df2732ec701215c25408b32b8e7fd3c7341f3f237edf7969cacaf4953684bd617b91a5730e144a2f21be899ab20f271fb7
checksum: 10/402d4f96c60beaba354081a36b053c3cf7c1dda7fddab791031dc4206b9873b98437895d5a6a68247e07cb8a5922a1770143c560772dfdaa00ba90a5396251d8
languageName: node
linkType: hard
@@ -5550,6 +5550,13 @@ __metadata:
languageName: node
linkType: hard
"@types/js-yaml@npm:4.0.9":
version: 4.0.9
resolution: "@types/js-yaml@npm:4.0.9"
checksum: 10/a0ce595db8a987904badd21fc50f9f444cb73069f4b95a76cc222e0a17b3ff180669059c763ec314bc4c3ce284379177a9da80e83c5f650c6c1310cafbfaa8e6
languageName: node
linkType: hard
"@types/jsesc@npm:^2.5.0":
version: 2.5.1
resolution: "@types/jsesc@npm:2.5.1"
@@ -9741,11 +9748,11 @@ __metadata:
"@octokit/auth-oauth-device": "npm:8.0.3"
"@octokit/plugin-retry": "npm:8.1.0"
"@octokit/rest": "npm:22.0.1"
"@playwright/test": "npm:1.60.0"
"@playwright/test": "npm:1.61.0"
"@replit/codemirror-indentation-markers": "npm:6.5.3"
"@rsdoctor/rspack-plugin": "npm:1.5.15"
"@rspack/core": "npm:2.0.8"
"@rspack/dev-server": "npm:2.0.3"
"@rspack/dev-server": "npm:2.1.0"
"@swc/helpers": "npm:0.5.23"
"@thomasloven/round-slider": "npm:0.6.0"
"@tsparticles/engine": "npm:4.2.1"
@@ -9756,6 +9763,7 @@ __metadata:
"@types/color-name": "npm:2.0.0"
"@types/culori": "npm:4.0.1"
"@types/html-minifier-terser": "npm:7.0.2"
"@types/js-yaml": "npm:4.0.9"
"@types/leaflet": "npm:1.9.21"
"@types/leaflet-draw": "npm:1.0.13"
"@types/leaflet.markercluster": "npm:1.5.6"
@@ -9811,7 +9819,7 @@ __metadata:
husky: "npm:9.1.7"
idb-keyval: "npm:6.2.5"
intl-messageformat: "npm:11.2.8"
js-yaml: "npm:5.0.0"
js-yaml: "npm:4.2.0"
jsdom: "npm:29.1.1"
jszip: "npm:3.10.1"
leaflet: "npm:1.9.4"
@@ -10754,18 +10762,7 @@ __metadata:
languageName: node
linkType: hard
"js-yaml@npm:5.0.0":
version: 5.0.0
resolution: "js-yaml@npm:5.0.0"
dependencies:
argparse: "npm:^2.0.1"
bin:
js-yaml: bin/js-yaml.mjs
checksum: 10/51691da5a1b3894ffdd6ffe386d061fecf8769d135560dce8f3d3b7c5f7b167ec7fbe2c232c91135d58216b2f6ba215e85ac60fe569c46f0a9024f2ff6dec47e
languageName: node
linkType: hard
"js-yaml@npm:^4.1.0":
"js-yaml@npm:4.2.0, js-yaml@npm:^4.1.0":
version: 4.2.0
resolution: "js-yaml@npm:4.2.0"
dependencies:
@@ -12603,27 +12600,27 @@ __metadata:
languageName: node
linkType: hard
"playwright-core@npm:1.60.0":
version: 1.60.0
resolution: "playwright-core@npm:1.60.0"
"playwright-core@npm:1.61.0":
version: 1.61.0
resolution: "playwright-core@npm:1.61.0"
bin:
playwright-core: cli.js
checksum: 10/66c0f83d627e673261c848dd6fe1f2856d5b5b6859268acb61a45c00f5bf926e596a351a9c481a3a4e82a45022c7c6b5d99ebc3906fc6876ac582ed6f7e16190
checksum: 10/e7ac4b2ef1c5f1701ec8086e47bc341988a3f753009d450e2a62daab5ade1fa943f1ef7fb48c721618dd7bd42667a11ff73c2e5dbd0674c20a3397b3c5be2f61
languageName: node
linkType: hard
"playwright@npm:1.60.0":
version: 1.60.0
resolution: "playwright@npm:1.60.0"
"playwright@npm:1.61.0":
version: 1.61.0
resolution: "playwright@npm:1.61.0"
dependencies:
fsevents: "npm:2.3.2"
playwright-core: "npm:1.60.0"
playwright-core: "npm:1.61.0"
dependenciesMeta:
fsevents:
optional: true
bin:
playwright: cli.js
checksum: 10/8569770637ee35d08cca3b53a5b56c21e9236bd1ac4718456d5988fb8acd51c5b3cc2cf90748363a36199529e870a9b6c68d6fc9e19571261cd867005d6331c1
checksum: 10/b5faf97391315334a30e88e03216fd6090988b99896e71b341995a27c49d49dcebc825ec389dabc9b2d2cab6368c418cad9cbb925a88de35fb3b26a19bf05bea
languageName: node
linkType: hard