mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-30 12:11:42 +00:00
Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 19231b9e78 | |||
| e7daf09a1a | |||
| fff1568898 | |||
| db8bd28b07 | |||
| e773ba4ded | |||
| a9a2d17741 | |||
| 49a7814115 | |||
| 002bf491bf | |||
| 43fcd1b0a4 | |||
| ab031ab139 | |||
| 07030e6575 | |||
| c32ae22f63 | |||
| 585db17e86 | |||
| 28739f7fd3 | |||
| 8db3f168a5 | |||
| aa2c8564ed | |||
| aaf5986fd7 | |||
| 8c20a1041f | |||
| 05afa19a76 | |||
| 66775f03dd | |||
| 53e47e58f1 | |||
| 7b2569346f | |||
| 72fe6e1cbb | |||
| 26f270720a | |||
| e01bef53dc | |||
| 04226dda32 | |||
| b8fc05d5c4 | |||
| d602e77fc3 | |||
| a19842bd4d | |||
| 13872baa8c | |||
| a1aaf3fe33 | |||
| 84840dc922 | |||
| 8e43688ed8 | |||
| d9037b84c8 | |||
| c070765f54 | |||
| 8e5d976f7b | |||
| 2dbb052200 | |||
| 3b04f29755 | |||
| 865b5b1b80 | |||
| b44c69b1b0 | |||
| 27787e51f8 | |||
| dc7daf3156 | |||
| b898468193 | |||
| 781aa116b8 | |||
| 60c86899f3 | |||
| f8d870d6bb | |||
| 4d82b352a9 | |||
| 179b4cf77c | |||
| 542f07606a | |||
| cf2c440e7b | |||
| 27fbabb71b | |||
| 389af6e00c | |||
| 7ff4cf58e8 | |||
| f849302876 | |||
| 1db707937b | |||
| 70f0d12e43 | |||
| 12bb09dad2 | |||
| f08ffefe28 | |||
| 9de89278cd | |||
| 207d997a3a | |||
| 0bb32aa1b4 | |||
| ba0310ee58 |
@@ -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"],
|
||||
});
|
||||
}
|
||||
@@ -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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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 });
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
@@ -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:
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
+15
-19
@@ -28,7 +28,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -155,19 +155,19 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -209,7 +209,7 @@ jobs:
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Download blob report (local)
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: blob-report-local
|
||||
@@ -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
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
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 });
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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 });
|
||||
|
||||
@@ -18,6 +18,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
|
||||
- uses: release-drafter/release-drafter@ed4bc48ec97379be2258e7b7ac2624a3e26ab809 # v7.4.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@e91ad1948e57189485b9c1ad608af0c303946f89 # master
|
||||
uses: home-assistant/actions/helpers/verify-version@f4ca6f671bd429efb108c0f2fa0ae8af0215986c # master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
contents: write # Required to upload release assets
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
|
||||
@@ -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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
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 });
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,7 +1,7 @@
|
||||
import fs from "fs";
|
||||
import { glob } from "glob";
|
||||
import gulp from "gulp";
|
||||
import yaml from "js-yaml";
|
||||
import { load as loadYaml } from "js-yaml";
|
||||
import { marked } from "marked";
|
||||
import path from "path";
|
||||
import paths from "../paths.cjs";
|
||||
@@ -47,7 +47,7 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
|
||||
|
||||
if (descriptionContent.startsWith("---")) {
|
||||
const metadataEnd = descriptionContent.indexOf("---", 3);
|
||||
metadata = yaml.load(descriptionContent.substring(3, metadataEnd));
|
||||
metadata = loadYaml(descriptionContent.substring(3, metadataEnd));
|
||||
descriptionContent = descriptionContent
|
||||
.substring(metadataEnd + 3)
|
||||
.trim();
|
||||
|
||||
@@ -240,6 +240,7 @@ gulp.task("rspack-dev-server-e2e-test-app", () =>
|
||||
),
|
||||
contentBase: paths.e2eTestApp_output_root,
|
||||
port: 8095,
|
||||
open: false,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
+3
-1
@@ -66,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(
|
||||
|
||||
@@ -240,6 +240,12 @@ export default tseslint.config(
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [".github/scripts/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+17
-16
@@ -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)",
|
||||
@@ -36,14 +38,14 @@
|
||||
"@babel/runtime": "8.0.0",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.10.3",
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.43.1",
|
||||
"@codemirror/state": "6.7.0",
|
||||
"@codemirror/view": "6.43.3",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.4.9",
|
||||
@@ -80,6 +82,7 @@
|
||||
"@tsparticles/engine": "4.2.1",
|
||||
"@tsparticles/preset-links": "4.2.1",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.198.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.2.0",
|
||||
@@ -96,13 +99,12 @@
|
||||
"echarts": "6.1.0",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.4.2",
|
||||
"google-timezones-json": "1.2.0",
|
||||
"gulp-zopfli-green": "7.0.0",
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.5",
|
||||
"intl-messageformat": "11.2.8",
|
||||
"js-yaml": "4.2.0",
|
||||
"intl-messageformat": "11.2.9",
|
||||
"js-yaml": "5.1.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
@@ -144,17 +146,16 @@
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.60.0",
|
||||
"@rsdoctor/rspack-plugin": "1.5.15",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.16",
|
||||
"@rspack/core": "2.0.8",
|
||||
"@rspack/dev-server": "2.0.3",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
"@types/color-name": "2.0.0",
|
||||
"@types/culori": "4.0.1",
|
||||
"@types/html-minifier-terser": "7.0.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/leaflet": "1.9.21",
|
||||
"@types/leaflet-draw": "1.0.13",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
@@ -171,7 +172,7 @@
|
||||
"eslint": "10.5.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.16.2",
|
||||
"eslint-plugin-import-x": "4.17.0",
|
||||
"eslint-plugin-lit": "2.3.1",
|
||||
"eslint-plugin-lit-a11y": "5.1.1",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
@@ -180,7 +181,7 @@
|
||||
"fs-extra": "11.3.5",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.6.0",
|
||||
"globals": "17.7.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-brotli": "3.0.0",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
@@ -201,11 +202,11 @@
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "22.0.0",
|
||||
"tar": "7.5.16",
|
||||
"tar": "7.5.17",
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.61.1",
|
||||
"typescript-eslint": "8.62.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.9",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -218,7 +219,7 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.6.0",
|
||||
"globals": "17.7.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"glob@^10.2.2": "^10.5.0"
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260624.1"
|
||||
version = "20260624.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import type {
|
||||
Condition,
|
||||
TimeCondition,
|
||||
VisibilityCondition,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
|
||||
/**
|
||||
* Extract media queries from conditions recursively
|
||||
*/
|
||||
export function extractMediaQueries(conditions: Condition[]): string[] {
|
||||
export function extractMediaQueries(
|
||||
conditions: VisibilityCondition[]
|
||||
): string[] {
|
||||
return conditions.reduce<string[]>((array, c) => {
|
||||
if ("conditions" in c && c.conditions) {
|
||||
array.push(...extractMediaQueries(c.conditions));
|
||||
}
|
||||
if (c.condition === "screen" && c.media_query) {
|
||||
if (
|
||||
"condition" in c &&
|
||||
c.condition === "screen" &&
|
||||
"media_query" in c &&
|
||||
c.media_query
|
||||
) {
|
||||
array.push(c.media_query);
|
||||
}
|
||||
return array;
|
||||
@@ -22,14 +29,16 @@ export function extractMediaQueries(conditions: Condition[]): string[] {
|
||||
* Extract time conditions from conditions recursively
|
||||
*/
|
||||
export function extractTimeConditions(
|
||||
conditions: Condition[]
|
||||
conditions: VisibilityCondition[]
|
||||
): TimeCondition[] {
|
||||
return conditions.reduce<TimeCondition[]>((array, c) => {
|
||||
if ("conditions" in c && c.conditions) {
|
||||
array.push(...extractTimeConditions(c.conditions));
|
||||
}
|
||||
if (c.condition === "time") {
|
||||
array.push(c);
|
||||
if ("condition" in c && c.condition === "time") {
|
||||
// Dashboard `time` is always the client-side lovelace shape; core `time`
|
||||
// is intentionally excluded from VisibilityCondition.
|
||||
array.push(c as TimeCondition);
|
||||
}
|
||||
return array;
|
||||
}, []);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { listenMediaQuery } from "../dom/media_query";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
TimeCondition,
|
||||
VisibilityCondition,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
|
||||
import { extractMediaQueries, extractTimeConditions } from "./extract";
|
||||
import { calculateNextTimeUpdate } from "./time-calculator";
|
||||
|
||||
@@ -16,95 +15,68 @@ import { calculateNextTimeUpdate } from "./time-calculator";
|
||||
const MAX_TIMEOUT_DELAY = 2147483647;
|
||||
|
||||
/**
|
||||
* Helper to setup media query listeners for conditional visibility
|
||||
* Schedule a callback to fire at the next boundary of a time condition,
|
||||
* rescheduling itself afterwards. Delays beyond the setTimeout maximum are
|
||||
* capped and re-scheduled without firing (so the boundary is only reported
|
||||
* once it is actually reached). Registers a single cleanup function that
|
||||
* clears the pending timeout.
|
||||
*/
|
||||
export function setupMediaQueryListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
function scheduleTimeBoundaryListener(
|
||||
getHass: () => HomeAssistant,
|
||||
timeCondition: Omit<TimeCondition, "condition">,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
onBoundary: () => void
|
||||
): void {
|
||||
const mediaQueries = extractMediaQueries(conditions);
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
if (mediaQueries.length === 0) return;
|
||||
const scheduleUpdate = () => {
|
||||
// Read hass lazily so timezone changes are picked up on the next boundary.
|
||||
const delay = calculateNextTimeUpdate(getHass(), timeCondition);
|
||||
|
||||
// Optimization for single media query
|
||||
const hasOnlyMediaQuery =
|
||||
conditions.length === 1 &&
|
||||
conditions[0].condition === "screen" &&
|
||||
!!conditions[0].media_query;
|
||||
if (delay === undefined) return;
|
||||
|
||||
mediaQueries.forEach((mediaQuery) => {
|
||||
const unsub = listenMediaQuery(mediaQuery, (matches) => {
|
||||
if (hasOnlyMediaQuery) {
|
||||
onUpdate(matches);
|
||||
} else {
|
||||
const context = getContext?.() ?? {};
|
||||
const conditionsMet = checkConditionsMet(conditions, hass, context);
|
||||
onUpdate(conditionsMet);
|
||||
// Cap delay to prevent setTimeout overflow
|
||||
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (delay <= MAX_TIMEOUT_DELAY) {
|
||||
onBoundary();
|
||||
}
|
||||
});
|
||||
addListener(unsub);
|
||||
scheduleUpdate();
|
||||
}, cappedDelay);
|
||||
};
|
||||
|
||||
// Register cleanup function once, outside of scheduleUpdate
|
||||
addListener(() => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
scheduleUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to setup time-based listeners for conditional visibility
|
||||
* Observe the client-evaluated parts of a condition tree — `screen` media
|
||||
* queries and `time` boundaries — and invoke `onChange` whenever one of them
|
||||
* could have flipped.
|
||||
*
|
||||
* This does not evaluate the conditions itself: the caller recombines client
|
||||
* and server results on notification. Used by `ConditionEvaluatorController`,
|
||||
* which merges these client signals with the results of `subscribe_condition`
|
||||
* subscriptions.
|
||||
*/
|
||||
export function setupTimeListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
export function observeConditionChanges(
|
||||
conditions: VisibilityCondition[],
|
||||
getHass: () => HomeAssistant,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
onChange: () => void
|
||||
): void {
|
||||
const timeConditions = extractTimeConditions(conditions);
|
||||
extractMediaQueries(conditions).forEach((mediaQuery) => {
|
||||
addListener(listenMediaQuery(mediaQuery, () => onChange()));
|
||||
});
|
||||
|
||||
if (timeConditions.length === 0) return;
|
||||
|
||||
timeConditions.forEach((timeCondition) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const scheduleUpdate = () => {
|
||||
const delay = calculateNextTimeUpdate(hass, timeCondition);
|
||||
|
||||
if (delay === undefined) return;
|
||||
|
||||
// Cap delay to prevent setTimeout overflow
|
||||
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (delay <= MAX_TIMEOUT_DELAY) {
|
||||
const context = getContext?.() ?? {};
|
||||
const conditionsMet = checkConditionsMet(conditions, hass, context);
|
||||
onUpdate(conditionsMet);
|
||||
}
|
||||
scheduleUpdate();
|
||||
}, cappedDelay);
|
||||
};
|
||||
|
||||
// Register cleanup function once, outside of scheduleUpdate
|
||||
addListener(() => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
scheduleUpdate();
|
||||
extractTimeConditions(conditions).forEach((timeCondition) => {
|
||||
scheduleTimeBoundaryListener(getHass, timeCondition, addListener, onChange);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up all condition listeners (media query, time) for conditional visibility.
|
||||
*/
|
||||
export function setupConditionListeners(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
addListener: (unsub: () => void) => void,
|
||||
onUpdate: (conditionsMet: boolean) => void,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
setupMediaQueryListeners(conditions, hass, addListener, onUpdate, getContext);
|
||||
setupTimeListeners(conditions, hass, addListener, onUpdate, getContext);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { Condition as CoreCondition } from "../../data/automation";
|
||||
import type { VisibilityCondition } from "../../panels/lovelace/common/validate-condition";
|
||||
import {
|
||||
isLogicalCondition,
|
||||
isServerCondition,
|
||||
translateToCoreCondition,
|
||||
} from "./translate";
|
||||
|
||||
/** A maximal server subtree, to be opened as one `subscribe_condition`. */
|
||||
export interface ServerSubtree {
|
||||
id: string;
|
||||
coreCondition: CoreCondition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a single client-only condition leaf (`screen`, `user`,
|
||||
* `view_columns`, `location`, `time`). Returns `undefined` when the outcome is
|
||||
* not yet determinable (e.g. context not available).
|
||||
*/
|
||||
export type ClientConditionEvaluator = (
|
||||
condition: VisibilityCondition
|
||||
) => boolean | undefined;
|
||||
|
||||
/** Server subtree results keyed by {@link ServerSubtree.id}; `undefined` = not yet reported. */
|
||||
export type ServerConditionResults = Record<string, boolean | undefined>;
|
||||
|
||||
export interface SplitConditionTree {
|
||||
/** Maximal server subtrees, each to be opened as one `subscribe_condition`. */
|
||||
serverSubtrees: ServerSubtree[];
|
||||
/**
|
||||
* Combine client + server results into the overall visibility using
|
||||
* three-valued (Kleene) logic. Returns `undefined` while the outcome still
|
||||
* depends on a server subtree that has not reported yet.
|
||||
*/
|
||||
evaluate: (
|
||||
clientEvaluator: ClientConditionEvaluator,
|
||||
serverResults: ServerConditionResults
|
||||
) => boolean | undefined;
|
||||
}
|
||||
|
||||
type EvalNode = (
|
||||
clientEvaluator: ClientConditionEvaluator,
|
||||
serverResults: ServerConditionResults
|
||||
) => boolean | undefined;
|
||||
|
||||
// Three-valued logic combinators (true / false / undefined = unknown). `false`
|
||||
// dominates AND and `true` dominates OR regardless of any unknown sibling.
|
||||
const andNode =
|
||||
(children: EvalNode[]): EvalNode =>
|
||||
(clientEvaluator, serverResults) => {
|
||||
let unknown = false;
|
||||
for (const child of children) {
|
||||
const value = child(clientEvaluator, serverResults);
|
||||
if (value === false) return false;
|
||||
if (value === undefined) unknown = true;
|
||||
}
|
||||
return unknown ? undefined : true;
|
||||
};
|
||||
|
||||
const orNode =
|
||||
(children: EvalNode[]): EvalNode =>
|
||||
(clientEvaluator, serverResults) => {
|
||||
let unknown = false;
|
||||
for (const child of children) {
|
||||
const value = child(clientEvaluator, serverResults);
|
||||
if (value === true) return true;
|
||||
if (value === undefined) unknown = true;
|
||||
}
|
||||
return unknown ? undefined : false;
|
||||
};
|
||||
|
||||
const notNode =
|
||||
(child: EvalNode): EvalNode =>
|
||||
(clientEvaluator, serverResults) => {
|
||||
const value = child(clientEvaluator, serverResults);
|
||||
return value === undefined ? undefined : !value;
|
||||
};
|
||||
|
||||
const serverLeaf =
|
||||
(id: string): EvalNode =>
|
||||
(_clientEvaluator, serverResults) =>
|
||||
serverResults[id];
|
||||
|
||||
const clientLeaf =
|
||||
(condition: VisibilityCondition): EvalNode =>
|
||||
(clientEvaluator) =>
|
||||
clientEvaluator(condition);
|
||||
|
||||
/**
|
||||
* Split a dashboard visibility condition tree into:
|
||||
*
|
||||
* - a flat list of **maximal server subtrees** (`serverSubtrees`), each
|
||||
* translated to core format and meant to back one `subscribe_condition`; and
|
||||
* - an **`evaluate`** function that recombines those subtree results with
|
||||
* locally-evaluated client leaves into the overall visibility.
|
||||
*
|
||||
* The top-level array is treated as an implicit `AND`. Sibling server
|
||||
* conditions sharing a logical parent (including that implicit top-level AND)
|
||||
* are grouped into a *single* subscription using the parent's operator, to
|
||||
* avoid subscription fan-out. A `not` combines its children with `AND` before
|
||||
* negating, matching lovelace `not` semantics (¬(AND of children)).
|
||||
*/
|
||||
export const splitConditionTree = (
|
||||
conditions: VisibilityCondition[]
|
||||
): SplitConditionTree => {
|
||||
const serverSubtrees: ServerSubtree[] = [];
|
||||
let nextId = 0;
|
||||
|
||||
const addSubtree = (coreCondition: CoreCondition): EvalNode => {
|
||||
const id = String(nextId);
|
||||
nextId += 1;
|
||||
serverSubtrees.push({ id, coreCondition });
|
||||
return serverLeaf(id);
|
||||
};
|
||||
|
||||
// Partition children into client/server, group the server siblings into one
|
||||
// subscription, and recurse into the client ones. `groupOperator` is the
|
||||
// operator used to combine the grouped server siblings.
|
||||
const buildSiblings = (
|
||||
children: VisibilityCondition[],
|
||||
groupOperator: "and" | "or"
|
||||
): EvalNode[] => {
|
||||
const serverChildren: VisibilityCondition[] = [];
|
||||
const clientChildren: VisibilityCondition[] = [];
|
||||
for (const child of children) {
|
||||
(isServerCondition(child) ? serverChildren : clientChildren).push(child);
|
||||
}
|
||||
|
||||
const nodes: EvalNode[] = [];
|
||||
|
||||
if (serverChildren.length === 1) {
|
||||
nodes.push(addSubtree(translateToCoreCondition(serverChildren[0])));
|
||||
} else if (serverChildren.length > 1) {
|
||||
nodes.push(
|
||||
addSubtree({
|
||||
condition: groupOperator,
|
||||
conditions: serverChildren.map(translateToCoreCondition),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (const child of clientChildren) {
|
||||
nodes.push(build(child));
|
||||
}
|
||||
|
||||
return nodes;
|
||||
};
|
||||
|
||||
// Only ever reached for client-class nodes (server subtrees are grouped and
|
||||
// translated whole by `buildSiblings`).
|
||||
const build = (condition: VisibilityCondition): EvalNode => {
|
||||
if (isLogicalCondition(condition)) {
|
||||
const children = condition.conditions ?? [];
|
||||
if (condition.condition === "or") {
|
||||
return orNode(buildSiblings(children, "or"));
|
||||
}
|
||||
if (condition.condition === "not") {
|
||||
return notNode(andNode(buildSiblings(children, "and")));
|
||||
}
|
||||
return andNode(buildSiblings(children, "and"));
|
||||
}
|
||||
// Defensive: a server leaf reaching here still becomes a subscription.
|
||||
if (isServerCondition(condition)) {
|
||||
return addSubtree(translateToCoreCondition(condition));
|
||||
}
|
||||
return clientLeaf(condition);
|
||||
};
|
||||
|
||||
const root = andNode(buildSiblings(conditions, "and"));
|
||||
|
||||
return {
|
||||
serverSubtrees,
|
||||
evaluate: (clientEvaluator, serverResults) =>
|
||||
root(clientEvaluator, serverResults),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
import type {
|
||||
Condition as CoreCondition,
|
||||
NumericStateCondition as CoreNumericStateCondition,
|
||||
StateCondition as CoreStateCondition,
|
||||
} from "../../data/automation";
|
||||
import type {
|
||||
LegacyCondition,
|
||||
NumericStateCondition as LovelaceNumericStateCondition,
|
||||
StateCondition as LovelaceStateCondition,
|
||||
VisibilityCondition,
|
||||
VisibilityLogicalCondition,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import { isValidEntityId } from "../entity/valid_entity_id";
|
||||
|
||||
/**
|
||||
* Lovelace condition types evaluated on the client; these have no usable core
|
||||
* equivalent for dashboards and are never sent to `subscribe_condition`.
|
||||
*/
|
||||
const CLIENT_CONDITION_TYPES = new Set([
|
||||
"screen",
|
||||
"user",
|
||||
"view_columns",
|
||||
"location",
|
||||
"time",
|
||||
]);
|
||||
|
||||
const LOGICAL_CONDITION_TYPES = new Set(["and", "or", "not"]);
|
||||
|
||||
/** Type guard for the `and` / `or` / `not` combinators. */
|
||||
export const isLogicalCondition = (
|
||||
condition: VisibilityCondition
|
||||
): condition is VisibilityLogicalCondition =>
|
||||
"condition" in condition && LOGICAL_CONDITION_TYPES.has(condition.condition);
|
||||
|
||||
/**
|
||||
* Whether a condition must be evaluated server-side (via `subscribe_condition`).
|
||||
*
|
||||
* Leaves: everything except the client-only lovelace types is server-class,
|
||||
* including legacy `{ entity, state }` conditions (treated as `state`) and any
|
||||
* integration-provided condition.
|
||||
*
|
||||
* Compounds (`and` / `or` / `not`) are server-class only when *every*
|
||||
* descendant is, so a single client leaf anywhere forces the whole compound
|
||||
* client-side, where it becomes a combinator wrapping server subtrees (see
|
||||
* `splitConditionTree`). An empty compound is vacuously server-class.
|
||||
*/
|
||||
export const isServerCondition = (condition: VisibilityCondition): boolean => {
|
||||
if (isLogicalCondition(condition)) {
|
||||
return (condition.conditions ?? []).every(isServerCondition);
|
||||
}
|
||||
// Legacy lovelace condition without a `condition` key → treated as `state`.
|
||||
if (!("condition" in condition)) {
|
||||
return true;
|
||||
}
|
||||
return !CLIENT_CONDITION_TYPES.has(condition.condition);
|
||||
};
|
||||
|
||||
/** Inverse of {@link isServerCondition}. */
|
||||
export const isClientCondition = (condition: VisibilityCondition): boolean =>
|
||||
!isServerCondition(condition);
|
||||
|
||||
/**
|
||||
* Whether *every* leaf in the tree is a client-only condition, so the whole
|
||||
* tree can be evaluated and validated client-side without any
|
||||
* `subscribe_condition` round-trip. Distinct from {@link isClientCondition},
|
||||
* which is true when *any* leaf is client-side.
|
||||
*/
|
||||
export const isPureClientCondition = (
|
||||
condition: VisibilityCondition
|
||||
): boolean =>
|
||||
isLogicalCondition(condition)
|
||||
? (condition.conditions ?? []).every(isPureClientCondition)
|
||||
: isClientCondition(condition);
|
||||
|
||||
/**
|
||||
* Translate a server-class lovelace condition into its core automation
|
||||
* equivalent. Core-format conditions (and condition types with no lovelace
|
||||
* counterpart, like `template` / `sun` / `zone` / `device` / integration
|
||||
* conditions) are passed through untouched.
|
||||
*
|
||||
* The caller is responsible for only translating server-class conditions
|
||||
* ({@link isServerCondition}); passing a client-only condition just returns it
|
||||
* unchanged.
|
||||
*/
|
||||
export const translateToCoreCondition = (
|
||||
condition: VisibilityCondition
|
||||
): CoreCondition => {
|
||||
// Legacy lovelace condition: { entity, state, state_not } with no `condition`.
|
||||
if (!("condition" in condition)) {
|
||||
return translateStateCondition({ condition: "state", ...condition });
|
||||
}
|
||||
|
||||
if (isLogicalCondition(condition)) {
|
||||
return translateLogicalCondition(condition);
|
||||
}
|
||||
|
||||
switch (condition.condition) {
|
||||
case "state":
|
||||
return translateStateCondition(condition as LovelaceStateCondition);
|
||||
case "numeric_state":
|
||||
return translateNumericStateCondition(
|
||||
condition as LovelaceNumericStateCondition
|
||||
);
|
||||
default:
|
||||
// Already core format (sun, zone, template, device, integration, or a
|
||||
// core `state` / `numeric_state` carrying `entity_id`) → pass through.
|
||||
return condition as CoreCondition;
|
||||
}
|
||||
};
|
||||
|
||||
// A core condition that always evaluates to false — ¬(AND of nothing) = ¬true.
|
||||
// Used where checkConditionsMet short-circuits to false (an incomplete config),
|
||||
// so we never emit a schema-invalid condition that would break a grouped
|
||||
// subscription.
|
||||
const alwaysFalseCondition = (): CoreCondition => ({
|
||||
condition: "not",
|
||||
conditions: [{ condition: "and", conditions: [] }],
|
||||
});
|
||||
|
||||
const translateStateCondition = (
|
||||
condition: LovelaceStateCondition | CoreStateCondition | LegacyCondition
|
||||
): CoreCondition => {
|
||||
// Already core format — distinguished from lovelace by `entity_id`.
|
||||
if ("entity_id" in condition) {
|
||||
return condition as CoreStateCondition;
|
||||
}
|
||||
|
||||
const lovelace = condition as LovelaceStateCondition;
|
||||
|
||||
// Incomplete config: no entity, or no comparison value. checkConditionsMet
|
||||
// returns false for these (and a `state` condition with no `entity_id` /
|
||||
// `state` is invalid for core), so resolve to a clean always-false.
|
||||
if (
|
||||
lovelace.entity === undefined ||
|
||||
(lovelace.state === undefined && lovelace.state_not === undefined)
|
||||
) {
|
||||
return alwaysFalseCondition();
|
||||
}
|
||||
|
||||
const base = {
|
||||
condition: "state" as const,
|
||||
entity_id: lovelace.entity,
|
||||
...(lovelace.attribute !== undefined
|
||||
? { attribute: lovelace.attribute }
|
||||
: {}),
|
||||
};
|
||||
|
||||
// KNOWN LIMITATION: when the compared value is itself an entity id, lovelace
|
||||
// (checkStateCondition -> getValueFromEntityId) resolves *any* entity to its
|
||||
// live state, but core's `state` condition only dereferences `input_*`
|
||||
// entities and compares everything else literally. A value referencing a
|
||||
// non-`input_*` entity therefore changes meaning after delegation. This is
|
||||
// niche (the visibility editor does not offer entity-as-value) and left as a
|
||||
// future enhancement — a faithful, reactive fix would emit a `template`
|
||||
// condition. See https://github.com/home-assistant/frontend/issues/52836.
|
||||
|
||||
// `state` wins over `state_not` when both are present, mirroring
|
||||
// checkConditionsMet (`state ?? state_not`, positive branch when `state`).
|
||||
if (lovelace.state !== undefined) {
|
||||
return { ...base, state: lovelace.state } as CoreStateCondition;
|
||||
}
|
||||
|
||||
// Core has no `state_not`; wrap a positive `state` in `not`.
|
||||
return {
|
||||
condition: "not",
|
||||
conditions: [{ ...base, state: lovelace.state_not } as CoreStateCondition],
|
||||
};
|
||||
};
|
||||
|
||||
const translateNumericStateCondition = (
|
||||
condition: LovelaceNumericStateCondition | CoreNumericStateCondition
|
||||
): CoreCondition => {
|
||||
if ("entity_id" in condition) {
|
||||
return condition as CoreNumericStateCondition;
|
||||
}
|
||||
const lovelace = condition as LovelaceNumericStateCondition;
|
||||
const core: CoreNumericStateCondition = {
|
||||
condition: "numeric_state",
|
||||
entity_id: lovelace.entity as string,
|
||||
};
|
||||
if (lovelace.attribute !== undefined) {
|
||||
core.attribute = lovelace.attribute;
|
||||
}
|
||||
const above = translateNumericBound(lovelace.above);
|
||||
if (above !== undefined) {
|
||||
core.above = above;
|
||||
}
|
||||
const below = translateNumericBound(lovelace.below);
|
||||
if (below !== undefined) {
|
||||
core.below = below;
|
||||
}
|
||||
return core;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconcile a lovelace numeric bound with core's interpretation. Lovelace
|
||||
* resolves a string bound to an entity's state only when that entity exists,
|
||||
* otherwise falling back to `Number(...)` (which yields `NaN` for junk, leaving
|
||||
* the bound effectively ignored). Core instead treats *every* string bound as
|
||||
* an entity id and errors when it is not one. To preserve lovelace behavior:
|
||||
*
|
||||
* - a finite numeric string (`"5"`, `"10.5"`, even `""` → 0) coerces to a
|
||||
* number (the entity-id regex matches `"10.5"`, so test `Number()` first);
|
||||
* - a genuine entity-id reference passes through for core to resolve;
|
||||
* - anything else (junk like `"foo"`, or non-finite like `"1e400"`) is dropped,
|
||||
* matching lovelace's "NaN ⇒ ignored" and never emitting a non-finite number
|
||||
* (which is not JSON-serializable).
|
||||
*/
|
||||
const translateNumericBound = (
|
||||
bound: string | number | undefined
|
||||
): string | number | undefined => {
|
||||
if (typeof bound !== "string") {
|
||||
return bound;
|
||||
}
|
||||
const numeric = Number(bound);
|
||||
if (!isNaN(numeric) && isFinite(numeric)) {
|
||||
return numeric;
|
||||
}
|
||||
if (isValidEntityId(bound)) {
|
||||
return bound;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const translateLogicalCondition = (
|
||||
condition: VisibilityLogicalCondition
|
||||
): CoreCondition => {
|
||||
// Lovelace treats a logical condition with no `conditions` key as vacuously
|
||||
// true (checkAnd/Or/NotCondition all early-return on a missing list).
|
||||
if (condition.conditions === undefined) {
|
||||
return { condition: "and", conditions: [] };
|
||||
}
|
||||
|
||||
const conditions = condition.conditions.map(translateToCoreCondition);
|
||||
|
||||
if (condition.condition === "not") {
|
||||
// Lovelace `not` means ¬(AND of children); core `not` means ¬(OR of
|
||||
// children). Wrapping the children in an `and` preserves the lovelace
|
||||
// meaning for any arity — including an empty `not`, which becomes ¬(AND of
|
||||
// nothing) = ¬true = false, matching checkConditionsMet. A single child is
|
||||
// unambiguous (¬(OR of one) = ¬(AND of one)) and left unwrapped for a
|
||||
// tidier persisted form.
|
||||
if (conditions.length === 1) {
|
||||
return { condition: "not", conditions };
|
||||
}
|
||||
return { condition: "not", conditions: [{ condition: "and", conditions }] };
|
||||
}
|
||||
|
||||
// Empty `and` (true) / `or` (false) already agree between lovelace and core.
|
||||
return { condition: condition.condition, conditions };
|
||||
};
|
||||
@@ -0,0 +1,329 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { subscribeCondition } from "../../data/automation";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
VisibilityCondition,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { observeConditionChanges } from "../condition/listeners";
|
||||
import type {
|
||||
ClientConditionEvaluator,
|
||||
ServerConditionResults,
|
||||
SplitConditionTree,
|
||||
} from "../condition/split";
|
||||
import { splitConditionTree } from "../condition/split";
|
||||
|
||||
/** Tri-state visibility outcome. `unknown` = a server subtree has not reported yet. */
|
||||
export type ConditionEvaluation = "visible" | "hidden" | "unknown";
|
||||
|
||||
export interface ConditionEvaluatorOptions {
|
||||
/** Called whenever the combined result or error changes. */
|
||||
onResult: (result: ConditionEvaluation, error?: string) => void;
|
||||
/** Debounce (ms) before (re)opening subscriptions when the tree changes. */
|
||||
resubscribeDelay?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_RESUBSCRIBE_DELAY = 50;
|
||||
|
||||
/**
|
||||
* Reactive controller that keeps a dashboard visibility condition tree
|
||||
* evaluated live by combining:
|
||||
*
|
||||
* - `subscribe_condition` subscriptions, one per maximal server subtree
|
||||
* (`state`, `numeric_state`, `template`, `sun`, `zone`, `device`,
|
||||
* integration conditions), and
|
||||
* - locally-evaluated client leaves (`screen`, `user`, `view_columns`,
|
||||
* `location`, `time`), reacting to media-query / time-boundary / hass /
|
||||
* context changes.
|
||||
*
|
||||
* The host calls {@link observe} whenever its inputs change; the controller
|
||||
* only (re)subscribes when the *condition tree* changes (debounced) and merely
|
||||
* recomputes for hass/context changes. Subscriptions are torn down on host
|
||||
* disconnect and re-opened on reconnect. The combined result uses three-valued
|
||||
* logic so the host can render an explicit `unknown` state without flashing
|
||||
* while server results are still pending.
|
||||
*/
|
||||
export class ConditionEvaluatorController implements ReactiveController {
|
||||
private _host: ReactiveControllerHost;
|
||||
|
||||
private readonly _onResult: ConditionEvaluatorOptions["onResult"];
|
||||
|
||||
private readonly _resubscribeDelay: number;
|
||||
|
||||
private _conditions?: VisibilityCondition[];
|
||||
|
||||
private _hass?: HomeAssistant;
|
||||
|
||||
private _getContext?: () => ConditionContext;
|
||||
|
||||
private _connected = false;
|
||||
|
||||
// Structural signature of the tree the live subscriptions/listeners are for,
|
||||
// and of the tree a pending (debounced) re-subscribe will switch to. Compared
|
||||
// by value (not array reference) so a host re-deriving the array each render
|
||||
// does not starve the debounce or needlessly drop subscriptions.
|
||||
private _subscribedSignature?: string;
|
||||
|
||||
private _pendingSignature?: string;
|
||||
|
||||
// Memoize the signature for a stable array reference to avoid re-stringifying
|
||||
// on every host update.
|
||||
private _lastConditionsRef?: VisibilityCondition[];
|
||||
|
||||
private _lastSignature?: string;
|
||||
|
||||
private _split?: SplitConditionTree;
|
||||
|
||||
private _serverResults: ServerConditionResults = {};
|
||||
|
||||
private _subtreeErrors: Record<string, string | undefined> = {};
|
||||
|
||||
private _subscriptions: Promise<UnsubscribeFunc>[] = [];
|
||||
|
||||
private _listeners: (() => void)[] = [];
|
||||
|
||||
// Bumped on every teardown so late-arriving async results are ignored.
|
||||
private _generation = 0;
|
||||
|
||||
private _resubscribeTimeout?: ReturnType<typeof setTimeout>;
|
||||
|
||||
private _result: ConditionEvaluation = "unknown";
|
||||
|
||||
private _error?: string;
|
||||
|
||||
private _notifiedResult?: ConditionEvaluation;
|
||||
|
||||
private _notifiedError?: string;
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost,
|
||||
options: ConditionEvaluatorOptions
|
||||
) {
|
||||
this._host = host;
|
||||
this._onResult = options.onResult;
|
||||
this._resubscribeDelay =
|
||||
options.resubscribeDelay ?? DEFAULT_RESUBSCRIBE_DELAY;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public get result(): ConditionEvaluation {
|
||||
return this._result;
|
||||
}
|
||||
|
||||
public get error(): string | undefined {
|
||||
return this._error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide the latest inputs. Cheap to call on every host update: it only
|
||||
* (re)subscribes when the condition tree reference changes, otherwise it just
|
||||
* recomputes the client-dependent parts.
|
||||
*/
|
||||
public observe(
|
||||
conditions: VisibilityCondition[] | undefined,
|
||||
hass: HomeAssistant | undefined,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
this._conditions = conditions;
|
||||
this._hass = hass;
|
||||
this._getContext = getContext;
|
||||
this._sync();
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._connected = true;
|
||||
this._sync();
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._connected = false;
|
||||
this._teardown();
|
||||
// Nothing backs the last result once subscriptions are closed; report
|
||||
// `unknown` (and force the notification through) so a detached/reconnecting
|
||||
// host never renders a stale, no-longer-live visibility.
|
||||
this._notifiedResult = undefined;
|
||||
this._notifiedError = undefined;
|
||||
this._setResult("unknown", undefined);
|
||||
}
|
||||
|
||||
private _signatureOf(
|
||||
conditions: VisibilityCondition[] | undefined
|
||||
): string | undefined {
|
||||
if (conditions === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (conditions === this._lastConditionsRef) {
|
||||
return this._lastSignature;
|
||||
}
|
||||
this._lastConditionsRef = conditions;
|
||||
this._lastSignature = JSON.stringify(conditions);
|
||||
return this._lastSignature;
|
||||
}
|
||||
|
||||
private _sync(): void {
|
||||
if (!this._connected) {
|
||||
return;
|
||||
}
|
||||
const signature = this._signatureOf(this._conditions);
|
||||
// Re-subscribe only when the tree we are (or are about to be) subscribed to
|
||||
// actually differs by value — not merely by array reference.
|
||||
const targetSignature = this._pendingSignature ?? this._subscribedSignature;
|
||||
if (signature !== targetSignature) {
|
||||
this._pendingSignature = signature;
|
||||
this._scheduleResubscribe();
|
||||
}
|
||||
// Always recompute so client leaves (and the current split) stay live, even
|
||||
// while a re-subscribe is pending.
|
||||
this._recompute();
|
||||
}
|
||||
|
||||
private _scheduleResubscribe(): void {
|
||||
if (this._resubscribeTimeout !== undefined) {
|
||||
clearTimeout(this._resubscribeTimeout);
|
||||
}
|
||||
this._resubscribeTimeout = setTimeout(() => {
|
||||
this._resubscribeTimeout = undefined;
|
||||
this._subscribe();
|
||||
}, this._resubscribeDelay);
|
||||
}
|
||||
|
||||
private _subscribe(): void {
|
||||
this._teardown();
|
||||
|
||||
const conditions = this._conditions;
|
||||
const hass = this._hass;
|
||||
this._subscribedSignature = this._signatureOf(conditions);
|
||||
this._pendingSignature = undefined;
|
||||
|
||||
if (!conditions || !hass) {
|
||||
this._setResult("unknown", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const split = splitConditionTree(conditions);
|
||||
this._split = split;
|
||||
|
||||
const generation = this._generation;
|
||||
const connection: Connection = hass.connection;
|
||||
|
||||
for (const subtree of split.serverSubtrees) {
|
||||
this._serverResults[subtree.id] = undefined;
|
||||
const subscription = subscribeCondition(
|
||||
connection,
|
||||
(message) => {
|
||||
if (generation !== this._generation) {
|
||||
return;
|
||||
}
|
||||
if (message.error !== undefined) {
|
||||
this._serverResults[subtree.id] = false;
|
||||
this._subtreeErrors[subtree.id] =
|
||||
typeof message.error === "string"
|
||||
? message.error
|
||||
: message.error.message;
|
||||
} else {
|
||||
this._serverResults[subtree.id] = message.result;
|
||||
this._subtreeErrors[subtree.id] = undefined;
|
||||
}
|
||||
this._recompute();
|
||||
},
|
||||
subtree.coreCondition
|
||||
);
|
||||
subscription.catch((err: unknown) => {
|
||||
if (generation !== this._generation) {
|
||||
return;
|
||||
}
|
||||
this._serverResults[subtree.id] = false;
|
||||
this._subtreeErrors[subtree.id] =
|
||||
err instanceof Error ? err.message : String(err);
|
||||
this._recompute();
|
||||
});
|
||||
this._subscriptions.push(subscription);
|
||||
}
|
||||
|
||||
observeConditionChanges(
|
||||
conditions,
|
||||
() => this._hass ?? hass,
|
||||
(unsub) => this._listeners.push(unsub),
|
||||
() => this._recompute()
|
||||
);
|
||||
|
||||
this._recompute();
|
||||
}
|
||||
|
||||
private _recompute(): void {
|
||||
if (!this._split || !this._hass) {
|
||||
this._setResult("unknown", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const hass = this._hass;
|
||||
const context = this._getContext?.() ?? {};
|
||||
const clientEvaluator: ClientConditionEvaluator = (condition) => {
|
||||
try {
|
||||
// Only client-class leaves reach here, and those are all lovelace
|
||||
// Condition members.
|
||||
return checkConditionsMet([condition as Condition], hass, context);
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const value = this._split.evaluate(clientEvaluator, this._serverResults);
|
||||
const result: ConditionEvaluation =
|
||||
value === undefined ? "unknown" : value ? "visible" : "hidden";
|
||||
|
||||
this._setResult(result, this._combinedError());
|
||||
}
|
||||
|
||||
private _combinedError(): string | undefined {
|
||||
for (const error of Object.values(this._subtreeErrors)) {
|
||||
if (error) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _setResult(
|
||||
result: ConditionEvaluation,
|
||||
error: string | undefined
|
||||
): void {
|
||||
this._result = result;
|
||||
this._error = error;
|
||||
if (result === this._notifiedResult && error === this._notifiedError) {
|
||||
return;
|
||||
}
|
||||
this._notifiedResult = result;
|
||||
this._notifiedError = error;
|
||||
this._onResult(result, error);
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
private _teardown(): void {
|
||||
// Invalidate any in-flight subscription callbacks.
|
||||
this._generation += 1;
|
||||
if (this._resubscribeTimeout !== undefined) {
|
||||
clearTimeout(this._resubscribeTimeout);
|
||||
this._resubscribeTimeout = undefined;
|
||||
}
|
||||
for (const subscription of this._subscriptions) {
|
||||
subscription.then((unsub) => unsub()).catch(() => undefined);
|
||||
}
|
||||
this._subscriptions = [];
|
||||
for (const unsub of this._listeners) {
|
||||
unsub();
|
||||
}
|
||||
this._listeners = [];
|
||||
this._split = undefined;
|
||||
this._serverResults = {};
|
||||
this._subtreeErrors = {};
|
||||
this._subscribedSignature = undefined;
|
||||
this._pendingSignature = undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { setupConditionListeners } from "../condition/listeners";
|
||||
|
||||
/**
|
||||
* Reactive controller that manages the media-query and time-based listeners
|
||||
* needed to keep a set of lovelace visibility conditions evaluated live.
|
||||
*
|
||||
* The host is responsible for the actual evaluation (e.g. computing visible /
|
||||
* hidden / invalid state); the controller only triggers it via the supplied
|
||||
* `onUpdate` callback when something the conditions depend on changes. Call
|
||||
* `setup()` whenever the conditions change; the controller clears previous
|
||||
* listeners and re-subscribes. Listeners are automatically released when the
|
||||
* host disconnects.
|
||||
*/
|
||||
export class ConditionListenersController implements ReactiveController {
|
||||
private _unsubs: (() => void)[] = [];
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
public setup(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
onUpdate: () => void,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
this.clear();
|
||||
if (!conditions.length) {
|
||||
return;
|
||||
}
|
||||
setupConditionListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => this._unsubs.push(unsub),
|
||||
() => onUpdate(),
|
||||
getContext
|
||||
);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
for (const unsub of this._unsubs) {
|
||||
unsub();
|
||||
}
|
||||
this._unsubs = [];
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import timezones from "google-timezones-json";
|
||||
import { timeZonesNames } from "@vvo/tzdb";
|
||||
import { TimeZone } from "../../data/translation";
|
||||
|
||||
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
|
||||
@@ -10,7 +10,7 @@ const RESOLVED_TIME_ZONE =
|
||||
RESOLVED_RAW &&
|
||||
(RESOLVED_RAW === "UTC" ||
|
||||
RESOLVED_RAW === "Etc/UTC" ||
|
||||
RESOLVED_RAW in timezones)
|
||||
timeZonesNames.includes(RESOLVED_RAW))
|
||||
? RESOLVED_RAW
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -17,11 +17,18 @@ import {
|
||||
} from "../data/icons";
|
||||
import "./ha-icon";
|
||||
import "./ha-svg-icon";
|
||||
import { consumeEntityState } from "../common/decorators/consume-context-entry";
|
||||
|
||||
@customElement("ha-state-icon")
|
||||
export class HaStateIcon extends LitElement {
|
||||
@property({ attribute: false }) public stateObj?: HassEntity;
|
||||
|
||||
@state()
|
||||
@consumeEntityState({ entityIdPath: ["entityId"] })
|
||||
private _consumeStateObj?: HassEntity;
|
||||
|
||||
@property({ attribute: false }) public entityId?: string;
|
||||
|
||||
@property({ attribute: false }) public stateValue?: string;
|
||||
|
||||
@property() public icon?: string;
|
||||
@@ -38,11 +45,15 @@ export class HaStateIcon extends LitElement {
|
||||
@consume({ context: entitiesContext, subscribe: true })
|
||||
protected _entities?: ContextType<typeof entitiesContext>;
|
||||
|
||||
private get _stateObj(): HassEntity | undefined {
|
||||
return this.stateObj ?? this._consumeStateObj;
|
||||
}
|
||||
|
||||
private get _overrideIcon(): string | undefined {
|
||||
return (
|
||||
this.icon ||
|
||||
(this.stateObj && this._entities?.[this.stateObj.entity_id]?.icon) ||
|
||||
this.stateObj?.attributes.icon
|
||||
(this._stateObj && this._entities?.[this._stateObj.entity_id]?.icon) ||
|
||||
this._stateObj?.attributes.icon
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,7 +83,7 @@ export class HaStateIcon extends LitElement {
|
||||
this._entities,
|
||||
this._config,
|
||||
this._connection,
|
||||
this.stateObj,
|
||||
this._stateObj,
|
||||
this.stateValue,
|
||||
] as const,
|
||||
});
|
||||
@@ -82,7 +93,7 @@ export class HaStateIcon extends LitElement {
|
||||
if (overrideIcon) {
|
||||
return html`<ha-icon .icon=${overrideIcon}></ha-icon>`;
|
||||
}
|
||||
if (!this.stateObj) {
|
||||
if (!this._stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
if (!this._config || !this._connection || !this._entities) {
|
||||
@@ -97,7 +108,7 @@ export class HaStateIcon extends LitElement {
|
||||
}
|
||||
|
||||
private _renderFallback() {
|
||||
const domain = computeStateDomain(this.stateObj!);
|
||||
const domain = computeStateDomain(this._stateObj!);
|
||||
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import timezones from "google-timezones-json";
|
||||
import { getTimeZones, timeZonesNames } from "@vvo/tzdb";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -13,38 +13,40 @@ const SEARCH_KEYS = [
|
||||
{ name: "secondary", weight: 8 },
|
||||
];
|
||||
|
||||
// google-timezones-json is missing the bare "UTC" and "Etc/UTC" zones, even
|
||||
// though both are valid IANA identifiers and common server defaults. Without
|
||||
// them a "UTC" configuration shows up as an unknown time zone. Add them back.
|
||||
// @vvo/tzdb is missing the bare "UTC" zone, even though it is a valid IANA
|
||||
// identifier and a common server default. Add UTC back so a
|
||||
// "UTC" configuration can be selected.
|
||||
const ADDITIONAL_TIMEZONES: PickerComboBoxItem[] = [
|
||||
{ id: "UTC", primary: "(GMT+00:00) UTC", secondary: "UTC" },
|
||||
{ id: "Etc/UTC", primary: "(GMT+00:00) UTC", secondary: "Etc/UTC" },
|
||||
{ id: "UTC", primary: "+00:00 UTC", secondary: "UTC" },
|
||||
];
|
||||
|
||||
// google-timezones-json also ships an invalid IANA identifier. Correct it so
|
||||
// the zone can be selected (the backend rejects the invalid id).
|
||||
const TIMEZONE_ID_CORRECTIONS: Record<string, string> = {
|
||||
"Asia/Yuzhno-Sakhalinsk": "Asia/Sakhalin",
|
||||
};
|
||||
|
||||
export const getTimezoneOptions = (): PickerComboBoxItem[] => {
|
||||
const options: PickerComboBoxItem[] = Object.entries(
|
||||
timezones as Record<string, string>
|
||||
).map(([key, value]) => {
|
||||
const id = TIMEZONE_ID_CORRECTIONS[key] ?? key;
|
||||
return {
|
||||
id,
|
||||
primary: value,
|
||||
secondary: id,
|
||||
};
|
||||
});
|
||||
const options: PickerComboBoxItem[] = Array.from(
|
||||
new Map(
|
||||
getTimeZones({ includeUtc: true })
|
||||
.flatMap((timezone) => {
|
||||
const groupArray = Array.isArray(timezone.group)
|
||||
? timezone.group
|
||||
: [timezone.group];
|
||||
const filteredGroup = groupArray.filter((gName) =>
|
||||
timeZonesNames.includes(gName)
|
||||
);
|
||||
|
||||
return [timezone.name, ...filteredGroup].map((nameString) => ({
|
||||
id: nameString,
|
||||
primary: timezone.rawFormat,
|
||||
secondary: nameString,
|
||||
}));
|
||||
})
|
||||
.map((item) => [item.id, item])
|
||||
).values()
|
||||
);
|
||||
|
||||
for (const timezone of ADDITIONAL_TIMEZONES) {
|
||||
if (!options.some((option) => option.id === timezone.id)) {
|
||||
options.push(timezone);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Schema } from "js-yaml";
|
||||
import { DEFAULT_SCHEMA, dump, load } from "js-yaml";
|
||||
import { dump, load, YAML11_SCHEMA } from "js-yaml";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -30,7 +30,7 @@ const isEmpty = (obj: Record<string, unknown>): boolean => {
|
||||
export class HaYamlEditor extends LitElement {
|
||||
@property() public value?: any;
|
||||
|
||||
@property({ attribute: false }) public yamlSchema: Schema = DEFAULT_SCHEMA;
|
||||
@property({ attribute: false }) public yamlSchema: Schema = YAML11_SCHEMA;
|
||||
|
||||
@property({ attribute: false }) public defaultValue?: any;
|
||||
|
||||
@@ -70,7 +70,6 @@ export class HaYamlEditor extends LitElement {
|
||||
this._yaml = !isEmpty(value)
|
||||
? dump(value, {
|
||||
schema: this.yamlSchema,
|
||||
quotingType: '"',
|
||||
noRefs: true,
|
||||
})
|
||||
: "";
|
||||
|
||||
@@ -74,7 +74,7 @@ export interface MediaPlayerItemId {
|
||||
media_content_type?: string | undefined;
|
||||
}
|
||||
|
||||
const MANUAL_ITEM: MediaPlayerItem = {
|
||||
const MANUAL_ITEM_BASE: Omit<MediaPlayerItem, "title"> = {
|
||||
can_expand: true,
|
||||
can_play: false,
|
||||
can_search: false,
|
||||
@@ -83,7 +83,6 @@ const MANUAL_ITEM: MediaPlayerItem = {
|
||||
media_content_id: MANUAL_MEDIA_SOURCE_PREFIX,
|
||||
media_content_type: "",
|
||||
iconPath: mdiKeyboard,
|
||||
title: "Manual entry",
|
||||
};
|
||||
|
||||
@customElement("ha-media-player-browse")
|
||||
@@ -240,7 +239,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
currentId.media_content_id &&
|
||||
isManualMediaSourceContentId(currentId.media_content_id)
|
||||
) {
|
||||
this._currentItem = MANUAL_ITEM;
|
||||
this._currentItem = this._manualItem();
|
||||
fireEvent(this, "media-browsed", {
|
||||
ids: navigateIds,
|
||||
current: this._currentItem,
|
||||
@@ -801,12 +800,21 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
return prom.then((item) => {
|
||||
if (!mediaContentId && this.action === "pick") {
|
||||
item.children = item.children || [];
|
||||
item.children.push(MANUAL_ITEM);
|
||||
item.children.push(this._manualItem());
|
||||
}
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
private _manualItem(): MediaPlayerItem {
|
||||
return {
|
||||
...MANUAL_ITEM_BASE,
|
||||
title: this.hass.localize(
|
||||
"ui.components.selectors.selector.types.manual"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private _measureCard(): void {
|
||||
this.narrow = (this.dialog ? window.innerWidth : this.offsetWidth) < 450;
|
||||
}
|
||||
|
||||
+16
-3
@@ -1,6 +1,6 @@
|
||||
import { isComponentLoaded } from "../common/config/is_component_loaded";
|
||||
import { atLeastVersion } from "../common/config/version";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, LogFileDisabledReason } from "../types";
|
||||
import type { HassioAddonInfo } from "./hassio/addon";
|
||||
|
||||
export interface LogProvider {
|
||||
@@ -9,11 +9,24 @@ export interface LogProvider {
|
||||
addon?: HassioAddonInfo;
|
||||
}
|
||||
|
||||
const hasSupervisorCoreLogDownload = (hass: HomeAssistant): boolean =>
|
||||
isComponentLoaded(hass.config, "hassio") &&
|
||||
atLeastVersion(hass.config.version, 2025, 10);
|
||||
|
||||
export const fetchErrorLog = (hass: HomeAssistant) =>
|
||||
hass.callApi<string>("GET", "error_log");
|
||||
|
||||
export const getErrorLogDownloadUrl = (hass: HomeAssistant) =>
|
||||
isComponentLoaded(hass.config, "hassio") &&
|
||||
atLeastVersion(hass.config.version, 2025, 10)
|
||||
hasSupervisorCoreLogDownload(hass)
|
||||
? "/api/hassio/core/logs/latest"
|
||||
: "/api/error_log";
|
||||
|
||||
export const getCoreLogFileDownloadUnavailableReason = (
|
||||
hass: HomeAssistant
|
||||
): LogFileDisabledReason | undefined => {
|
||||
if (hasSupervisorCoreLogDownload(hass)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return hass.config.logging?.log_file_disabled_reason ?? undefined;
|
||||
};
|
||||
|
||||
+4
-1
@@ -144,7 +144,10 @@ export const subscribeLogbook = (
|
||||
}
|
||||
return hass.connection.subscribeMessage<LogbookStreamMessage>(
|
||||
(message) => callbackFunction(message, subscriptionId),
|
||||
params
|
||||
params,
|
||||
// Don't auto-resubscribe: the replay uses a stale start_time and ha-logbook
|
||||
// appends events without deduping, so it resubscribes on `ready` instead.
|
||||
{ resubscribe: false }
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { PropertyValues, ReactiveElement } from "lit";
|
||||
import { state } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { setupConditionListeners } from "../common/condition/listeners";
|
||||
import type { ConditionEvaluation } from "../common/controllers/condition-evaluator-controller";
|
||||
import { ConditionEvaluatorController } from "../common/controllers/condition-evaluator-controller";
|
||||
import { maxColumnsContext } from "../panels/lovelace/common/context";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
VisibilityCondition,
|
||||
} from "../panels/lovelace/common/validate-condition";
|
||||
import {
|
||||
addEntityToCondition,
|
||||
checkConditionsMet,
|
||||
} from "../panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
type Constructor<T> = abstract new (...args: any[]) => T;
|
||||
|
||||
@@ -20,22 +26,30 @@ export interface ConditionalConfig {
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixin to handle conditional listeners for visibility control
|
||||
* Mixin to handle conditional visibility control.
|
||||
*
|
||||
* Provides lifecycle management for listeners that control conditional
|
||||
* visibility of components.
|
||||
* Visibility conditions are evaluated by a {@link ConditionEvaluatorController}:
|
||||
* stateful conditions (`state`, `numeric_state`, `template`, `sun`, `zone`,
|
||||
* `device`, integration conditions) are delegated to core via
|
||||
* `subscribe_condition`, while client-only conditions (`screen`, `user`,
|
||||
* `view_columns`, `location`, `time`) are evaluated locally. The host stays
|
||||
* declarative — it never evaluates conditions itself.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Extend your component with ConditionalListenerMixin<YourConfigType>(ReactiveElement)
|
||||
* 2. Ensure component has config.visibility or _config.visibility property with conditions
|
||||
* 3. Ensure component has _updateVisibility() or _updateElement() method
|
||||
* 4. Override setupConditionalListeners() if custom behavior needed (e.g., filter conditions)
|
||||
* 1. Extend with `ConditionalListenerMixin<YourConfigType>(ReactiveElement)`.
|
||||
* 2. Provide conditions via `config.visibility` / `_config.visibility`, or by
|
||||
* overriding `setupConditionalListeners()` and calling
|
||||
* `super.setupConditionalListeners(customConditions)`.
|
||||
* 3. Implement `_updateVisibility()` (or `_updateElement()`) and have it derive
|
||||
* visibility from {@link _conditionsVisible} rather than evaluating
|
||||
* conditions directly.
|
||||
*
|
||||
* The mixin automatically:
|
||||
* - Sets up listeners when component connects to DOM
|
||||
* - Cleans up listeners when component disconnects from DOM
|
||||
* - Handles conditional visibility based on defined conditions
|
||||
* - Consumes column count from the view via Lit Context
|
||||
* - feeds the evaluator on connect and whenever `hass`, the config, or the
|
||||
* column count change;
|
||||
* - notifies the host (`_updateVisibility` / `_updateElement`) when the verdict
|
||||
* changes; and
|
||||
* - tears down subscriptions on disconnect (handled by the controller).
|
||||
*/
|
||||
export const ConditionalListenerMixin = <
|
||||
TConfig extends ConditionalConfig = ConditionalConfig,
|
||||
@@ -43,8 +57,6 @@ export const ConditionalListenerMixin = <
|
||||
superClass: Constructor<ReactiveElement>
|
||||
) => {
|
||||
abstract class ConditionalListenerClass extends superClass {
|
||||
private __listeners: (() => void)[] = [];
|
||||
|
||||
protected _config?: TConfig;
|
||||
|
||||
public config?: TConfig;
|
||||
@@ -57,6 +69,51 @@ export const ConditionalListenerMixin = <
|
||||
|
||||
protected _conditionContext: ConditionContext = {};
|
||||
|
||||
// The conditions currently being evaluated (a card/badge/section/view
|
||||
// `visibility`, or the conditional card/row `conditions`). Retained so the
|
||||
// optimistic synchronous seed evaluates exactly what the evaluator
|
||||
// subscribed to.
|
||||
private __conditions?: VisibilityCondition[];
|
||||
|
||||
// Latest server-aware verdict from the evaluator. `unknown` until a server
|
||||
// subtree first reports (or immediately for an all-client tree).
|
||||
private __conditionResult: ConditionEvaluation = "unknown";
|
||||
|
||||
// Cache for the entity-folded array fed to the evaluator. Rebuilt only when
|
||||
// the source tree reference or the entity context changes, so the
|
||||
// evaluator's reference-based signature memo keeps hitting on hass-only
|
||||
// updates instead of re-stringifying every tick.
|
||||
private __observedSource?: VisibilityCondition[];
|
||||
|
||||
private __observedEntityId?: string;
|
||||
|
||||
private __observed?: VisibilityCondition[];
|
||||
|
||||
// Value signature of the source tree, used to drop the cached verdict when
|
||||
// the tree changes by value so `_conditionsVisible` re-seeds for it.
|
||||
private __conditionsSignature?: string;
|
||||
|
||||
private __conditionEvaluator = new ConditionEvaluatorController(this, {
|
||||
// The synchronous seed in `_conditionsVisible` covers the initial frame,
|
||||
// so there is no need to delay (re)subscribing.
|
||||
resubscribeDelay: 0,
|
||||
onResult: (result) => {
|
||||
this.__conditionResult = result;
|
||||
// The forced `unknown` on disconnect only matters to hosts that render
|
||||
// the evaluator's result; we drive visibility imperatively, so ignore
|
||||
// notifications once detached.
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
const config = this._config || this.config;
|
||||
if (this._updateVisibility) {
|
||||
this._updateVisibility();
|
||||
} else if (this._updateElement && config) {
|
||||
this._updateElement(config);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
protected _updateElement?(config: TConfig): void;
|
||||
|
||||
protected _updateVisibility?(conditionsMet?: boolean): void;
|
||||
@@ -66,11 +123,6 @@ export const ConditionalListenerMixin = <
|
||||
this.setupConditionalListeners();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.clearConditionalListeners();
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues) {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has("_maxColumns")) {
|
||||
@@ -83,67 +135,105 @@ export const ConditionalListenerMixin = <
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has("_maxColumns")) {
|
||||
this._updateVisibility?.();
|
||||
// Re-feed the evaluator after the host has settled its inputs (e.g.
|
||||
// `_conditionContext.entity_id`, which consumers set in `willUpdate`).
|
||||
// The evaluator only re-subscribes when the *tree* changes; a
|
||||
// hass/context change merely recomputes.
|
||||
if (
|
||||
changedProperties.has("hass") ||
|
||||
changedProperties.has("config") ||
|
||||
changedProperties.has("_config") ||
|
||||
changedProperties.has("_maxColumns")
|
||||
) {
|
||||
this.setupConditionalListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear conditional listeners
|
||||
* Resolve the observed conditions to a visibility boolean.
|
||||
*
|
||||
* This method is called when the component is disconnected from the DOM.
|
||||
* It clears all the listeners that were set up by the setupConditionalListeners() method.
|
||||
* Prefers the evaluator's server-aware verdict; while a server subtree is
|
||||
* still pending (`unknown`) it falls back to an optimistic synchronous
|
||||
* client evaluation. That fallback is exact for the legacy lovelace
|
||||
* condition types (so existing dashboards never flash) and resolves to
|
||||
* hidden for core-only conditions (`template` / `sun` / …) until the server
|
||||
* reports — erring toward hiding rather than leaking content.
|
||||
*
|
||||
* Consumers call this from `_updateVisibility` instead of evaluating
|
||||
* `checkConditionsMet` themselves.
|
||||
*/
|
||||
protected clearConditionalListeners(): void {
|
||||
this.__listeners.forEach((unsub) => unsub());
|
||||
this.__listeners = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a conditional listener to the list of listeners
|
||||
*
|
||||
* This method is called when a new listener is added.
|
||||
* It adds the listener to the list of listeners.
|
||||
*
|
||||
* @param unsubscribe - The unsubscribe function to call when the listener is no longer needed
|
||||
* @returns void
|
||||
*/
|
||||
protected addConditionalListener(unsubscribe: () => void): void {
|
||||
this.__listeners.push(unsubscribe);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup conditional listeners for visibility control
|
||||
*
|
||||
* Default implementation:
|
||||
* - Checks config.visibility or _config.visibility for conditions (if not provided)
|
||||
* - Sets up appropriate listeners based on condition types
|
||||
* - Calls _updateVisibility() or _updateElement() when conditions change
|
||||
*
|
||||
* Override this method to customize behavior (e.g., filter conditions first)
|
||||
* and call super.setupConditionalListeners(customConditions) to reuse the base implementation
|
||||
*
|
||||
* @param conditions - Optional conditions array. If not provided, will check config.visibility or _config.visibility
|
||||
*/
|
||||
protected setupConditionalListeners(conditions?: Condition[]): void {
|
||||
const config = this.config || this._config;
|
||||
const finalConditions = conditions || config?.visibility;
|
||||
|
||||
if (!finalConditions || !this.hass) {
|
||||
return;
|
||||
protected _conditionsVisible(): boolean {
|
||||
const conditions = this.__conditions;
|
||||
if (!conditions || conditions.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
setupConditionListeners(
|
||||
finalConditions,
|
||||
if (this.__conditionResult !== "unknown") {
|
||||
return this.__conditionResult === "visible";
|
||||
}
|
||||
if (!this.hass) {
|
||||
return true;
|
||||
}
|
||||
return checkConditionsMet(
|
||||
conditions as Condition[],
|
||||
this.hass,
|
||||
this._conditionContext
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed the current conditions to the evaluator.
|
||||
*
|
||||
* Override to supply a custom condition set (e.g. the conditional card's
|
||||
* `conditions`) and call `super.setupConditionalListeners(customConditions)`.
|
||||
*
|
||||
* @param conditions - Optional conditions. Defaults to
|
||||
* `config.visibility` / `_config.visibility`.
|
||||
*/
|
||||
protected setupConditionalListeners(
|
||||
conditions?: VisibilityCondition[]
|
||||
): void {
|
||||
// Prefer the resolved `_config` (e.g. a strategy-generated section config)
|
||||
// over the raw `config`, matching the pre-refactor evaluation source.
|
||||
const config = this._config || this.config;
|
||||
const finalConditions =
|
||||
conditions ?? (config?.visibility as VisibilityCondition[] | undefined);
|
||||
const entityId = this._conditionContext.entity_id;
|
||||
|
||||
this.__conditions = finalConditions;
|
||||
|
||||
// Re-derive the entity-folded array only when the source tree reference or
|
||||
// the entity context actually changes — not on every hass tick — so the
|
||||
// evaluator keeps seeing a stable array reference and its signature memo
|
||||
// keeps hitting. The evaluator translates to core format with no notion of
|
||||
// the host's `entity_id` context, so fold it in here (mirroring
|
||||
// `checkConditionsMet`, which reads `entity_id || entity || context`).
|
||||
if (
|
||||
finalConditions !== this.__observedSource ||
|
||||
entityId !== this.__observedEntityId
|
||||
) {
|
||||
// When the tree changes by *value*, drop the cached verdict so
|
||||
// `_conditionsVisible` re-seeds for the new tree instead of reusing the
|
||||
// previous tree's result for a frame.
|
||||
const signature = finalConditions
|
||||
? JSON.stringify(finalConditions)
|
||||
: undefined;
|
||||
if (signature !== this.__conditionsSignature) {
|
||||
this.__conditionsSignature = signature;
|
||||
this.__conditionResult = "unknown";
|
||||
}
|
||||
this.__observedSource = finalConditions;
|
||||
this.__observedEntityId = entityId;
|
||||
this.__observed =
|
||||
finalConditions && entityId
|
||||
? ((finalConditions as Condition[]).map((c) =>
|
||||
addEntityToCondition(c, entityId)
|
||||
) as VisibilityCondition[])
|
||||
: finalConditions;
|
||||
}
|
||||
|
||||
this.__conditionEvaluator.observe(
|
||||
this.__observed,
|
||||
this.hass,
|
||||
(unsub) => this.addConditionalListener(unsub),
|
||||
(conditionsMet) => {
|
||||
if (this._updateVisibility) {
|
||||
this._updateVisibility(conditionsMet);
|
||||
} else if (this._updateElement && config) {
|
||||
this._updateElement(config);
|
||||
}
|
||||
},
|
||||
() => this._conditionContext
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import "../../../layouts/hass-tabs-subpage-data-table";
|
||||
import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import { showAddApplicationCredentialDialog } from "./show-dialog-add-application-credential";
|
||||
|
||||
@customElement("ha-config-application-credentials")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mdiDotsVertical } from "@mdi/js";
|
||||
import { DEFAULT_SCHEMA, Type } from "js-yaml";
|
||||
import { defineScalarTag, YAML11_SCHEMA } from "js-yaml";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -47,12 +47,11 @@ const SUPPORTED_UI_TYPES = [
|
||||
"schema",
|
||||
];
|
||||
|
||||
const ADDON_YAML_SCHEMA = DEFAULT_SCHEMA.extend([
|
||||
new Type("!secret", {
|
||||
kind: "scalar",
|
||||
construct: (data) => `!secret ${data}`,
|
||||
}),
|
||||
]);
|
||||
const secretTag = defineScalarTag("!secret", {
|
||||
resolve: (data) => `!secret ${data}`,
|
||||
});
|
||||
|
||||
const ADDON_YAML_SCHEMA = YAML11_SCHEMA.withTags(secretTag);
|
||||
|
||||
const MASKED_FIELDS = ["password", "secret", "token"];
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
import "../../../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import {
|
||||
loadAreaRegistryDetailDialog,
|
||||
showAreaRegistryDetailDialog,
|
||||
|
||||
@@ -214,6 +214,7 @@ export class HaAutomationAddItems extends LitElement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.items.blank {
|
||||
border-radius: var(--ha-border-radius-xl);
|
||||
|
||||
@@ -120,7 +120,7 @@ import {
|
||||
getLabelsTableColumn,
|
||||
getTriggeredAtTableColumn,
|
||||
} from "../common/data-table-columns";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
|
||||
import {
|
||||
getAssistantsSortableKey,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { load } from "js-yaml";
|
||||
import { load, YAML11_SCHEMA } from "js-yaml";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, queryAll } from "lit/decorators";
|
||||
@@ -224,7 +224,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
|
||||
|
||||
let loaded: any;
|
||||
try {
|
||||
loaded = load(paste);
|
||||
loaded = load(paste, { schema: YAML11_SCHEMA });
|
||||
} catch (_err: any) {
|
||||
showEditorToast(this, {
|
||||
message: this.hass.localize(
|
||||
|
||||
@@ -52,7 +52,7 @@ import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { documentationUrl } from "../../../util/documentation-url";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import { configSections } from "../ha-panel-config";
|
||||
import { configSections } from "../config-sections";
|
||||
import { showAddBlueprintDialog } from "./show-dialog-import-blueprint";
|
||||
|
||||
type BlueprintMetaDataPath = BlueprintMetaData & {
|
||||
|
||||
@@ -41,7 +41,9 @@ export class DialogSupportPackage extends LitElement {
|
||||
<ha-dialog
|
||||
.open=${this._open}
|
||||
width="full"
|
||||
header-title="Download support package"
|
||||
.headerTitle=${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.download_support_package"
|
||||
)}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
${this._supportPackage
|
||||
@@ -52,13 +54,16 @@ export class DialogSupportPackage extends LitElement {
|
||||
: html`
|
||||
<div class="progress-container">
|
||||
<ha-spinner></ha-spinner>
|
||||
Generating preview...
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.support_package_generating_preview"
|
||||
)}...
|
||||
</div>
|
||||
`}
|
||||
<div slot="footer" class="footer">
|
||||
<ha-alert>
|
||||
This file may contain personal data about your home. Avoid sharing
|
||||
them with unverified or untrusted parties.
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.cloud.account.support_package_privacy_warning"
|
||||
)}
|
||||
</ha-alert>
|
||||
<hr />
|
||||
<ha-dialog-footer>
|
||||
@@ -67,10 +72,10 @@ export class DialogSupportPackage extends LitElement {
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
Close
|
||||
${this.hass.localize("ui.common.close")}
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" @click=${this._download}>
|
||||
Download
|
||||
${this.hass.localize("ui.common.download")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</div>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
@@ -133,6 +133,23 @@ import {
|
||||
import { getAvailableAssistants } from "../voice-assistants/expose/available-assistants";
|
||||
import { isHelperDomain, type HelperDomain } from "./const";
|
||||
import { showHelperDetailDialog } from "./show-dialog-helper-detail";
|
||||
import { computeDomain } from "../../../common/entity/compute_domain";
|
||||
|
||||
interface LimitedEntity {
|
||||
entity_id: HassEntity["entity_id"];
|
||||
attributes: {
|
||||
friendly_name?: HassEntity["attributes"]["friendly_name"];
|
||||
editable?: HassEntity["attributes"]["editable"];
|
||||
};
|
||||
}
|
||||
function equalLimitedEntity(a: LimitedEntity, b: LimitedEntity): boolean {
|
||||
return (
|
||||
a === b ||
|
||||
(a.entity_id === b.entity_id &&
|
||||
a.attributes?.friendly_name === b.attributes?.friendly_name &&
|
||||
a.attributes?.editable === b.attributes?.editable)
|
||||
);
|
||||
}
|
||||
|
||||
interface HelperItem {
|
||||
id: string;
|
||||
@@ -142,7 +159,7 @@ interface HelperItem {
|
||||
editable?: boolean;
|
||||
type: string;
|
||||
configEntry?: ConfigEntry;
|
||||
entity?: HassEntity;
|
||||
entity?: LimitedEntity;
|
||||
category: string | undefined;
|
||||
area?: string;
|
||||
label_entries: LabelRegistryEntry[];
|
||||
@@ -221,7 +238,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
})
|
||||
private _activeHiddenColumns?: string[];
|
||||
|
||||
@state() private _helperEntities?: HassEntity[];
|
||||
@state() private _helperEntities?: LimitedEntity[];
|
||||
|
||||
@state() private _disabledEntityEntries?: EntityRegistryEntry[];
|
||||
|
||||
@@ -338,7 +355,9 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
moveable: false,
|
||||
template: (helper) =>
|
||||
helper.entity
|
||||
? html`<ha-state-icon .stateObj=${helper.entity}></ha-state-icon>`
|
||||
? html`<ha-state-icon
|
||||
.entityId=${helper.entity_id}
|
||||
></ha-state-icon>`
|
||||
: html`<ha-svg-icon
|
||||
.path=${helper.icon}
|
||||
style="color: var(--error-color)"
|
||||
@@ -465,7 +484,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
private _getItems = memoizeOne(
|
||||
(
|
||||
localize: LocalizeFunc,
|
||||
stateItems: HassEntity[],
|
||||
stateItems: LimitedEntity[],
|
||||
disabledEntries: EntityRegistryEntry[],
|
||||
entityEntries: Record<string, EntityRegistryEntry>,
|
||||
configEntries: Record<string, ConfigEntry>,
|
||||
@@ -500,7 +519,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
|
||||
type: configEntry
|
||||
? configEntry.domain
|
||||
: this._entitySource![entityState.entity_id] ||
|
||||
computeStateDomain(entityState),
|
||||
computeDomain(entityState.entity_id),
|
||||
configEntry,
|
||||
entity: entityState,
|
||||
};
|
||||
@@ -1269,7 +1288,9 @@ ${rejected
|
||||
if (
|
||||
!this._helperEntities ||
|
||||
this._helperEntities.length !== newHelpers.length ||
|
||||
!this._helperEntities.every((val, idx) => newHelpers[idx] === val)
|
||||
!this._helperEntities.every((val, idx) =>
|
||||
equalLimitedEntity(newHelpers[idx], val)
|
||||
)
|
||||
) {
|
||||
this._helperEntities = newHelpers;
|
||||
if (Object.keys(this._filters).length > 0) {
|
||||
|
||||
@@ -47,7 +47,10 @@ import { fetchDiagnosticHandler } from "../../../data/diagnostics";
|
||||
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
|
||||
import { subscribeEntityRegistry } from "../../../data/entity/entity_registry";
|
||||
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
|
||||
import { getErrorLogDownloadUrl } from "../../../data/error_log";
|
||||
import {
|
||||
getCoreLogFileDownloadUnavailableReason,
|
||||
getErrorLogDownloadUrl,
|
||||
} from "../../../data/error_log";
|
||||
import type {
|
||||
IntegrationLogInfo,
|
||||
IntegrationManifest,
|
||||
@@ -1179,6 +1182,20 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
|
||||
LogSeverity[LogSeverity.NOTSET],
|
||||
"once"
|
||||
);
|
||||
const logFileDownloadUnavailableReason =
|
||||
getCoreLogFileDownloadUnavailableReason(this.hass);
|
||||
if (logFileDownloadUnavailableReason) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.logs.log_file_disabled_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
`ui.panel.config.logs.log_file_disabled_debug_download.${logFileDownloadUnavailableReason}`
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const timeString = new Date().toISOString().replace(/:/g, "-");
|
||||
const logFileName = `home-assistant_${integration}_${timeString}.log`;
|
||||
const signedUrl = await getSignedPath(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -45,7 +45,11 @@ import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import type { ConnectionStatus } from "../../../data/connection-status";
|
||||
import { fetchErrorLog, getErrorLogDownloadUrl } from "../../../data/error_log";
|
||||
import {
|
||||
fetchErrorLog,
|
||||
getCoreLogFileDownloadUnavailableReason,
|
||||
getErrorLogDownloadUrl,
|
||||
} from "../../../data/error_log";
|
||||
import { extractApiErrorMessage } from "../../../data/hassio/common";
|
||||
import {
|
||||
fetchHassioBoots,
|
||||
@@ -131,6 +135,11 @@ class ErrorLogCard extends LitElement {
|
||||
const hasBoots = this._streamSupported && Array.isArray(this._boots);
|
||||
|
||||
const localize = this.localizeFunc || this.hass.localize;
|
||||
const logFileDownloadUnavailableReason =
|
||||
!this.provider || this.provider === "core"
|
||||
? getCoreLogFileDownloadUnavailableReason(this.hass)
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<div class="error-log-intro">
|
||||
${this._error
|
||||
@@ -187,11 +196,13 @@ class ErrorLogCard extends LitElement {
|
||||
</ha-dropdown>
|
||||
`
|
||||
: nothing}
|
||||
<ha-icon-button
|
||||
.path=${mdiDownload}
|
||||
@click=${this._downloadLogs}
|
||||
.label=${localize("ui.panel.config.logs.download_logs")}
|
||||
></ha-icon-button>
|
||||
${logFileDownloadUnavailableReason
|
||||
? nothing
|
||||
: html`<ha-icon-button
|
||||
.path=${mdiDownload}
|
||||
@click=${this._downloadLogs}
|
||||
.label=${localize("ui.panel.config.logs.download_logs")}
|
||||
></ha-icon-button>`}
|
||||
<ha-icon-button
|
||||
.path=${this._wrapLines ? mdiWrapDisabled : mdiWrap}
|
||||
@click=${this._toggleLineWrap}
|
||||
@@ -248,11 +259,21 @@ class ErrorLogCard extends LitElement {
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>`
|
||||
: nothing}
|
||||
${this._loadingState === "loading"
|
||||
? html`<div>${localize("ui.panel.config.logs.loading_log")}</div>`
|
||||
: this._loadingState === "empty"
|
||||
? html`<div>${localize("ui.panel.config.logs.no_errors")}</div>`
|
||||
: nothing}
|
||||
${logFileDownloadUnavailableReason
|
||||
? html`<ha-alert alert-type="warning">
|
||||
${localize(
|
||||
`ui.panel.config.logs.log_file_disabled.${logFileDownloadUnavailableReason}`
|
||||
)}
|
||||
</ha-alert>`
|
||||
: this._loadingState === "loading"
|
||||
? html`<div>
|
||||
${localize("ui.panel.config.logs.loading_log")}
|
||||
</div>`
|
||||
: this._loadingState === "empty"
|
||||
? html`<div>
|
||||
${localize("ui.panel.config.logs.no_errors")}
|
||||
</div>`
|
||||
: nothing}
|
||||
${this._loadingState === "loaded" &&
|
||||
this.filter &&
|
||||
this._noSearchResults
|
||||
@@ -367,6 +388,13 @@ class ErrorLogCard extends LitElement {
|
||||
}
|
||||
|
||||
private async _downloadLogs(): Promise<void> {
|
||||
if (
|
||||
(!this.provider || this.provider === "core") &&
|
||||
getCoreLogFileDownloadUnavailableReason(this.hass)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._streamSupported && this.provider) {
|
||||
showDownloadLogsDialog(this, {
|
||||
header: this.header,
|
||||
@@ -400,6 +428,14 @@ class ErrorLogCard extends LitElement {
|
||||
this._ansiToHtmlElement?.clear();
|
||||
}
|
||||
|
||||
if (
|
||||
(!this.provider || this.provider === "core") &&
|
||||
getCoreLogFileDownloadUnavailableReason(this.hass)
|
||||
) {
|
||||
this._loadingState = "loaded";
|
||||
return;
|
||||
}
|
||||
|
||||
const streamLogs =
|
||||
this._streamSupported &&
|
||||
isComponentLoaded(this.hass.config, "hassio") &&
|
||||
|
||||
@@ -6,6 +6,7 @@ import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/buttons/ha-call-service-button";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
@@ -14,7 +15,10 @@ import "../../../components/ha-list";
|
||||
import "../../../components/ha-list-item";
|
||||
import "../../../components/ha-spinner";
|
||||
import { getSignedPath } from "../../../data/auth";
|
||||
import { getErrorLogDownloadUrl } from "../../../data/error_log";
|
||||
import {
|
||||
getCoreLogFileDownloadUnavailableReason,
|
||||
getErrorLogDownloadUrl,
|
||||
} from "../../../data/error_log";
|
||||
import { domainToName } from "../../../data/integration";
|
||||
import type { LoggedError } from "../../../data/system_log";
|
||||
import {
|
||||
@@ -88,6 +92,8 @@ export class SystemLogCard extends LitElement {
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const logFileDownloadUnavailableReason =
|
||||
getCoreLogFileDownloadUnavailableReason(this.hass);
|
||||
const filteredItems = this._items
|
||||
? this._getFilteredItems(
|
||||
this.hass.localize,
|
||||
@@ -109,36 +115,55 @@ export class SystemLogCard extends LitElement {
|
||||
`
|
||||
: html`
|
||||
<div class="header">
|
||||
<h1 class="card-header">${this.header || "Logs"}</h1>
|
||||
<h1 class="card-header">
|
||||
${this.header ||
|
||||
this.hass.localize("ui.panel.config.logs.caption")}
|
||||
</h1>
|
||||
<div class="header-buttons">
|
||||
<ha-icon-button
|
||||
.path=${mdiDownload}
|
||||
@click=${this._downloadLogs}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.logs.download_logs"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
${logFileDownloadUnavailableReason
|
||||
? nothing
|
||||
: html`<ha-icon-button
|
||||
.path=${mdiDownload}
|
||||
@click=${this._downloadLogs}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.logs.download_logs"
|
||||
)}
|
||||
></ha-icon-button>`}
|
||||
<ha-icon-button
|
||||
.path=${mdiRefresh}
|
||||
@click=${this.fetchData}
|
||||
.label=${this.hass.localize("ui.common.refresh")}
|
||||
></ha-icon-button>
|
||||
|
||||
<ha-dropdown @wa-select=${this._handleOverflowAction}>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.path=${mdiDotsVertical}
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
></ha-icon-button>
|
||||
<ha-dropdown-item value="show-full-logs">
|
||||
<ha-svg-icon slot="icon" .path=${mdiText}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.logs.show_full_logs"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
${logFileDownloadUnavailableReason
|
||||
? nothing
|
||||
: html`<ha-dropdown
|
||||
@wa-select=${this._handleOverflowAction}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="trigger"
|
||||
.path=${mdiDotsVertical}
|
||||
.label=${this.hass.localize("ui.common.menu")}
|
||||
></ha-icon-button>
|
||||
<ha-dropdown-item value="show-full-logs">
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiText}
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.logs.show_full_logs"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>`}
|
||||
</div>
|
||||
</div>
|
||||
${logFileDownloadUnavailableReason
|
||||
? html`<ha-alert alert-type="warning">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.logs.log_file_disabled.${logFileDownloadUnavailableReason}`
|
||||
)}
|
||||
</ha-alert>`
|
||||
: nothing}
|
||||
${this._items.length === 0
|
||||
? html`
|
||||
<div class="card-content empty-content">
|
||||
@@ -229,6 +254,10 @@ export class SystemLogCard extends LitElement {
|
||||
}
|
||||
|
||||
private async _downloadLogs() {
|
||||
if (getCoreLogFileDownloadUnavailableReason(this.hass)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeString = new Date().toISOString().replace(/:/g, "-");
|
||||
const downloadUrl = getErrorLogDownloadUrl(this.hass);
|
||||
const logFileName = `home-assistant_${timeString}.log`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -197,7 +197,9 @@ export class HaScriptTrace extends LitElement {
|
||||
</div>
|
||||
|
||||
${this._traces === undefined
|
||||
? html`<div class="container">Loading…</div>`
|
||||
? html`<div class="container">
|
||||
${this.hass.localize("ui.panel.config.script.trace.loading")}…
|
||||
</div>`
|
||||
: this._traces.length === 0
|
||||
? html`<div class="container">
|
||||
${this.hass!.localize(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mdiHelpCircleOutline } from "@mdi/js";
|
||||
import { load } from "js-yaml";
|
||||
import { load, YAML11_SCHEMA } from "js-yaml";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, query, queryAll } from "lit/decorators";
|
||||
@@ -197,7 +197,7 @@ export class HaManualScriptEditor extends ManualEditorMixin<ScriptConfig>(
|
||||
|
||||
let loaded: any;
|
||||
try {
|
||||
loaded = load(paste);
|
||||
loaded = load(paste, { schema: YAML11_SCHEMA });
|
||||
} catch (_err: any) {
|
||||
showEditorToast(this, {
|
||||
message: this.hass.localize(
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiPuzzle, mdiRobot, mdiScriptText } from "@mdi/js";
|
||||
import { mdiCast, mdiCloud, mdiPuzzle, mdiRobot, mdiScriptText } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
@@ -17,6 +17,7 @@ import "../../components/ha-relative-time";
|
||||
import "../../components/ha-domain-icon";
|
||||
import "../../components/ha-state-icon";
|
||||
import "../../components/ha-svg-icon";
|
||||
import "../../components/ha-tooltip";
|
||||
import "../../components/user/ha-user-badge";
|
||||
import { UNAVAILABLE } from "../../data/entity/entity";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
@@ -47,6 +48,12 @@ interface LogbookRenderItem extends LogbookItem {
|
||||
renderedValue: TemplateResult | string;
|
||||
}
|
||||
|
||||
// Names are the fixed system user names set by core (cloud/cast integrations).
|
||||
const SYSTEM_USER_ICONS: Record<string, string> = {
|
||||
"Home Assistant Cloud": mdiCloud,
|
||||
"Home Assistant Cast": mdiCast,
|
||||
};
|
||||
|
||||
@customElement("ha-logbook-entry")
|
||||
class HaLogbookEntry extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -56,6 +63,8 @@ class HaLogbookEntry extends LitElement {
|
||||
@property({ attribute: false }) public userIdToName: Record<string, string> =
|
||||
{};
|
||||
|
||||
@property({ attribute: false }) public systemUserIds = new Set<string>();
|
||||
|
||||
@property({ attribute: false }) public traceContexts: TraceContexts = {};
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@@ -87,6 +96,7 @@ class HaLogbookEntry extends LitElement {
|
||||
const item = computeLogbookItem(this.hass, entry, {
|
||||
nameDetail: this.nameDetail,
|
||||
userIdToName: this.userIdToName,
|
||||
systemUserIds: this.systemUserIds,
|
||||
});
|
||||
|
||||
const traceContext =
|
||||
@@ -208,9 +218,10 @@ class HaLogbookEntry extends LitElement {
|
||||
) {
|
||||
return html`<span class="trailing">
|
||||
${cause
|
||||
? html`<span class="cause-badge" title=${cause.name}
|
||||
>${this._renderCauseIcon(cause)}</span
|
||||
>`
|
||||
? html`<ha-tooltip for="cause-badge">${cause.name}</ha-tooltip>
|
||||
<span class="cause-badge" id="cause-badge"
|
||||
>${this._renderCauseIcon(cause)}</span
|
||||
>`
|
||||
: nothing}
|
||||
${traceLink ? this._renderTraceLink(traceLink) : nothing}
|
||||
${this._renderTimeChip(renderedTime)}
|
||||
@@ -443,6 +454,15 @@ class HaLogbookEntry extends LitElement {
|
||||
|
||||
private _renderCauseIcon(cause: LogbookCause) {
|
||||
if (cause.type === "user") {
|
||||
const systemIcon = cause.systemUser
|
||||
? SYSTEM_USER_ICONS[cause.name]
|
||||
: undefined;
|
||||
if (systemIcon) {
|
||||
return html`<ha-svg-icon
|
||||
class="cause-icon"
|
||||
.path=${systemIcon}
|
||||
></ha-svg-icon>`;
|
||||
}
|
||||
return html`<ha-user-badge
|
||||
class="cause-icon cause-avatar"
|
||||
.user=${{ id: cause.userId!, name: cause.name } as User}
|
||||
|
||||
@@ -30,6 +30,8 @@ class HaLogbookRenderer extends LitElement {
|
||||
@property({ attribute: false }) public userIdToName: Record<string, string> =
|
||||
{};
|
||||
|
||||
@property({ attribute: false }) public systemUserIds = new Set<string>();
|
||||
|
||||
@property({ attribute: false }) public traceContexts: TraceContexts = {};
|
||||
|
||||
@property({ attribute: false }) public entries: LogbookEntry[] = [];
|
||||
@@ -134,6 +136,7 @@ class HaLogbookRenderer extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.item=${item}
|
||||
.userIdToName=${this.userIdToName}
|
||||
.systemUserIds=${this.systemUserIds}
|
||||
.traceContexts=${this.traceContexts}
|
||||
.narrow=${this.narrow}
|
||||
.noIcon=${this.noIcon}
|
||||
|
||||
@@ -81,6 +81,8 @@ export class HaLogbook extends LitElement {
|
||||
|
||||
@state() private _userIdToName = {};
|
||||
|
||||
@state() private _systemUserIds = new Set<string>();
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
private _unsubLogbook?: Promise<UnsubscribeFunc>;
|
||||
@@ -96,6 +98,8 @@ export class HaLogbook extends LitElement {
|
||||
|
||||
private _logbookSubscriptionId = 0;
|
||||
|
||||
private _readyListenerAttached = false;
|
||||
|
||||
protected render() {
|
||||
if (!isComponentLoaded(this.hass.config, "logbook")) {
|
||||
return nothing;
|
||||
@@ -135,6 +139,7 @@ export class HaLogbook extends LitElement {
|
||||
.entries=${this._logbookEntries}
|
||||
.traceContexts=${this._traceContexts}
|
||||
.userIdToName=${this._userIdToName}
|
||||
.systemUserIds=${this._systemUserIds}
|
||||
@hass-logbook-live=${this._handleLogbookLive}
|
||||
></ha-logbook-renderer>
|
||||
`;
|
||||
@@ -241,6 +246,7 @@ export class HaLogbook extends LitElement {
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._attachReadyListener();
|
||||
if (this.hasUpdated) {
|
||||
// Ensure clean state before subscribing
|
||||
this._subscribeLogbookPeriod(this._calculateLogbookPeriod());
|
||||
@@ -249,9 +255,43 @@ export class HaLogbook extends LitElement {
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._detachReadyListener();
|
||||
this._unsubscribe(true);
|
||||
}
|
||||
|
||||
private _attachReadyListener(): void {
|
||||
if (this._readyListenerAttached || !this.hass) {
|
||||
return;
|
||||
}
|
||||
this.hass.connection.addEventListener("ready", this._handleConnectionReady);
|
||||
this._readyListenerAttached = true;
|
||||
}
|
||||
|
||||
private _detachReadyListener(): void {
|
||||
if (!this._readyListenerAttached) {
|
||||
return;
|
||||
}
|
||||
this.hass?.connection.removeEventListener(
|
||||
"ready",
|
||||
this._handleConnectionReady
|
||||
);
|
||||
this._readyListenerAttached = false;
|
||||
}
|
||||
|
||||
private _handleConnectionReady = () => {
|
||||
// The old subscription died with the dropped connection and isn't restored
|
||||
// server-side. Drop the stale handle and resubscribe from scratch, else the
|
||||
// replayed history would duplicate the entries we already have.
|
||||
if (!this._unsubLogbook) {
|
||||
return;
|
||||
}
|
||||
this._unsubLogbook = undefined;
|
||||
this._logbookEntries = undefined;
|
||||
this._pendingStreamMessages = [];
|
||||
this._liveUpdatesEnabled = true;
|
||||
this._throttleGetLogbookEntries();
|
||||
};
|
||||
|
||||
private _calculateLogbookPeriod() {
|
||||
const now = new Date();
|
||||
if ("range" in this.time) {
|
||||
@@ -285,6 +325,9 @@ export class HaLogbook extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// connectedCallback may have run before hass was set; attach now.
|
||||
this._attachReadyListener();
|
||||
|
||||
try {
|
||||
this._logbookSubscriptionId++;
|
||||
|
||||
@@ -415,6 +458,7 @@ export class HaLogbook extends LitElement {
|
||||
|
||||
private _updateUsers = throttle(async () => {
|
||||
const userIdToName = {};
|
||||
const systemUserIds = new Set<string>();
|
||||
|
||||
// Start loading users
|
||||
const userProm = this.hass.user?.is_admin && fetchUsers(this.hass);
|
||||
@@ -437,10 +481,14 @@ export class HaLogbook extends LitElement {
|
||||
if (!(user.id in userIdToName)) {
|
||||
userIdToName[user.id] = user.name;
|
||||
}
|
||||
if (user.system_generated) {
|
||||
systemUserIds.add(user.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._userIdToName = userIdToName;
|
||||
this._systemUserIds = systemUserIds;
|
||||
}, 60000);
|
||||
|
||||
static get styles() {
|
||||
|
||||
@@ -126,6 +126,7 @@ export interface LogbookCause {
|
||||
type: LogbookCauseType;
|
||||
name: string;
|
||||
userId?: string;
|
||||
systemUser?: boolean;
|
||||
entityId?: string;
|
||||
brandDomain?: string;
|
||||
}
|
||||
@@ -133,13 +134,19 @@ export interface LogbookCause {
|
||||
export const computeLogbookCause = (
|
||||
hass: HomeAssistant,
|
||||
item: LogbookEntry,
|
||||
userIdToName: Record<string, string>
|
||||
userIdToName: Record<string, string>,
|
||||
systemUserIds?: Set<string>
|
||||
): LogbookCause | undefined => {
|
||||
const userName = item.context_user_id
|
||||
? userIdToName[item.context_user_id]
|
||||
: undefined;
|
||||
if (userName) {
|
||||
return { type: "user", name: userName, userId: item.context_user_id };
|
||||
return {
|
||||
type: "user",
|
||||
name: userName,
|
||||
userId: item.context_user_id,
|
||||
systemUser: systemUserIds?.has(item.context_user_id!),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -312,6 +319,7 @@ export interface LogbookItem {
|
||||
export interface BuildLogbookItemOptions {
|
||||
nameDetail?: LogbookNameDetail;
|
||||
userIdToName?: Record<string, string>;
|
||||
systemUserIds?: Set<string>;
|
||||
}
|
||||
|
||||
export const computeLogbookItem = (
|
||||
@@ -341,7 +349,12 @@ export const computeLogbookItem = (
|
||||
name: display?.primary ?? entry.name,
|
||||
context: display?.secondary,
|
||||
value: computeLogbookValue(hass, entry, domain, historicStateObj),
|
||||
cause: computeLogbookCause(hass, entry, opts.userIdToName ?? {}),
|
||||
cause: computeLogbookCause(
|
||||
hass,
|
||||
entry,
|
||||
opts.userIdToName ?? {},
|
||||
opts.systemUserIds
|
||||
),
|
||||
when: entry.when * 1000,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import { getConfigEntityId } from "../common/get-config-entity-id";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
import { createBadgeElement } from "../create-element/create-badge-element";
|
||||
import { createErrorBadgeConfig } from "../create-element/create-element-base";
|
||||
import type { LovelaceBadge } from "../types";
|
||||
@@ -165,14 +164,7 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
|
||||
return;
|
||||
}
|
||||
|
||||
const visible =
|
||||
conditionsMet ??
|
||||
(!this.config?.visibility ||
|
||||
checkConditionsMet(
|
||||
this.config.visibility,
|
||||
this.hass,
|
||||
this._conditionContext
|
||||
));
|
||||
const visible = conditionsMet ?? this._conditionsVisible();
|
||||
this._setElementVisibility(visible);
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,9 @@ export class HuiErrorBadge extends LitElement implements LovelaceBadge {
|
||||
class="error"
|
||||
@click=${this._viewDetail}
|
||||
type="button"
|
||||
label="Error"
|
||||
.label=${this.hass?.localize(
|
||||
"ui.panel.lovelace.editor.error_section.title"
|
||||
) ?? ""}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiAlertCircle}></ha-svg-icon>
|
||||
<div class="content">${this._config.error}</div>
|
||||
|
||||
@@ -135,7 +135,11 @@ class HuiHistoryChartCardFeature
|
||||
if (this._coordinates && !this._coordinates.length) {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="info">No state history found.</div>
|
||||
<div class="info">
|
||||
${this.hass!.localize(
|
||||
"ui.components.history_charts.no_history_found"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -303,6 +303,43 @@ class HuiEnergySankeyCard
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full consumption
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
if (untracked > 0) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untracked,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
|
||||
@@ -445,6 +445,44 @@ class HuiPowerSankeyCard
|
||||
deviceNodes.push(otherNode);
|
||||
}
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full power
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
// only show if larger than 1W
|
||||
if (untracked > 1) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untracked,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-m
|
||||
import { migrateLayoutToGridOptions } from "../common/compute-card-grid-size";
|
||||
import { computeCardSize } from "../common/compute-card-size";
|
||||
import { getConfigEntityId } from "../common/get-config-entity-id";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
import { tryCreateCardElement } from "../create-element/create-card-element";
|
||||
import { createErrorCardElement } from "../create-element/create-element-base";
|
||||
import type { LovelaceCard, LovelaceGridOptions } from "../types";
|
||||
@@ -262,14 +261,7 @@ export class HuiCard extends ConditionalListenerMixin<LovelaceCardConfig>(
|
||||
return;
|
||||
}
|
||||
|
||||
const visible =
|
||||
conditionsMet ??
|
||||
(!this.config?.visibility ||
|
||||
checkConditionsMet(
|
||||
this.config.visibility,
|
||||
this.hass,
|
||||
this._conditionContext
|
||||
));
|
||||
const visible = conditionsMet ?? this._conditionsVisible();
|
||||
this._setElementVisibility(visible);
|
||||
}
|
||||
|
||||
|
||||
@@ -383,6 +383,43 @@ class HuiWaterFlowSankeyCard
|
||||
}
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full flow
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
// only show if larger than 1 L/min
|
||||
if (untracked > 1) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untracked,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
|
||||
@@ -246,6 +246,43 @@ class HuiWaterSankeyCard
|
||||
}
|
||||
deviceNodes.push(node);
|
||||
});
|
||||
|
||||
// Add untracked consumption nodes for parent devices whose sub-devices
|
||||
// don't account for the parent's full consumption
|
||||
const parentDeviceIds = new Set(Object.values(parentLinks));
|
||||
parentDeviceIds.forEach((parentId) => {
|
||||
const parentNode = deviceNodes.find((node) => node.id === parentId);
|
||||
if (!parentNode) {
|
||||
return;
|
||||
}
|
||||
const childrenSum = deviceNodes.reduce(
|
||||
(sum, node) =>
|
||||
parentLinks[node.id] === parentId ? sum + node.value : sum,
|
||||
0
|
||||
);
|
||||
const untracked = parentNode.value - childrenSum;
|
||||
if (untracked > 0) {
|
||||
const untrackedNodeId = `untracked_${parentId}`;
|
||||
deviceNodes.push({
|
||||
id: untrackedNodeId,
|
||||
label: this.hass.localize(
|
||||
"ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked_consumption"
|
||||
),
|
||||
value: untracked,
|
||||
color: computedStyle
|
||||
.getPropertyValue("--state-unavailable-color")
|
||||
.trim(),
|
||||
index: 4,
|
||||
});
|
||||
parentLinks[untrackedNodeId] = parentId;
|
||||
links.push({
|
||||
source: parentId,
|
||||
target: untrackedNodeId,
|
||||
value: untracked,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const devicesWithoutParent = deviceNodes.filter(
|
||||
(node) => !parentLinks[node.id]
|
||||
);
|
||||
|
||||
@@ -2,17 +2,23 @@ import {
|
||||
mdiAccount,
|
||||
mdiAmpersand,
|
||||
mdiCalendarClock,
|
||||
mdiCodeBraces,
|
||||
mdiDevices,
|
||||
mdiGateOr,
|
||||
mdiMapMarker,
|
||||
mdiMapMarkerRadius,
|
||||
mdiNotEqualVariant,
|
||||
mdiNumeric,
|
||||
mdiResponsive,
|
||||
mdiStateMachine,
|
||||
mdiViewColumnOutline,
|
||||
mdiWeatherSunny,
|
||||
} from "@mdi/js";
|
||||
import type { Condition } from "./validate-condition";
|
||||
|
||||
export const ICON_CONDITION: Record<Condition["condition"], string> = {
|
||||
// Keyed by the condition `condition` string. Covers the client-only lovelace
|
||||
// types, the logical combinators, and the core-format server types edited via
|
||||
// the automation condition editors (template/sun/zone/device).
|
||||
export const ICON_CONDITION: Record<string, string> = {
|
||||
view_columns: mdiViewColumnOutline,
|
||||
location: mdiMapMarker,
|
||||
numeric_state: mdiNumeric,
|
||||
@@ -23,4 +29,8 @@ export const ICON_CONDITION: Record<Condition["condition"], string> = {
|
||||
and: mdiAmpersand,
|
||||
not: mdiNotEqualVariant,
|
||||
or: mdiGateOr,
|
||||
template: mdiCodeBraces,
|
||||
sun: mdiWeatherSunny,
|
||||
zone: mdiMapMarkerRadius,
|
||||
device: mdiDevices,
|
||||
};
|
||||
|
||||
@@ -8,6 +8,15 @@ import {
|
||||
type WeekdayShort,
|
||||
} from "../../../common/datetime/weekday";
|
||||
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
|
||||
import type {
|
||||
NumericStateCondition as CoreNumericStateCondition,
|
||||
PlatformCondition as CorePlatformCondition,
|
||||
StateCondition as CoreStateCondition,
|
||||
SunCondition,
|
||||
TemplateCondition,
|
||||
ZoneCondition,
|
||||
} from "../../../data/automation";
|
||||
import type { DeviceCondition } from "../../../data/device/device_automation";
|
||||
import { UNKNOWN } from "../../../data/entity/entity";
|
||||
import { getUserPerson } from "../../../data/person";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
@@ -99,6 +108,77 @@ export interface NotCondition extends BaseCondition {
|
||||
conditions?: Condition[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard visibility conditions
|
||||
* ===============================
|
||||
*
|
||||
* Historically, dashboard visibility (`visibility` on cards/badges/sections/
|
||||
* views and `conditions` on the conditional card/row/element) used the
|
||||
* lovelace-only {@link Condition} format above, evaluated synchronously on the
|
||||
* client by {@link checkConditionsMet}.
|
||||
*
|
||||
* We are moving the *evaluation* of stateful conditions to core (see
|
||||
* https://github.com/home-assistant/frontend/issues/52836). The visibility
|
||||
* format therefore becomes the union of:
|
||||
*
|
||||
* - the **client-only** lovelace conditions that have no usable core
|
||||
* equivalent for dashboards — `screen`, `user`, `view_columns`, `location`,
|
||||
* and `time` (evaluated against the viewer's local context); and
|
||||
* - any **core** automation condition (`state`, `numeric_state`, `template`,
|
||||
* `sun`, `zone`, `device`, and integration-provided conditions), which is
|
||||
* evaluated server-side through `subscribe_condition`.
|
||||
*
|
||||
* The two may be mixed freely, including inside `and` / `or` / `not`.
|
||||
*
|
||||
* Back-compat is **read both / write new**: existing dashboards keep their
|
||||
* lovelace-format `state` / `numeric_state` conditions (`entity`, `state_not`,
|
||||
* …) and are translated to core format on the fly (see
|
||||
* `common/condition/translate.ts`); only conditions the user edits and saves
|
||||
* are persisted in core format.
|
||||
*
|
||||
* Note: lovelace `state` / `numeric_state` use `entity`, while their core
|
||||
* counterparts use `entity_id`. Both shapes coexist in this union and are
|
||||
* disambiguated by that field — centralized in `common/condition/translate.ts`.
|
||||
*/
|
||||
export type VisibilityCondition =
|
||||
// Client-only lovelace conditions (no core equivalent for dashboards)
|
||||
| ScreenCondition
|
||||
| UserCondition
|
||||
| ViewColumnsCondition
|
||||
| LocationCondition
|
||||
| TimeCondition
|
||||
// Lovelace stateful conditions (read-both back-compat; `entity`-based)
|
||||
| StateCondition
|
||||
| NumericStateCondition
|
||||
| LegacyCondition
|
||||
// Core automation conditions (server-evaluated; `entity_id`-based)
|
||||
| CoreVisibilityCondition
|
||||
// Logical combinators over the mixed union
|
||||
| VisibilityLogicalCondition;
|
||||
|
||||
/**
|
||||
* Core automation conditions usable for dashboard visibility, evaluated
|
||||
* server-side. Mirrors `data/automation`'s condition types, minus the ones
|
||||
* kept client-side by decision (`time`) and the ones with no dashboard meaning
|
||||
* (`trigger`). The `PlatformCondition` member covers integration-provided
|
||||
* conditions and, being a `condition: string` catch-all, also subsumes the
|
||||
* already-core `state` / `numeric_state` shapes.
|
||||
*/
|
||||
export type CoreVisibilityCondition =
|
||||
| CoreStateCondition
|
||||
| CoreNumericStateCondition
|
||||
| SunCondition
|
||||
| ZoneCondition
|
||||
| TemplateCondition
|
||||
| DeviceCondition
|
||||
| CorePlatformCondition;
|
||||
|
||||
/** `and` / `or` / `not` combinator whose children are the mixed union. */
|
||||
export interface VisibilityLogicalCondition extends BaseCondition {
|
||||
condition: "and" | "or" | "not";
|
||||
conditions?: VisibilityCondition[];
|
||||
}
|
||||
|
||||
function getValueFromEntityId(
|
||||
hass: HomeAssistant,
|
||||
value: string
|
||||
@@ -114,7 +194,15 @@ function checkStateCondition(
|
||||
hass: HomeAssistant,
|
||||
context: ConditionContext
|
||||
) {
|
||||
const entityId = condition.entity || context.entity_id;
|
||||
// A core-format condition carries its own `entity_id`; prefer it over the
|
||||
// lovelace `entity` and the host's context entity so the optimistic seed
|
||||
// targets the same entity the server-side subscription does.
|
||||
const entityId =
|
||||
("entity_id" in condition
|
||||
? (condition as { entity_id?: string }).entity_id
|
||||
: undefined) ||
|
||||
condition.entity ||
|
||||
context.entity_id;
|
||||
const stateObj = entityId ? hass.states[entityId] : undefined;
|
||||
const attribute = "attribute" in condition ? condition.attribute : undefined;
|
||||
let state: string;
|
||||
@@ -157,7 +245,14 @@ function checkStateNumericCondition(
|
||||
hass: HomeAssistant,
|
||||
context: ConditionContext
|
||||
) {
|
||||
const entityId = condition.entity || context.entity_id;
|
||||
// See checkStateCondition: prefer a core-format `entity_id` over the lovelace
|
||||
// `entity` and the host's context entity.
|
||||
const entityId =
|
||||
("entity_id" in condition
|
||||
? (condition as { entity_id?: string }).entity_id
|
||||
: undefined) ||
|
||||
condition.entity ||
|
||||
context.entity_id;
|
||||
const stateObj = entityId ? hass.states[entityId] : undefined;
|
||||
const state = condition.attribute
|
||||
? stateObj?.attributes[condition.attribute]
|
||||
@@ -432,6 +527,8 @@ export function validateConditionalConfig(
|
||||
return validateLocationCondition(c);
|
||||
case "numeric_state":
|
||||
return validateNumericStateCondition(c);
|
||||
case "state":
|
||||
return validateStateCondition(c);
|
||||
case "and":
|
||||
return validateAndCondition(c);
|
||||
case "not":
|
||||
@@ -439,7 +536,9 @@ export function validateConditionalConfig(
|
||||
case "or":
|
||||
return validateOrCondition(c);
|
||||
default:
|
||||
return validateStateCondition(c);
|
||||
// Server-evaluated conditions (template, sun, zone, device, and
|
||||
// integration-provided types) are validated by core, not the client.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return validateStateCondition(c);
|
||||
@@ -466,8 +565,12 @@ export function addEntityToCondition(
|
||||
}
|
||||
|
||||
if (
|
||||
condition.condition === "state" ||
|
||||
condition.condition === "numeric_state"
|
||||
(condition.condition === "state" ||
|
||||
condition.condition === "numeric_state") &&
|
||||
// A core-format condition already targets its own `entity_id`; do not graft
|
||||
// the host's context entity onto it (that would both mis-evaluate and emit a
|
||||
// schema-invalid core condition carrying both `entity` and `entity_id`).
|
||||
!("entity_id" in condition)
|
||||
) {
|
||||
return {
|
||||
entity: entityId,
|
||||
|
||||
@@ -5,11 +5,8 @@ import type { HomeAssistant } from "../../../types";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import type { HuiCard } from "../cards/hui-card";
|
||||
import type { ConditionalCardConfig } from "../cards/types";
|
||||
import type { Condition } from "../common/validate-condition";
|
||||
import {
|
||||
checkConditionsMet,
|
||||
validateConditionalConfig,
|
||||
} from "../common/validate-condition";
|
||||
import type { VisibilityCondition } from "../common/validate-condition";
|
||||
import { validateConditionalConfig } from "../common/validate-condition";
|
||||
import type { ConditionalRowConfig, LovelaceRow } from "../entity-rows/types";
|
||||
|
||||
declare global {
|
||||
@@ -26,6 +23,13 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
|
||||
|
||||
@property({ type: Boolean }) public preview = false;
|
||||
|
||||
// Stay mounted while hidden so the evaluator keeps its subscriptions alive and
|
||||
// can report a server-evaluated condition flipping to visible. Otherwise the
|
||||
// wrapper (hui-card) removes the hidden conditional card from the DOM, tearing
|
||||
// the evaluator down; the synchronous seed can revive a client condition but
|
||||
// not a server one (template/sun/…), so it would never reappear.
|
||||
public connectedWhileHidden = true;
|
||||
|
||||
@state() protected _config?: ConditionalCardConfig | ConditionalRowConfig;
|
||||
|
||||
protected _element?: HuiCard | LovelaceRow;
|
||||
@@ -66,17 +70,15 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
|
||||
}
|
||||
|
||||
protected setupConditionalListeners() {
|
||||
if (!this._config || !this.hass) {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter to supported conditions (those with 'condition' property)
|
||||
const supportedConditions = this._config.conditions.filter(
|
||||
(c) => "condition" in c
|
||||
) as Condition[];
|
||||
|
||||
// Pass filtered conditions to parent implementation
|
||||
super.setupConditionalListeners(supportedConditions);
|
||||
// The evaluator handles every condition type, including legacy
|
||||
// `{ entity, state }` conditions, so feed them all through.
|
||||
super.setupConditionalListeners(
|
||||
this._config.conditions as VisibilityCondition[]
|
||||
);
|
||||
}
|
||||
|
||||
protected update(changed: PropertyValues): void {
|
||||
@@ -88,7 +90,6 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
|
||||
changed.has("hass") ||
|
||||
changed.has("preview")
|
||||
) {
|
||||
this.clearConditionalListeners();
|
||||
this.setupConditionalListeners();
|
||||
this._updateVisibility();
|
||||
}
|
||||
@@ -101,13 +102,7 @@ export class HuiConditionalBase extends ConditionalListenerMixin<
|
||||
|
||||
this._element.preview = this.preview;
|
||||
|
||||
const conditionMet =
|
||||
conditionsMet ??
|
||||
checkConditionsMet(
|
||||
this._config.conditions,
|
||||
this.hass,
|
||||
this._conditionContext
|
||||
);
|
||||
const conditionMet = conditionsMet ?? this._conditionsVisible();
|
||||
|
||||
this.setVisibility(conditionMet);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@ import deepClone from "deep-clone-simple";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ConditionListenersController } from "../../../../common/controllers/condition-listeners-controller";
|
||||
import { isPureClientCondition } from "../../../../common/condition/translate";
|
||||
import type { ConditionEvaluation } from "../../../../common/controllers/condition-evaluator-controller";
|
||||
import { ConditionEvaluatorController } from "../../../../common/controllers/condition-evaluator-controller";
|
||||
import { storage } from "../../../../common/decorators/storage";
|
||||
import { dynamicElement } from "../../../../common/dom/dynamic-element-directive";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
@@ -32,19 +34,33 @@ import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import "../../../../components/ha-tooltip";
|
||||
import "../../../../components/ha-yaml-editor";
|
||||
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import "../../../config/automation/condition/ha-automation-condition-editor";
|
||||
import "../../../config/automation/condition/types/ha-automation-condition-device";
|
||||
import "../../../config/automation/condition/types/ha-automation-condition-numeric_state";
|
||||
import "../../../config/automation/condition/types/ha-automation-condition-state";
|
||||
import "../../../config/automation/condition/types/ha-automation-condition-sun";
|
||||
import "../../../config/automation/condition/types/ha-automation-condition-template";
|
||||
import "../../../config/automation/condition/types/ha-automation-condition-zone";
|
||||
import { haStyle } from "../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type {
|
||||
NumericStateCondition as CoreNumericStateCondition,
|
||||
StateCondition as CoreStateCondition,
|
||||
} from "../../../../data/automation";
|
||||
import { ICON_CONDITION } from "../../common/icon-condition";
|
||||
import type {
|
||||
AndCondition,
|
||||
Condition,
|
||||
ConditionContext,
|
||||
LegacyCondition,
|
||||
NotCondition,
|
||||
NumericStateCondition,
|
||||
OrCondition,
|
||||
StateCondition,
|
||||
VisibilityCondition,
|
||||
} from "../../common/validate-condition";
|
||||
import {
|
||||
checkConditionsMet,
|
||||
addEntityToCondition,
|
||||
validateConditionalConfig,
|
||||
} from "../../common/validate-condition";
|
||||
import type { ConditionsEntityContext } from "./context";
|
||||
@@ -72,14 +88,98 @@ const containsNoEntityCondition = (
|
||||
noEntity &&
|
||||
CONTAINER_CONDITIONS.includes(condition.condition) &&
|
||||
(condition as OrCondition | AndCondition | NotCondition).conditions?.some(
|
||||
(c) => NO_ENTITY_CONDITIONS.includes(c.condition)
|
||||
(c) =>
|
||||
NO_ENTITY_CONDITIONS.includes(c.condition) ||
|
||||
containsNoEntityCondition(c, noEntity)
|
||||
) === true;
|
||||
|
||||
// Server-class condition types with no lovelace editor; edited via the
|
||||
// automation condition editors (which already speak core format).
|
||||
export const SERVER_EDITOR_CONDITIONS = ["template", "sun", "zone", "device"];
|
||||
|
||||
export const isServerEditorCondition = (condition: string): boolean =>
|
||||
SERVER_EDITOR_CONDITIONS.includes(condition);
|
||||
|
||||
// Condition types edited via the core automation condition editors. The
|
||||
// server-class types always are; `state` / `numeric_state` are too, except in
|
||||
// entity-filter mode, where they keep the lovelace no-entity syntax and editor.
|
||||
export const usesAutomationConditionEditor = (
|
||||
conditionType: string,
|
||||
noEntity: boolean
|
||||
): boolean =>
|
||||
isServerEditorCondition(conditionType) ||
|
||||
(!noEntity &&
|
||||
(conditionType === "state" || conditionType === "numeric_state"));
|
||||
|
||||
// Render-only translation: present a lovelace `state` / `numeric_state`
|
||||
// condition in the struct-valid core format the automation editor speaks. This
|
||||
// is edit-faithful — unlike the eval-oriented `translateToCoreCondition`, it
|
||||
// never collapses an incomplete config to always-false. Already-core conditions
|
||||
// (carrying `entity_id`) and every other type pass through unchanged. When the
|
||||
// lovelace condition is entity-less (it implicitly targets the host card's
|
||||
// entity), `contextEntityId` is folded in as the `entity_id` so the automation
|
||||
// editor shows the effective entity instead of an empty, invalid field.
|
||||
const toCoreEditorCondition = (
|
||||
condition: VisibilityCondition,
|
||||
contextEntityId?: string
|
||||
): VisibilityCondition => {
|
||||
if ("entity_id" in condition) {
|
||||
return condition;
|
||||
}
|
||||
// Legacy `{ entity, state }` has no `condition` key and is treated as `state`.
|
||||
if (!("condition" in condition) || condition.condition === "state") {
|
||||
const lovelace = condition as StateCondition | LegacyCondition;
|
||||
const attribute = "attribute" in lovelace ? lovelace.attribute : undefined;
|
||||
const entity_id = lovelace.entity ?? contextEntityId ?? "";
|
||||
// Core has no `state_not`; represent it as `not(state)`, which routes to
|
||||
// the (lovelace) `not` editor wrapping a core `state` editor.
|
||||
if (lovelace.state === undefined && lovelace.state_not !== undefined) {
|
||||
const inner: CoreStateCondition = {
|
||||
condition: "state",
|
||||
entity_id,
|
||||
state: lovelace.state_not,
|
||||
};
|
||||
if (attribute !== undefined) {
|
||||
inner.attribute = attribute;
|
||||
}
|
||||
return { condition: "not", conditions: [inner] };
|
||||
}
|
||||
// Incomplete configs keep an empty `state` so the editor stays usable.
|
||||
const core: CoreStateCondition = {
|
||||
condition: "state",
|
||||
entity_id,
|
||||
state: lovelace.state ?? [],
|
||||
};
|
||||
if (attribute !== undefined) {
|
||||
core.attribute = attribute;
|
||||
}
|
||||
return core;
|
||||
}
|
||||
if (condition.condition === "numeric_state") {
|
||||
const lovelace = condition as NumericStateCondition;
|
||||
const core: CoreNumericStateCondition = {
|
||||
condition: "numeric_state",
|
||||
entity_id: lovelace.entity ?? contextEntityId ?? "",
|
||||
};
|
||||
if (lovelace.attribute !== undefined) {
|
||||
core.attribute = lovelace.attribute;
|
||||
}
|
||||
if (lovelace.above !== undefined) {
|
||||
core.above = lovelace.above;
|
||||
}
|
||||
if (lovelace.below !== undefined) {
|
||||
core.below = lovelace.below;
|
||||
}
|
||||
return core;
|
||||
}
|
||||
return condition;
|
||||
};
|
||||
|
||||
@customElement("ha-card-condition-editor")
|
||||
export class HaCardConditionEditor extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) condition!: Condition | LegacyCondition;
|
||||
@property({ attribute: false }) condition!: VisibilityCondition;
|
||||
|
||||
@state()
|
||||
@consume({ context: conditionsEntityContext, subscribe: true })
|
||||
@@ -95,7 +195,7 @@ export class HaCardConditionEditor extends LitElement {
|
||||
subscribe: false,
|
||||
storage: "sessionStorage",
|
||||
})
|
||||
protected _clipboard?: Condition | LegacyCondition;
|
||||
protected _clipboard?: VisibilityCondition;
|
||||
|
||||
@state() public _yamlMode = false;
|
||||
|
||||
@@ -112,7 +212,31 @@ export class HaCardConditionEditor extends LitElement {
|
||||
message?: string;
|
||||
} = { state: "unknown" };
|
||||
|
||||
private _listeners = new ConditionListenersController(this);
|
||||
// Live-test indicator, driven by the same server-backed evaluator the
|
||||
// dashboard uses at runtime: client leaves locally, server-class subtrees via
|
||||
// `subscribe_condition`, combined with three-valued logic.
|
||||
private _conditionEvaluator = new ConditionEvaluatorController(this, {
|
||||
// Debounce so editing (e.g. typing a template) doesn't churn subscriptions.
|
||||
resubscribeDelay: 500,
|
||||
onResult: (result, error) => this._setLiveTestResult(result, error),
|
||||
});
|
||||
|
||||
// Cache of the folded observation (and its client-validity) keyed by the
|
||||
// source condition + entity context, so the evaluator's reference-based
|
||||
// signature memo keeps hitting on hass-only ticks instead of rebuilding the
|
||||
// array — mirrors ConditionalListenerMixin.
|
||||
private __observedSource?: VisibilityCondition;
|
||||
|
||||
private __observedEntityId?: string;
|
||||
|
||||
private __observed?: VisibilityCondition[];
|
||||
|
||||
private __clientInvalid = false;
|
||||
|
||||
// Pins the live-test result for the hidden / client-invalid branches that
|
||||
// bypass the evaluator, so its torn-down `unknown` callback can't clobber
|
||||
// them — mirrors ha-visibility-status.
|
||||
private _override?: LiveTestState;
|
||||
|
||||
private get _editor() {
|
||||
if (!this._condition) return undefined;
|
||||
@@ -121,82 +245,141 @@ export class HaCardConditionEditor extends LitElement {
|
||||
) as LovelaceConditionEditorConstructor | undefined;
|
||||
}
|
||||
|
||||
private get _usesAutomationEditor(): boolean {
|
||||
return (
|
||||
!!this._condition &&
|
||||
usesAutomationConditionEditor(this._condition.condition, this._noEntity)
|
||||
);
|
||||
}
|
||||
|
||||
// No-entity (filter-mode) conditions have no entity to evaluate against, so
|
||||
// the live-test indicator is suppressed for those.
|
||||
private _hideLiveTest(condition: Condition): boolean {
|
||||
return (
|
||||
isNoEntityCondition(condition.condition, this._noEntity) ||
|
||||
containsNoEntityCondition(condition, this._noEntity)
|
||||
);
|
||||
}
|
||||
|
||||
public expand() {
|
||||
this.updateComplete.then(() => {
|
||||
this.shadowRoot!.querySelector("ha-expansion-panel")!.expanded = true;
|
||||
});
|
||||
}
|
||||
|
||||
private _setupConditionListeners() {
|
||||
this._listeners.setup(
|
||||
this.condition ? [this.condition as Condition] : [],
|
||||
this.hass,
|
||||
() => this._evaluateLiveTest()
|
||||
);
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
if (changedProperties.has("condition")) {
|
||||
this._condition = {
|
||||
// Recompute on entity-context change too: an entity-less condition folds in
|
||||
// the host card's entity, which arrives via context (possibly after the
|
||||
// condition is first set).
|
||||
if (
|
||||
changedProperties.has("condition") ||
|
||||
(changedProperties as Map<string, unknown>).has("_entityContext")
|
||||
) {
|
||||
const normalized = {
|
||||
condition: "state",
|
||||
...this.condition,
|
||||
};
|
||||
const validator = this._editor?.validateUIConfig;
|
||||
if (validator) {
|
||||
try {
|
||||
validator(this._condition, this.hass);
|
||||
this._uiAvailable = true;
|
||||
this._uiWarnings = [];
|
||||
} catch (err) {
|
||||
this._uiWarnings = handleStructError(
|
||||
this.hass,
|
||||
err as Error
|
||||
).warnings;
|
||||
this._uiAvailable = false;
|
||||
}
|
||||
} else {
|
||||
this._uiAvailable = false;
|
||||
} as Condition;
|
||||
// In "current" mode the card supplies the entity for entity-less
|
||||
// conditions; fold it into the displayed core condition.
|
||||
const contextEntityId =
|
||||
this._entityContext?.mode === "current"
|
||||
? this._entityContext.entityId
|
||||
: undefined;
|
||||
// Present lovelace `state` / `numeric_state` in core format for the
|
||||
// automation editor (read-both back-compat); every other type passes
|
||||
// through unchanged. `_condition` always carries a `condition` key (core
|
||||
// entries coexist as the wider runtime shape, narrowed here for display).
|
||||
this._condition = (
|
||||
usesAutomationConditionEditor(normalized.condition, this._noEntity)
|
||||
? toCoreEditorCondition(normalized, contextEntityId)
|
||||
: normalized
|
||||
) as Condition;
|
||||
if (this._usesAutomationEditor) {
|
||||
// Rendered by the embedded automation condition editor, which provides
|
||||
// its own UI for these core-format types.
|
||||
this._uiAvailable = true;
|
||||
this._uiWarnings = [];
|
||||
} else {
|
||||
const validator = this._editor?.validateUIConfig;
|
||||
if (validator) {
|
||||
try {
|
||||
validator(this._condition, this.hass);
|
||||
this._uiAvailable = true;
|
||||
this._uiWarnings = [];
|
||||
} catch (err) {
|
||||
this._uiWarnings = handleStructError(
|
||||
this.hass,
|
||||
err as Error
|
||||
).warnings;
|
||||
this._uiAvailable = false;
|
||||
}
|
||||
} else {
|
||||
this._uiAvailable = false;
|
||||
this._uiWarnings = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!this._uiAvailable && !this._yamlMode) {
|
||||
this._yamlMode = true;
|
||||
}
|
||||
|
||||
this._setupConditionListeners();
|
||||
}
|
||||
|
||||
if (changedProperties.has("condition") || changedProperties.has("hass")) {
|
||||
this._evaluateLiveTest();
|
||||
this._updateLiveTest();
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues<this>): void {
|
||||
if ((changedProperties as Map<string, unknown>).has("_entityContext")) {
|
||||
this._evaluateLiveTest();
|
||||
this._updateLiveTest();
|
||||
}
|
||||
}
|
||||
|
||||
private _evaluateLiveTest() {
|
||||
if (!this.condition || !this._condition) {
|
||||
private _liveTestContext(): ConditionContext {
|
||||
return this._entityContext?.mode === "current"
|
||||
? { entity_id: this._entityContext.entityId }
|
||||
: {};
|
||||
}
|
||||
|
||||
// Feed the condition (with the card's entity folded in when in "current"
|
||||
// mode) to the evaluator, which subscribes server subtrees and evaluates
|
||||
// client leaves locally. `onResult` maps its verdict to the indicator.
|
||||
private _updateLiveTest() {
|
||||
if (
|
||||
!this.condition ||
|
||||
!this._condition ||
|
||||
this._hideLiveTest(this._condition)
|
||||
) {
|
||||
this._override = "unknown";
|
||||
this._conditionEvaluator.observe(undefined, this.hass);
|
||||
this._liveTestResult = { state: "unknown" };
|
||||
return;
|
||||
}
|
||||
|
||||
const entityId = this._liveTestContext().entity_id;
|
||||
// Rebuild the folded observation + client-validity only when the source
|
||||
// condition or entity context changes, so a fresh array isn't fed to the
|
||||
// evaluator on every hass tick (which would defeat its signature memo).
|
||||
if (
|
||||
isNoEntityCondition(this._condition.condition, this._noEntity) ||
|
||||
containsNoEntityCondition(this._condition, this._noEntity)
|
||||
this.condition !== this.__observedSource ||
|
||||
entityId !== this.__observedEntityId
|
||||
) {
|
||||
this._liveTestResult = {
|
||||
state: "unknown",
|
||||
message: this.hass.localize(
|
||||
"ui.panel.lovelace.editor.condition-editor.live_test_state.unknown"
|
||||
),
|
||||
};
|
||||
return;
|
||||
this.__observedSource = this.condition;
|
||||
this.__observedEntityId = entityId;
|
||||
this.__clientInvalid =
|
||||
isPureClientCondition(this.condition) &&
|
||||
!validateConditionalConfig([this.condition] as Condition[]);
|
||||
const observed = entityId
|
||||
? addEntityToCondition(this.condition as Condition, entityId)
|
||||
: this.condition;
|
||||
this.__observed = [observed] as VisibilityCondition[];
|
||||
}
|
||||
|
||||
if (!validateConditionalConfig([this.condition])) {
|
||||
// The server-backed path only reports errors for server-class subtrees, so
|
||||
// surface a malformed client-only config as `invalid` here.
|
||||
if (this.__clientInvalid) {
|
||||
this._override = "invalid";
|
||||
this._conditionEvaluator.observe(undefined, this.hass);
|
||||
this._liveTestResult = {
|
||||
state: "invalid",
|
||||
message: this.hass.localize(
|
||||
@@ -206,15 +389,32 @@ export class HaCardConditionEditor extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const testContext =
|
||||
this._entityContext?.mode === "current"
|
||||
? { entity_id: this._entityContext.entityId }
|
||||
: {};
|
||||
const pass = checkConditionsMet([this.condition], this.hass, testContext);
|
||||
this._override = undefined;
|
||||
this._conditionEvaluator.observe(this.__observed, this.hass, () =>
|
||||
this._liveTestContext()
|
||||
);
|
||||
}
|
||||
|
||||
private _setLiveTestResult(result: ConditionEvaluation, error?: string) {
|
||||
// The hidden / client-invalid branches pin the result; ignore the
|
||||
// evaluator's (torn-down) callback in those cases — mirrors
|
||||
// ha-visibility-status.
|
||||
if (this._override !== undefined) {
|
||||
return;
|
||||
}
|
||||
if (error) {
|
||||
// Surface the raw server error as the tooltip detail (the localized
|
||||
// `invalid` label remains the indicator's aria-label) — matches how the
|
||||
// automation condition editor reports validation/test errors.
|
||||
this._liveTestResult = { state: "invalid", message: error };
|
||||
return;
|
||||
}
|
||||
const liveState: LiveTestState =
|
||||
result === "visible" ? "pass" : result === "hidden" ? "fail" : "unknown";
|
||||
this._liveTestResult = {
|
||||
state: pass ? "pass" : "fail",
|
||||
state: liveState,
|
||||
message: this.hass.localize(
|
||||
`ui.panel.lovelace.editor.condition-editor.live_test_state.${pass ? "pass" : "fail"}`
|
||||
`ui.panel.lovelace.editor.condition-editor.live_test_state.${liveState}`
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -224,9 +424,7 @@ export class HaCardConditionEditor extends LitElement {
|
||||
|
||||
if (!condition) return nothing;
|
||||
|
||||
const hideLiveTest =
|
||||
isNoEntityCondition(condition.condition, this._noEntity) ||
|
||||
containsNoEntityCondition(condition, this._noEntity);
|
||||
const hideLiveTest = this._hideLiveTest(condition);
|
||||
|
||||
return html`
|
||||
<div class="container">
|
||||
@@ -286,8 +484,7 @@ export class HaCardConditionEditor extends LitElement {
|
||||
>
|
||||
</ha-icon-button>
|
||||
|
||||
${isNoEntityCondition(condition.condition, this._noEntity) ||
|
||||
containsNoEntityCondition(condition, this._noEntity)
|
||||
${hideLiveTest
|
||||
? nothing
|
||||
: html`<ha-dropdown-item value="test">
|
||||
${this.hass.localize(
|
||||
@@ -365,22 +562,33 @@ export class HaCardConditionEditor extends LitElement {
|
||||
@value-changed=${this._onYamlChange}
|
||||
></ha-yaml-editor>
|
||||
`
|
||||
: html`
|
||||
${dynamicElement(
|
||||
getConditionClassName(condition.condition, this._noEntity),
|
||||
{
|
||||
hass: this.hass,
|
||||
condition: condition,
|
||||
}
|
||||
)}
|
||||
`}
|
||||
: this._usesAutomationEditor
|
||||
? html`
|
||||
<ha-automation-condition-editor
|
||||
.hass=${this.hass}
|
||||
.condition=${condition}
|
||||
.uiSupported=${true}
|
||||
></ha-automation-condition-editor>
|
||||
`
|
||||
: html`
|
||||
${dynamicElement(
|
||||
getConditionClassName(
|
||||
condition.condition,
|
||||
this._noEntity
|
||||
),
|
||||
{
|
||||
hass: this.hass,
|
||||
condition: condition,
|
||||
}
|
||||
)}
|
||||
`}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _handleAction(ev: HaDropdownSelectEvent) {
|
||||
private _handleAction(ev: HaDropdownSelectEvent) {
|
||||
const action = ev.detail.item.value;
|
||||
|
||||
if (action === undefined) {
|
||||
@@ -389,7 +597,7 @@ export class HaCardConditionEditor extends LitElement {
|
||||
|
||||
switch (action) {
|
||||
case "test":
|
||||
await this._testCondition();
|
||||
this._testCondition();
|
||||
return;
|
||||
case "duplicate":
|
||||
this._duplicateCondition();
|
||||
@@ -410,37 +618,20 @@ export class HaCardConditionEditor extends LitElement {
|
||||
|
||||
private _timeout?: number;
|
||||
|
||||
private async _testCondition() {
|
||||
private _testCondition() {
|
||||
if (this._timeout) {
|
||||
window.clearTimeout(this._timeout);
|
||||
this._timeout = undefined;
|
||||
}
|
||||
this._testingResult = undefined;
|
||||
const condition = this.condition;
|
||||
|
||||
const validateResult = validateConditionalConfig([this.condition]);
|
||||
|
||||
if (!validateResult) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.lovelace.editor.condition-editor.invalid_config_title"
|
||||
),
|
||||
text: this.hass.localize(
|
||||
"ui.panel.lovelace.editor.condition-editor.invalid_config_text"
|
||||
),
|
||||
});
|
||||
// Surface the evaluator's current live verdict as a transient chip. A
|
||||
// not-yet-reported (unknown) server result shows no chip rather than
|
||||
// asserting a false failure.
|
||||
const result = this._conditionEvaluator.result;
|
||||
if (result === "unknown") {
|
||||
this._testingResult = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const testContext =
|
||||
this._entityContext?.mode === "current"
|
||||
? { entity_id: this._entityContext.entityId }
|
||||
: {};
|
||||
this._testingResult = checkConditionsMet(
|
||||
[condition],
|
||||
this.hass,
|
||||
testContext
|
||||
);
|
||||
this._testingResult = result === "visible";
|
||||
|
||||
this._timeout = window.setTimeout(() => {
|
||||
this._testingResult = undefined;
|
||||
@@ -522,6 +713,6 @@ declare global {
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"duplicate-condition": { value: Condition | LegacyCondition };
|
||||
"duplicate-condition": { value: VisibilityCondition };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { HomeAssistant } from "../../../../types";
|
||||
import { ICON_CONDITION } from "../../common/icon-condition";
|
||||
import type {
|
||||
Condition,
|
||||
LegacyCondition,
|
||||
VisibilityCondition,
|
||||
} from "../../common/validate-condition";
|
||||
import type { ConditionsEntityContext } from "./context";
|
||||
import { conditionsEntityContext } from "./context";
|
||||
@@ -23,16 +23,15 @@ import "./ha-card-condition-editor";
|
||||
import {
|
||||
type HaCardConditionEditor,
|
||||
getConditionClassName,
|
||||
usesAutomationConditionEditor,
|
||||
} from "./ha-card-condition-editor";
|
||||
import type { LovelaceConditionEditorConstructor } from "./types";
|
||||
import "./types/ha-card-condition-and";
|
||||
import "./types/ha-card-condition-location";
|
||||
import "./types/ha-card-condition-not";
|
||||
import "./types/ha-card-condition-numeric_state";
|
||||
import "./types/ha-card-condition-numeric_state-no_entity";
|
||||
import "./types/ha-card-condition-or";
|
||||
import "./types/ha-card-condition-screen";
|
||||
import "./types/ha-card-condition-state";
|
||||
import "./types/ha-card-condition-state-no_entity";
|
||||
import "./types/ha-card-condition-time";
|
||||
import "./types/ha-card-condition-user";
|
||||
@@ -44,10 +43,15 @@ const UI_CONDITION = [
|
||||
"screen",
|
||||
"time",
|
||||
"user",
|
||||
// Server-class types, edited via the automation condition editors.
|
||||
"template",
|
||||
"sun",
|
||||
"zone",
|
||||
"device",
|
||||
"and",
|
||||
"not",
|
||||
"or",
|
||||
] as const satisfies readonly Condition["condition"][];
|
||||
] as const satisfies readonly string[];
|
||||
|
||||
@customElement("ha-card-conditions-editor")
|
||||
export class HaCardConditionsEditor extends LitElement {
|
||||
@@ -59,12 +63,9 @@ export class HaCardConditionsEditor extends LitElement {
|
||||
subscribe: false,
|
||||
storage: "sessionStorage",
|
||||
})
|
||||
protected _clipboard?: Condition | LegacyCondition;
|
||||
protected _clipboard?: VisibilityCondition;
|
||||
|
||||
@property({ attribute: false }) public conditions!: (
|
||||
| Condition
|
||||
| LegacyCondition
|
||||
)[];
|
||||
@property({ attribute: false }) public conditions!: VisibilityCondition[];
|
||||
|
||||
@state()
|
||||
@consume({ context: conditionsEntityContext, subscribe: true })
|
||||
@@ -77,6 +78,11 @@ export class HaCardConditionsEditor extends LitElement {
|
||||
private _focusLastConditionOnChange = false;
|
||||
|
||||
protected firstUpdated() {
|
||||
// The reused automation condition editors (state / numeric_state / template
|
||||
// / sun / zone / device) label their form fields from the `config`
|
||||
// translation fragment, which the dashboard editor does not otherwise load.
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
|
||||
// Expand the condition if there is only one
|
||||
if (this.conditions.length === 1) {
|
||||
const row = this.shadowRoot!.querySelector<HaCardConditionEditor>(
|
||||
@@ -161,17 +167,31 @@ export class HaCardConditionsEditor extends LitElement {
|
||||
}
|
||||
|
||||
private _addCondition(ev: HaDropdownSelectEvent) {
|
||||
const condition = ev.detail.item.value as "paste" | Condition["condition"];
|
||||
const value = ev.detail.item.value as string;
|
||||
const conditions = [...this.conditions];
|
||||
|
||||
if (!condition || (condition === "paste" && !this._clipboard)) {
|
||||
if (!value || (value === "paste" && !this._clipboard)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (condition === "paste") {
|
||||
if (value === "paste") {
|
||||
const newCondition = deepClone(this._clipboard!);
|
||||
conditions.push(newCondition);
|
||||
} else if (usesAutomationConditionEditor(value, this._noEntity)) {
|
||||
// Authored in core format via the automation condition editors (server
|
||||
// types, plus state/numeric_state outside entity-filter mode); seed with
|
||||
// that editor's default config.
|
||||
const elClass = customElements.get(`ha-automation-condition-${value}`) as
|
||||
| { defaultConfig?: object }
|
||||
| undefined;
|
||||
const defaultConfig = elClass?.defaultConfig;
|
||||
conditions.push(
|
||||
(defaultConfig
|
||||
? { ...defaultConfig }
|
||||
: { condition: value }) as VisibilityCondition
|
||||
);
|
||||
} else {
|
||||
const condition = value as Condition["condition"];
|
||||
const elClass = customElements.get(
|
||||
getConditionClassName(condition, this._noEntity)
|
||||
) as LovelaceConditionEditorConstructor | undefined;
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { mdiAlertCircle, mdiEye, mdiEyeOff } from "@mdi/js";
|
||||
import { mdiAlertCircle, mdiEye, mdiEyeOff, mdiHelpCircle } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ConditionListenersController } from "../../../../common/controllers/condition-listeners-controller";
|
||||
import { isPureClientCondition } from "../../../../common/condition/translate";
|
||||
import type { ConditionEvaluation } from "../../../../common/controllers/condition-evaluator-controller";
|
||||
import { ConditionEvaluatorController } from "../../../../common/controllers/condition-evaluator-controller";
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import { HaRowItem } from "../../../../components/item/ha-row-item";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type {
|
||||
Condition,
|
||||
LegacyCondition,
|
||||
ConditionContext,
|
||||
VisibilityCondition,
|
||||
} from "../../common/validate-condition";
|
||||
import {
|
||||
checkConditionsMet,
|
||||
addEntityToCondition,
|
||||
validateConditionalConfig,
|
||||
} from "../../common/validate-condition";
|
||||
import type { ConditionsEntityContext } from "./context";
|
||||
import { conditionsEntityContext } from "./context";
|
||||
|
||||
type VisibilityState = "visible" | "hidden" | "invalid";
|
||||
type VisibilityState = "visible" | "hidden" | "unknown" | "invalid";
|
||||
|
||||
const STATE_ICONS: Record<VisibilityState, string> = {
|
||||
visible: mdiEye,
|
||||
hidden: mdiEyeOff,
|
||||
unknown: mdiHelpCircle,
|
||||
invalid: mdiAlertCircle,
|
||||
};
|
||||
|
||||
@@ -34,14 +38,14 @@ const STATE_ICONS: Record<VisibilityState, string> = {
|
||||
* Alert banner that surfaces the live visibility result for a set of
|
||||
* lovelace conditions.
|
||||
*
|
||||
* @attr {"visible"|"hidden"|"invalid"} state - Computed visibility state
|
||||
* @attr {"visible"|"hidden"|"unknown"|"invalid"} state - Computed visibility state
|
||||
*/
|
||||
@customElement("ha-visibility-status")
|
||||
export class HaVisibilityStatus extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public conditions: (Condition | LegacyCondition)[] = [];
|
||||
public conditions: VisibilityCondition[] = [];
|
||||
|
||||
@state()
|
||||
@consume({ context: conditionsEntityContext, subscribe: true })
|
||||
@@ -50,17 +54,30 @@ export class HaVisibilityStatus extends LitElement {
|
||||
@property()
|
||||
public state: VisibilityState = "visible";
|
||||
|
||||
private _listeners = new ConditionListenersController(this);
|
||||
// Evaluate the whole set through the same server-backed controller the
|
||||
// dashboard uses at runtime, so server-class conditions report a real
|
||||
// verdict instead of being flagged as an invalid configuration.
|
||||
private _conditionEvaluator = new ConditionEvaluatorController(this, {
|
||||
resubscribeDelay: 500,
|
||||
onResult: (result, error) => this._applyResult(result, error),
|
||||
});
|
||||
|
||||
// Cache the folded observation + client-validity keyed by (conditions ref,
|
||||
// entity id) so the controller's signature memo keeps hitting on hass-only
|
||||
// ticks. `_override` pins the state for the empty / client-invalid branches
|
||||
// that bypass the controller.
|
||||
private __observedSource?: VisibilityCondition[];
|
||||
|
||||
private __observedEntityId?: string;
|
||||
|
||||
private __observed?: VisibilityCondition[];
|
||||
|
||||
private __clientInvalid = false;
|
||||
|
||||
private _override?: VisibilityState;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has("conditions") || changedProperties.has("hass")) {
|
||||
this._listeners.setup(
|
||||
(this.conditions ?? []) as Condition[],
|
||||
this.hass,
|
||||
() => this._evaluate()
|
||||
);
|
||||
}
|
||||
if (
|
||||
changedProperties.has("hass") ||
|
||||
changedProperties.has("conditions") ||
|
||||
@@ -77,7 +94,9 @@ export class HaVisibilityStatus extends LitElement {
|
||||
? "success"
|
||||
: this.state === "hidden"
|
||||
? "warning"
|
||||
: "error"}
|
||||
: this.state === "unknown"
|
||||
? "info"
|
||||
: "error"}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${STATE_ICONS[this.state]}></ha-svg-icon>
|
||||
<div class="headline">
|
||||
@@ -94,27 +113,76 @@ export class HaVisibilityStatus extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _context(): ConditionContext {
|
||||
return this._entityContext?.mode === "current"
|
||||
? { entity_id: this._entityContext.entityId }
|
||||
: {};
|
||||
}
|
||||
|
||||
private _evaluate() {
|
||||
const conditions = this.conditions ?? [];
|
||||
let newState: VisibilityState;
|
||||
|
||||
if (conditions.length === 0) {
|
||||
newState = "visible";
|
||||
} else if (!validateConditionalConfig(conditions)) {
|
||||
newState = "invalid";
|
||||
} else {
|
||||
const context =
|
||||
this._entityContext?.mode === "current"
|
||||
? { entity_id: this._entityContext.entityId }
|
||||
: {};
|
||||
newState = checkConditionsMet(conditions, this.hass, context)
|
||||
? "visible"
|
||||
: "hidden";
|
||||
}
|
||||
if (newState === this.state) {
|
||||
this._override = "visible";
|
||||
this._conditionEvaluator.observe(undefined, this.hass);
|
||||
this.state = "visible";
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = newState;
|
||||
const entityId =
|
||||
this._entityContext?.mode === "current"
|
||||
? this._entityContext.entityId
|
||||
: undefined;
|
||||
|
||||
// Rebuild the folded observation + client-validity only when the source
|
||||
// set or entity context changes, so a fresh array isn't fed to the
|
||||
// evaluator on every hass tick.
|
||||
if (
|
||||
conditions !== this.__observedSource ||
|
||||
entityId !== this.__observedEntityId
|
||||
) {
|
||||
this.__observedSource = conditions;
|
||||
this.__observedEntityId = entityId;
|
||||
this.__clientInvalid =
|
||||
conditions.every((c) => isPureClientCondition(c)) &&
|
||||
!validateConditionalConfig(conditions as Condition[]);
|
||||
this.__observed = (
|
||||
entityId
|
||||
? conditions.map((c) =>
|
||||
addEntityToCondition(c as Condition, entityId)
|
||||
)
|
||||
: conditions
|
||||
) as VisibilityCondition[];
|
||||
}
|
||||
|
||||
// `validateConditionalConfig` only understands client types; a malformed
|
||||
// server config surfaces through the controller's error instead.
|
||||
if (this.__clientInvalid) {
|
||||
this._override = "invalid";
|
||||
this._conditionEvaluator.observe(undefined, this.hass);
|
||||
this.state = "invalid";
|
||||
return;
|
||||
}
|
||||
|
||||
this._override = undefined;
|
||||
this._conditionEvaluator.observe(this.__observed, this.hass, () =>
|
||||
this._context()
|
||||
);
|
||||
}
|
||||
|
||||
private _applyResult(result: ConditionEvaluation, error?: string) {
|
||||
// The empty / client-invalid branches pin the state; ignore the
|
||||
// controller's (torn-down) result in those cases.
|
||||
if (this._override !== undefined) {
|
||||
return;
|
||||
}
|
||||
this.state = error
|
||||
? "invalid"
|
||||
: result === "visible"
|
||||
? "visible"
|
||||
: result === "hidden"
|
||||
? "hidden"
|
||||
: "unknown";
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { assert, literal, number, object, optional, string } from "superstruct";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
@@ -40,7 +40,9 @@ interface NumericStateConditionData {
|
||||
below?: number | string;
|
||||
}
|
||||
|
||||
@customElement("ha-card-condition-numeric_state")
|
||||
// Base class for the entity-filter (no-entity) numeric_state editor. The
|
||||
// with-entity dashboard editing path now uses the core automation condition
|
||||
// editor, so this class is not registered as an element on its own.
|
||||
export class HaCardConditionNumericState extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@@ -211,9 +213,3 @@ export class HaCardConditionNumericState extends LitElement {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-card-condition-numeric_state": HaCardConditionNumericState;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { assert, literal, object, optional, string } from "superstruct";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
@@ -37,7 +37,9 @@ interface StateConditionData {
|
||||
state?: string | string[];
|
||||
}
|
||||
|
||||
@customElement("ha-card-condition-state")
|
||||
// Base class for the entity-filter (no-entity) state editor. The with-entity
|
||||
// dashboard editing path now uses the core automation condition editor, so this
|
||||
// class is not registered as an element on its own.
|
||||
export class HaCardConditionState extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@@ -228,9 +230,3 @@ export class HaCardConditionState extends LitElement {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-card-condition-state": HaCardConditionState;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { customElement } from "lit/decorators";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import { createStyledHuiElement } from "../cards/picture-elements/create-styled-hui-element";
|
||||
import {
|
||||
checkConditionsMet,
|
||||
validateConditionalConfig,
|
||||
} from "../common/validate-condition";
|
||||
import type { VisibilityCondition } from "../common/validate-condition";
|
||||
import { validateConditionalConfig } from "../common/validate-condition";
|
||||
import type { LovelacePictureElementEditor } from "../types";
|
||||
import type {
|
||||
ConditionalElementConfig,
|
||||
@@ -13,18 +14,25 @@ import type {
|
||||
} from "./types";
|
||||
|
||||
@customElement("hui-conditional-element")
|
||||
class HuiConditionalElement extends HTMLElement implements LovelaceElement {
|
||||
class HuiConditionalElement
|
||||
extends ConditionalListenerMixin<ConditionalElementConfig>(ReactiveElement)
|
||||
implements LovelaceElement
|
||||
{
|
||||
public static async getConfigElement(): Promise<LovelacePictureElementEditor> {
|
||||
await import("../editor/config-elements/elements/hui-conditional-element-editor");
|
||||
return document.createElement("hui-conditional-element-editor");
|
||||
}
|
||||
|
||||
public _hass?: HomeAssistant;
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
private _config?: ConditionalElementConfig;
|
||||
@state() protected _config?: ConditionalElementConfig;
|
||||
|
||||
private _elements: LovelaceElement[] = [];
|
||||
|
||||
protected createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
|
||||
public setConfig(config: ConditionalElementConfig): void {
|
||||
if (
|
||||
!config.conditions ||
|
||||
@@ -36,46 +44,58 @@ class HuiConditionalElement extends HTMLElement implements LovelaceElement {
|
||||
throw new Error("Invalid configuration");
|
||||
}
|
||||
|
||||
if (this._elements.length > 0) {
|
||||
this._elements.forEach((el: LovelaceElement) => {
|
||||
if (el.parentElement) {
|
||||
el.parentElement.removeChild(el);
|
||||
}
|
||||
});
|
||||
this._elements.forEach((el) => el.parentElement?.removeChild(el));
|
||||
this._elements = [];
|
||||
|
||||
this._elements = [];
|
||||
}
|
||||
|
||||
this._config = config;
|
||||
|
||||
this._config.elements.forEach((elementConfig: LovelaceElementConfig) => {
|
||||
config.elements.forEach((elementConfig: LovelaceElementConfig) => {
|
||||
this._elements.push(createStyledHuiElement(elementConfig));
|
||||
});
|
||||
|
||||
this._updateElements();
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
set hass(hass: HomeAssistant) {
|
||||
this._hass = hass;
|
||||
|
||||
this._updateElements();
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._updateVisibility();
|
||||
}
|
||||
|
||||
private _updateElements() {
|
||||
if (!this._hass || !this._config) {
|
||||
protected setupConditionalListeners() {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visible = checkConditionsMet(this._config.conditions, this._hass, {});
|
||||
// The evaluator delegates the stateful conditions (state, numeric_state,
|
||||
// template, sun, zone, device, integration) to core and evaluates the
|
||||
// client-only ones locally, including legacy `{ entity, state }`.
|
||||
super.setupConditionalListeners(
|
||||
this._config.conditions as VisibilityCondition[]
|
||||
);
|
||||
}
|
||||
|
||||
this._elements.forEach((el: LovelaceElement) => {
|
||||
protected update(changed: PropertyValues): void {
|
||||
super.update(changed);
|
||||
|
||||
if (changed.has("_config") || changed.has("hass")) {
|
||||
this.setupConditionalListeners();
|
||||
this._updateVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
protected _updateVisibility() {
|
||||
if (!this.hass || !this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const visible = this._conditionsVisible();
|
||||
|
||||
this._elements.forEach((el) => {
|
||||
if (visible) {
|
||||
el.hass = this._hass;
|
||||
el.hass = this.hass;
|
||||
if (!el.parentElement) {
|
||||
this.appendChild(el);
|
||||
}
|
||||
} else if (el.parentElement) {
|
||||
el.parentElement.removeChild(el);
|
||||
this.removeChild(el);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,7 +122,11 @@ export class HuiGraphHeaderFooter
|
||||
if (this._coordinates && !this._coordinates.length) {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="info">No state history found.</div>
|
||||
<div class="info">
|
||||
${this.hass!.localize(
|
||||
"ui.components.history_charts.no_history_found"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
import { createHeadingBadgeElement } from "../create-element/create-heading-badge-element";
|
||||
import type { LovelaceHeadingBadge } from "../types";
|
||||
import type { LovelaceHeadingBadgeConfig } from "./types";
|
||||
@@ -160,14 +159,7 @@ export class HuiHeadingBadge extends ConditionalListenerMixin<LovelaceHeadingBad
|
||||
return;
|
||||
}
|
||||
|
||||
const visible =
|
||||
conditionsMet ??
|
||||
(!this.config?.visibility ||
|
||||
checkConditionsMet(
|
||||
this.config.visibility,
|
||||
this.hass,
|
||||
this._conditionContext
|
||||
));
|
||||
const visible = conditionsMet ?? this._conditionsVisible();
|
||||
this._setElementVisibility(visible);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,9 @@ export class HuiErrorSection
|
||||
|
||||
// Todo improve
|
||||
return html`
|
||||
<h1>Error</h1>
|
||||
<h1>
|
||||
${this.hass!.localize("ui.panel.lovelace.editor.error_section.title")}
|
||||
</h1>
|
||||
<p>${this._config.error}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@ import type { HomeAssistant } from "../../../types";
|
||||
import { ConditionalListenerMixin } from "../../../mixins/conditional-listener-mixin";
|
||||
import "../cards/hui-card";
|
||||
import type { HuiCard } from "../cards/hui-card";
|
||||
import { checkConditionsMet } from "../common/validate-condition";
|
||||
import { createSectionElement } from "../create-element/create-section-element";
|
||||
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
|
||||
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
|
||||
@@ -280,14 +279,7 @@ export class HuiSection extends ConditionalListenerMixin<LovelaceSectionConfig>(
|
||||
return;
|
||||
}
|
||||
|
||||
const visible =
|
||||
conditionsMet ??
|
||||
(!this._config.visibility ||
|
||||
checkConditionsMet(
|
||||
this._config.visibility,
|
||||
this.hass,
|
||||
this._conditionContext
|
||||
));
|
||||
const visible = conditionsMet ?? this._conditionsVisible();
|
||||
|
||||
if (!visible) {
|
||||
this._setElementVisibility(false);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user