Compare commits

...

15 Commits

Author SHA1 Message Date
Aidan Timson 7a607a63c5 Add test:e2e:app:dev 2026-06-25 12:09:40 +01:00
Aidan Timson 39dc34c688 Stop browser open 2026-06-25 12:07:45 +01:00
Aidan Timson dc2b2b44d6 Add test:e2e:app:dev to not need to build for every test run 2026-06-25 12:07:31 +01:00
Franck Nijhof 27fbabb71b Use choose selector for legacy trigger fields (#52859)
* Use choose selector for legacy trigger fields

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

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

Add translation entries for the choose selector toggle button labels.

* Shorten the numeric state value toggle label

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


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

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

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

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

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

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

---------

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

* rename

* change to warning icon with tooltip

* review

* Update duration_to_seconds.test.ts
2026-06-24 16:00:23 +02:00
63 changed files with 2146 additions and 1202 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:
+5 -9
View File
@@ -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();
+1
View File
@@ -240,6 +240,7 @@ gulp.task("rspack-dev-server-e2e-test-app", () =>
),
contentBase: paths.e2eTestApp_output_root,
port: 8095,
open: false,
})
);
@@ -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);
}
+4 -2
View File
@@ -23,10 +23,12 @@
"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",
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:app:dev": "test/e2e/app/script/develop_app",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
@@ -144,10 +146,10 @@
"@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",
@@ -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;
+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"
+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")
@@ -54,7 +54,7 @@ import {
import "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { showToast } from "../../../util/toast";
import { configSections } from "../ha-panel-config";
import { configSections } from "../config-sections";
import {
loadAreaRegistryDetailDialog,
showAreaRegistryDetailDialog,
@@ -1,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,17 +1,189 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { createDurationData } from "../../../../../common/datetime/create_duration_data";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { hasTemplate } from "../../../../../common/string/has-template";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import type { NumericStateTrigger } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
const SCHEMA = [
{
name: "entity_id",
required: true,
selector: { entity: { multiple: true } },
},
{
name: "attribute",
context: { filter_entity: "entity_id" },
selector: {
attribute: {
hide_attributes: [
"access_token",
"auto_update",
"available_modes",
"away_mode",
"changed_by",
"code_arm_required",
"code_format",
"color_mode",
"color_modes",
"current_activity",
"device_class",
"editable",
"effect_list",
"effect",
"entity_id",
"entity_picture",
"event_type",
"event_types",
"fan_mode",
"fan_modes",
"fan_speed_list",
"forecast",
"friendly_name",
"frontend_stream_type",
"has_date",
"has_time",
"hs_color",
"hvac_mode",
"hvac_modes",
"icon",
"id",
"latest_version",
"max_color_temp_kelvin",
"max_mireds",
"max_temp",
"media_album_name",
"media_artist",
"media_content_type",
"media_position_updated_at",
"media_title",
"min_color_temp_kelvin",
"min_mireds",
"min_temp",
"mode",
"next_dawn",
"next_dusk",
"next_midnight",
"next_noon",
"next_rising",
"next_setting",
"operation_list",
"operation_mode",
"options",
"percentage_step",
"precipitation_unit",
"preset_mode",
"preset_modes",
"pressure_unit",
"release_notes",
"release_summary",
"release_url",
"restored",
"rgb_color",
"rgbw_color",
"shuffle",
"skipped_version",
"sound_mode_list",
"sound_mode",
"source_list",
"source_type",
"source",
"state_class",
"step",
"supported_color_modes",
"supported_features",
"swing_mode",
"swing_modes",
"target_temp_step",
"temperature_unit",
"title",
"token",
"unit_of_measurement",
"user_id",
"uuid",
"visibility_unit",
"wind_speed_unit",
"xy_color",
],
},
},
},
{
name: "above",
selector: {
choose: {
translation_key:
"ui.panel.config.automation.editor.triggers.type.numeric_state.threshold_type",
choices: {
value: {
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
input: {
selector: {
entity: { domain: ["input_number", "number", "sensor"] },
},
},
},
},
},
},
{
name: "below",
selector: {
choose: {
translation_key:
"ui.panel.config.automation.editor.triggers.type.numeric_state.threshold_type",
choices: {
value: {
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
input: {
selector: {
entity: { domain: ["input_number", "number", "sensor"] },
},
},
},
},
},
},
{
name: "value_template",
selector: { template: {} },
},
{
name: "for",
selector: {
choose: {
translation_key:
"ui.panel.config.automation.editor.triggers.type.numeric_state.for_type",
choices: {
duration: { selector: { duration: {} } },
template: { selector: { template: {} } },
},
},
},
},
] as const;
@customElement("ha-automation-trigger-numeric_state")
export class HaNumericStateTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -20,236 +192,6 @@ export class HaNumericStateTrigger extends LitElement {
@property({ type: Boolean }) public disabled = false;
@state() private _inputAboveIsEntity?: boolean;
@state() private _inputBelowIsEntity?: boolean;
private _schema = memoizeOne(
(
localize: LocalizeFunc,
inputAboveIsEntity?: boolean,
inputBelowIsEntity?: boolean
) =>
[
{
name: "entity_id",
required: true,
selector: { entity: { multiple: true } },
},
{
name: "attribute",
context: { filter_entity: "entity_id" },
selector: {
attribute: {
hide_attributes: [
"access_token",
"auto_update",
"available_modes",
"away_mode",
"changed_by",
"code_arm_required",
"code_format",
"color_mode",
"color_modes",
"current_activity",
"device_class",
"editable",
"effect_list",
"effect",
"entity_id",
"entity_picture",
"event_type",
"event_types",
"fan_mode",
"fan_modes",
"fan_speed_list",
"forecast",
"friendly_name",
"frontend_stream_type",
"has_date",
"has_time",
"hs_color",
"hvac_mode",
"hvac_modes",
"icon",
"id",
"latest_version",
"max_color_temp_kelvin",
"max_mireds",
"max_temp",
"media_album_name",
"media_artist",
"media_content_type",
"media_position_updated_at",
"media_title",
"min_color_temp_kelvin",
"min_mireds",
"min_temp",
"mode",
"next_dawn",
"next_dusk",
"next_midnight",
"next_noon",
"next_rising",
"next_setting",
"operation_list",
"operation_mode",
"options",
"percentage_step",
"precipitation_unit",
"preset_mode",
"preset_modes",
"pressure_unit",
"release_notes",
"release_summary",
"release_url",
"restored",
"rgb_color",
"rgbw_color",
"shuffle",
"skipped_version",
"sound_mode_list",
"sound_mode",
"source_list",
"source_type",
"source",
"state_class",
"step",
"supported_color_modes",
"supported_features",
"swing_mode",
"swing_modes",
"target_temp_step",
"temperature_unit",
"title",
"token",
"unit_of_measurement",
"user_id",
"uuid",
"visibility_unit",
"wind_speed_unit",
"xy_color",
],
},
},
},
{
name: "lower_limit",
type: "select",
required: true,
options: [
[
"value",
localize(
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_value"
),
],
[
"input",
localize(
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_input"
),
],
],
},
...(inputAboveIsEntity
? ([
{
name: "above",
selector: {
entity: { domain: ["input_number", "number", "sensor"] },
},
},
] as const)
: ([
{
name: "above",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
] as const)),
{
name: "upper_limit",
type: "select",
required: true,
options: [
[
"value",
localize(
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_value"
),
],
[
"input",
localize(
"ui.panel.config.automation.editor.triggers.type.numeric_state.type_input"
),
],
],
},
...(inputBelowIsEntity
? ([
{
name: "below",
selector: {
entity: { domain: ["input_number", "number", "sensor"] },
},
},
] as const)
: ([
{
name: "below",
selector: {
number: {
mode: "box",
min: Number.MIN_SAFE_INTEGER,
max: Number.MAX_SAFE_INTEGER,
step: 0.1,
},
},
},
] as const)),
{
name: "value_template",
selector: { template: {} },
},
{ name: "for", selector: { duration: {} } },
] as const
);
public willUpdate(changedProperties: PropertyValues<this>) {
this._inputAboveIsEntity =
this._inputAboveIsEntity ??
(typeof this.trigger.above === "string" &&
((this.trigger.above as string).startsWith("input_number.") ||
(this.trigger.above as string).startsWith("number.") ||
(this.trigger.above as string).startsWith("sensor.")));
this._inputBelowIsEntity =
this._inputBelowIsEntity ??
(typeof this.trigger.below === "string" &&
((this.trigger.below as string).startsWith("input_number.") ||
(this.trigger.below as string).startsWith("number.") ||
(this.trigger.below as string).startsWith("sensor.")));
if (!changedProperties.has("trigger")) {
return;
}
// Check for templates in trigger. If found, revert to YAML mode.
if (this.trigger && hasTemplate(this.trigger.for)) {
fireEvent(
this,
"ui-mode-not-available",
Error(this.hass.localize("ui.errors.config.no_template_editor_support"))
);
}
}
public static get defaultConfig(): NumericStateTrigger {
return {
trigger: "numeric_state",
@@ -257,39 +199,61 @@ export class HaNumericStateTrigger extends LitElement {
};
}
private _data = memoizeOne(
(
inputAboveIsEntity: boolean,
inputBelowIsEntity: boolean,
trigger: NumericStateTrigger
) => ({
lower_limit: inputAboveIsEntity ? "input" : "value",
upper_limit: inputBelowIsEntity ? "input" : "value",
...trigger,
entity_id: ensureArray(trigger.entity_id),
for: createDurationData(trigger.for),
})
);
private _wrapForValue(
forValue: NumericStateTrigger["for"]
): Record<string, unknown> | undefined {
if (forValue === undefined) {
return undefined;
}
if (typeof forValue === "string" && hasTemplate(forValue)) {
return { active_choice: "template", template: forValue };
}
return {
active_choice: "duration",
duration: createDurationData(forValue),
};
}
private _unwrapForValue(
forValue: Record<string, unknown> | undefined
): NumericStateTrigger["for"] {
if (!forValue || !forValue.active_choice) {
return forValue as NumericStateTrigger["for"];
}
if (forValue.active_choice === "template") {
return forValue.template as string;
}
return forValue.duration as NumericStateTrigger["for"];
}
private _unwrapThresholdValue(
value: Record<string, unknown> | number | string | undefined
): number | string | undefined {
if (value === undefined || typeof value !== "object") {
return value as number | string | undefined;
}
if (!value.active_choice) {
return undefined;
}
return value[value.active_choice as string] as number | string | undefined;
}
private _data = memoizeOne((trigger: NumericStateTrigger) => ({
...trigger,
entity_id: ensureArray(trigger.entity_id),
for: this._wrapForValue(trigger.for),
}));
public render() {
const schema = this._schema(
this.hass.localize,
this._inputAboveIsEntity,
this._inputBelowIsEntity
);
const data = this._data(
this._inputAboveIsEntity!,
this._inputBelowIsEntity!,
this.trigger
);
const data = this._data(this.trigger);
return html`
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.schema=${SCHEMA}
.disabled=${this.disabled}
.localizeValue=${this.hass.localize}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
></ha-form>
@@ -300,11 +264,9 @@ export class HaNumericStateTrigger extends LitElement {
ev.stopPropagation();
const newTrigger = { ...ev.detail.value };
this._inputAboveIsEntity = newTrigger.lower_limit === "input";
this._inputBelowIsEntity = newTrigger.upper_limit === "input";
delete newTrigger.lower_limit;
delete newTrigger.upper_limit;
newTrigger.above = this._unwrapThresholdValue(newTrigger.above);
newTrigger.below = this._unwrapThresholdValue(newTrigger.below);
newTrigger.for = this._unwrapForValue(newTrigger.for);
if (newTrigger.value_template === "") {
delete newTrigger.value_template;
@@ -314,7 +276,7 @@ export class HaNumericStateTrigger extends LitElement {
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
schema: SchemaUnion<typeof SCHEMA>
): string => {
switch (schema.name) {
case "entity_id":
@@ -174,7 +174,19 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
},
},
},
{ name: "for", selector: { duration: {} } },
{
name: "for",
selector: {
choose: {
translation_key:
"ui.panel.config.automation.editor.triggers.type.state.for_type",
choices: {
duration: { selector: { duration: {} } },
template: { selector: { template: {} } },
},
},
},
},
] as const satisfies HaFormSchema[]
);
@@ -190,7 +202,9 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
delete this.trigger.for.milliseconds;
}
// Check for templates in trigger. If found, revert to YAML mode.
if (this.trigger && hasTemplate(this.trigger)) {
// Exclude "for" since the UI now supports templates there via choose.
const { for: _for, ...triggerWithoutFor } = this.trigger;
if (triggerWithoutFor && hasTemplate(triggerWithoutFor)) {
fireEvent(
this,
"ui-mode-not-available",
@@ -207,13 +221,38 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
return true;
}
protected render() {
const trgFor = createDurationData(this.trigger.for);
private _wrapForValue(
forValue: StateTrigger["for"]
): Record<string, unknown> | undefined {
if (forValue === undefined) {
return undefined;
}
if (typeof forValue === "string" && hasTemplate(forValue)) {
return { active_choice: "template", template: forValue };
}
return {
active_choice: "duration",
duration: createDurationData(forValue),
};
}
private _unwrapForValue(
forValue: Record<string, unknown> | undefined
): StateTrigger["for"] {
if (!forValue || !forValue.active_choice) {
return forValue as StateTrigger["for"];
}
if (forValue.active_choice === "template") {
return forValue.template as string;
}
return forValue.duration as StateTrigger["for"];
}
protected render() {
const data = {
...this.trigger,
entity_id: ensureArray(this.trigger.entity_id),
for: trgFor,
for: this._wrapForValue(this.trigger.for),
};
data.to = this._normalizeStates(this.trigger.to, data.attribute);
@@ -230,6 +269,7 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
.hass=${this.hass}
.data=${data}
.schema=${schema}
.localizeValue=${this.hass.localize}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
.disabled=${this.disabled}
@@ -241,6 +281,8 @@ export class HaStateTrigger extends LitElement implements TriggerElement {
ev.stopPropagation();
const newTrigger = ev.detail.value;
newTrigger.for = this._unwrapForValue(newTrigger.for);
newTrigger.to = this._applyAnyStateExclusive(
newTrigger.to,
newTrigger.attribute
@@ -1,4 +1,3 @@
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { TemplateTrigger } from "../../../../../data/automation";
@@ -11,7 +10,19 @@ import type { SchemaUnion } from "../../../../../components/ha-form/types";
const SCHEMA = [
{ name: "value_template", required: true, selector: { template: {} } },
{ name: "for", selector: { duration: {} } },
{
name: "for",
selector: {
choose: {
translation_key:
"ui.panel.config.automation.editor.triggers.type.template.for_type",
choices: {
duration: { selector: { duration: {} } },
template: { selector: { template: {} } },
},
},
},
},
] as const;
@customElement("ha-automation-trigger-template")
@@ -26,26 +37,37 @@ export class HaTemplateTrigger extends LitElement {
return { trigger: "template", value_template: "" };
}
public willUpdate(changedProperties: PropertyValues<this>) {
if (!changedProperties.has("trigger")) {
return;
private _wrapForValue(
forValue: TemplateTrigger["for"]
): Record<string, unknown> | undefined {
if (forValue === undefined) {
return undefined;
}
// Check for templates in trigger. If found, revert to YAML mode.
if (this.trigger && hasTemplate(this.trigger.for)) {
fireEvent(
this,
"ui-mode-not-available",
Error(this.hass.localize("ui.errors.config.no_template_editor_support"))
);
if (typeof forValue === "string" && hasTemplate(forValue)) {
return { active_choice: "template", template: forValue };
}
return {
active_choice: "duration",
duration: createDurationData(forValue),
};
}
private _unwrapForValue(
forValue: Record<string, unknown> | undefined
): TemplateTrigger["for"] {
if (!forValue || !forValue.active_choice) {
return forValue as TemplateTrigger["for"];
}
if (forValue.active_choice === "template") {
return forValue.template as string;
}
return forValue.duration as TemplateTrigger["for"];
}
protected render() {
const trgFor = createDurationData(this.trigger.for);
const data = {
...this.trigger,
for: trgFor,
for: this._wrapForValue(this.trigger.for),
};
return html`
@@ -53,6 +75,7 @@ export class HaTemplateTrigger extends LitElement {
.hass=${this.hass}
.data=${data}
.schema=${SCHEMA}
.localizeValue=${this.hass.localize}
@value-changed=${this._valueChanged}
.computeLabel=${this._computeLabelCallback}
.disabled=${this.disabled}
@@ -64,8 +87,11 @@ export class HaTemplateTrigger extends LitElement {
ev.stopPropagation();
const newTrigger = ev.detail.value;
newTrigger.for = this._unwrapForValue(newTrigger.for);
if (
newTrigger.for &&
typeof newTrigger.for === "object" &&
Object.values(newTrigger.for).every((value) => value === 0)
) {
delete newTrigger.for;
@@ -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 -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";
+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";
+30 -1
View File
@@ -5251,6 +5251,12 @@
"description": {
"picker": "Triggers when the state of an entity (or attribute) changes.",
"full": "When{hasAttribute, select, \n true { {attribute} of} \n other {}\n} {hasEntity, select, \n true {{entity}} \n other {something}\n} changes{fromChoice, select, \n fromUsed { from {fromString}}\n null { from any state} \n other {}\n}{toChoice, select, \n toUsed { to {toString}} \n null { to any state} \n special { state or any attributes} \n other {}\n}{hasDuration, select, \n true { for {duration}} \n other {}\n}"
},
"for_type": {
"choices": {
"duration": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::for_type::choices::duration%]",
"template": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::for_type::choices::template%]"
}
}
},
"homeassistant": {
@@ -5281,12 +5287,24 @@
"upper_limit": "Upper limit",
"value_template": "Value template",
"type_value": "Fixed number",
"type_input": "Numeric value of another entity",
"type_input": "Value of an entity",
"description": {
"picker": "Triggers when the numeric value of an entity''s state (or attribute''s value) crosses a given threshold.",
"above": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }",
"above-below": "When {attribute, select, \n undefined {} \n other {{attribute} from }\n }{entity} {numberOfEntities, plural,\n one {is}\n other {is}\n} above {above} and below {below}{duration, select, \n undefined {} \n other { for {duration}}\n }"
},
"threshold_type": {
"choices": {
"value": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::type_value%]",
"input": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::type_input%]"
}
},
"for_type": {
"choices": {
"duration": "Duration",
"template": "Template"
}
}
},
"persistent_notification": {
@@ -5344,6 +5362,12 @@
"description": {
"picker": "Triggers when a template evaluates to true.",
"full": "When a template changes from false to true{hasDuration, select, \n true { for {duration}} \n other {}\n }"
},
"for_type": {
"choices": {
"duration": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::for_type::choices::duration%]",
"template": "[%key:ui::panel::config::automation::editor::triggers::type::numeric_state::for_type::choices::template%]"
}
}
},
"time": {
@@ -5440,6 +5464,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%]",
@@ -7164,6 +7192,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("");
});
});
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Develop the e2e test app
# Stop on errors
set -e
cd "$(dirname "$0")/../../../.."
./node_modules/.bin/gulp develop-e2e-test-app
+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,
});
}
+23 -23
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
@@ -9748,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"
@@ -12600,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