Compare commits

..

46 Commits

Author SHA1 Message Date
Bram Kragten 2dabc28e7f Bumped version to 20260624.6 2026-07-17 16:42:23 +02:00
Paul Bottein fd70fcef2f Fix history graph editor crash on unavailable entity (#53141) 2026-07-17 16:42:08 +02:00
Paul Bottein 9bf46415f1 Fix save button not enabling on whitespace-only entity name changes (#53138) 2026-07-17 16:42:07 +02:00
Jan-Philipp Benecke c552af4c12 Remove custom app header text color in zha group page (#53101) 2026-07-17 16:42:06 +02:00
Bram Kragten c573669786 Bumped version to 20260624.5 2026-07-10 14:57:54 +02:00
Petar Petrov 4e1ccab159 Restore per-second updating of ha-relative-time (#53070)
Restore per-second ticking for ha-relative-time
2026-07-10 14:57:14 +02:00
Petar Petrov 347d63bce9 Wrap device automation picker option labels instead of truncating (#53065) 2026-07-10 14:57:13 +02:00
Petar Petrov a1d7b31732 Fix cloning when drag-sorting a section to the last position (#53050) 2026-07-10 14:57:12 +02:00
Petar Petrov 4d61df7ed7 Fix target picker "Add target" popover opening off to the right (#53041) 2026-07-10 14:57:11 +02:00
Petar Petrov 991dda70fc Only show solar/battery legend in energy usage graph when configured (#53030) 2026-07-10 14:57:10 +02:00
Petar Petrov 58f5480ca3 Fix escape key discarding unsaved changes in dashboard editor (#53029) 2026-07-10 14:57:09 +02:00
Petar Petrov 1f20bc0749 Fix number/input_number slider clipped in entities card at certain zooms (#53017) 2026-07-10 14:57:09 +02:00
Petar Petrov 0846cb3be3 Fix tile trend graph rendering blank when shown by a visibility condition (#53016) 2026-07-10 14:57:08 +02:00
Petar Petrov 33afb77367 Fix My redirect for AI tasks to /config/ai-tasks (#53015) 2026-07-10 14:57:07 +02:00
Petar Petrov b2f85e2595 Fix history/logbook entity selection leaking across browser tabs (#53014)
Keep history and logbook entity selection independent per tab
2026-07-10 14:57:06 +02:00
Simon Lamon 09956a7d9c No need to show rename when alias is already provided (#53003)
* No need to show rename when alias is already provided

* Update src/panels/config/script/ha-script-editor.ts

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-07-10 14:57:05 +02:00
Simon Lamon 8507e222f8 Add entity update tracking again in Automation and Script editor (#53002) 2026-07-10 14:57:04 +02:00
Petar Petrov 5737480398 Match Statistic card line height to Entity card (#52981) 2026-07-10 14:57:03 +02:00
Petar Petrov 44163b9ccb Fix focus loss in text selector with multiple (#52980) 2026-07-10 14:57:03 +02:00
Bram Kragten e79cd0c5b2 Bumped version to 20260624.4 2026-07-03 12:29:13 +02:00
Petar Petrov 52379b39e0 Fix My link for adding an add-on repository not doing anything (#52945) 2026-07-03 12:28:54 +02:00
Franck Nijhof 656e1bea8e Refresh the template tool documentation panel (#52941)
The About templates panel still pointed at the upstream Jinja2 docs and a
single extensions page. Rewrite the intro and link to the current templating
documentation instead: the learning guide (introduction, working with states,
debugging) and the searchable template functions reference.
2026-07-03 12:28:53 +02:00
Franck Nijhof 4ef3ed2f02 Link the config pane help icon to the dedicated docs page (#52940)
For built-in triggers, conditions, and actions, the help icon in the editor
config pane (and Developer Tools) linked to the integration page. Point it at
the dedicated page for that specific trigger/condition/action instead, e.g.
/triggers/air_quality.co2_changed. Custom integrations keep their own
documentation URL.
2026-07-03 12:28:27 +02:00
Petar Petrov fbad0ba885 Fix double bar in energy devices detail graph at start of day (#52939) 2026-07-03 12:25:06 +02:00
Petar Petrov c892691344 Add My links for infrared and radio frequency config panels (#52931) 2026-07-03 12:25:05 +02:00
Petar Petrov 811b7c7d98 Allow negative number entry in number selector on iOS (#52925)
Allow entering negative numbers in number selector on iOS
2026-07-03 12:24:37 +02:00
Bram Kragten 91dee86697 Bumped version to 20260624.3 2026-07-01 13:34:46 +02:00
Paul Bottein 9877377cb9 Show the event type in the logbook for event entities (#52863) 2026-07-01 13:34:34 +02:00
Bram Kragten 7a1c8c556f Bumped version to 20260624.2 2026-06-30 15:58:00 +02:00
Bram Kragten 2c3d8eb230 Show a warning when deprecated automation options where used and migr… (#52915)
Show a warning when deprecated automation options where used and migrated
2026-06-30 15:57:34 +02:00
Aidan Timson 67489affe7 Fix loading spinner position for more info weather forecast (#52903) 2026-06-30 15:57:33 +02:00
Paul Bottein 7fcbd8e245 Show dedicated icons for Cloud and Cast in Actvity and add tooltip (#52896) 2026-06-30 15:57:32 +02:00
Jan-Philipp Benecke 12a88231f3 Fix overflow issue in mobile automation target picker (#52883)
Fix overflow issue in mobile target picker
2026-06-30 15:57:31 +02:00
Paulus Schoutsen f66619fae6 Fix duplicate logbook entries in more info dialog after reconnect (#52880)
* Fix duplicate logbook entries after reconnect in more info dialog

When a more info dialog is left open while the app is backgrounded, the
WebSocket connection drops and reconnects on resume. The logbook stream
subscription relied on home-assistant-js-websocket's auto-resubscribe,
which replays the original subscription with its stale start_time. The
backend then resends the entire historical chunk, and ha-logbook appends
streamed events without deduplicating, so every entry was shown twice
(and a third time after another background/reconnect cycle).

Mirror the approach already used for the history stream: disable the
library's auto-resubscribe for the logbook event stream and have
ha-logbook listen for the connection "ready" event, resubscribing from a
clean state on reconnect instead of appending a replayed history chunk.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WYMwT7ZQJrjyzrNfGQyWaU

* Simplify logbook reconnect comments

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-06-30 15:57:30 +02:00
Bram Kragten 43ff58010a Bumped version to 20260624.1 2026-06-25 16:16:23 +02:00
Copilot c391d571d7 Localize "(default)" label in Edit sidebar dialog (#52868)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-25 16:15:51 +02:00
Petar Petrov 18e15f8a99 Group Sankey flow siblings under their parent to fix segment crossovers (#52867) 2026-06-25 16:15:50 +02:00
Paul Bottein dfdd55b649 Show dash for unavailable number entity in slider row (#52866) 2026-06-25 16:15:49 +02:00
Paul Bottein bed98776c3 Fix logbook padding and margin (#52864) 2026-06-25 16:15:48 +02:00
Paul Bottein ad37f1bb58 Show action labels instead of timestamps in the logbook (#52861) 2026-06-25 16:15:47 +02:00
Franck Nijhof 2a00b0d0ec Use choose selector for legacy trigger fields (#52859)
* Use choose selector for legacy trigger fields

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

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

Add translation entries for the choose selector toggle button labels.

* Shorten the numeric state value toggle label

Use "Value of an entity" instead of "Numeric value of another entity" for
the numeric state trigger toggle, so it stays compact.
2026-06-25 16:15:46 +02:00
Paul Bottein 20efc35da3 Keep self-closing slashes when minifying svg`` templates (#52857)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 16:15:45 +02:00
Michael Hansen ac71b4c400 Add demo voice assistants and exposed entities (#52855) 2026-06-25 16:15:44 +02:00
Franck Nijhof b85422e652 Use the Jinja block comment for toggle-comment in templates (#52854)
The jinja2 editor mode is rendered on a YAML base, so Ctrl+/ inserted a "#"
line comment, which does nothing useful in a template. Give the jinja2
language a Jinja block comment token so toggle-comment wraps with {# #},
while the plain YAML mode keeps its # comment.
2026-06-25 16:15:43 +02:00
Paulus Schoutsen 4ff69aab8f Show supported frequencies column in radio frequency devices list (#52851)
Add a "Frequencies" column to the radio frequency devices (proxy) list so
users can see which frequency bands each transmitter supports. The supported
frequency ranges are formatted into a human-readable, locale-aware string
(picking Hz/kHz/MHz/GHz automatically) with a helper in the data layer.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 16:15:42 +02:00
Bram Kragten ebb15d1118 Show warning when priming will not work for condition (#52709)
* Show warning when priming will not work for condition

* rename

* change to warning icon with tooltip

* review

* Update duration_to_seconds.test.ts
2026-06-25 16:15:41 +02:00
150 changed files with 2738 additions and 6036 deletions
-38
View File
@@ -1,38 +0,0 @@
#!/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();
}
}
@@ -1,195 +0,0 @@
#!/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- ")}`);
}
@@ -1,58 +0,0 @@
#!/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"],
});
}
+23 -8
View File
@@ -20,16 +20,31 @@ 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 { default: checkBlockingLabels } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`
const blockingLabels = [
"wait for backend",
"Needs UX",
"Do Not Review",
"Blocked",
"has-parent",
];
const prLabels = context.payload.pull_request.labels.map(
(l) => l.name
);
await checkBlockingLabels({ github, context, core });
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();
}
+2 -2
View File
@@ -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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: master
persist-credentials: false
+3 -5
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Setup Node
@@ -105,8 +105,6 @@ 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:
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
+2 -2
View File
@@ -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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: master
persist-credentials: false
+1 -1
View File
@@ -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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+1 -1
View File
@@ -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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+19 -15
View File
@@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -60,7 +60,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -92,7 +92,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -129,7 +129,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Check out files from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -155,19 +155,19 @@ jobs:
timeout-minutes: 10
- name: Download demo build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@@ -209,7 +209,7 @@ jobs:
run: yarn install --immutable
- name: Download blob report (local)
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
continue-on-error: true
with:
name: blob-report-local
@@ -229,12 +229,16 @@ jobs:
path: test/e2e/reports/combined/
retention-days: 14
- name: Post report to PR
- name: Post report link to PR
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const { default: postReportComment } = await import(
`${process.env.GITHUB_WORKSPACE}/test/e2e/post-report-comment.mjs`
);
await postReportComment({ github, context, core });
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,
});
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
+161 -9
View File
@@ -1,7 +1,7 @@
name: Pull request standards
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, checks out base repo scripts only, never PR head code
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, no PR code checkout
types:
- opened
- edited
@@ -23,16 +23,168 @@ 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 { default: checkStandards } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`
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- ")}`
);
await checkStandards({ github, context, core });
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@ed4bc48ec97379be2258e7b7ac2624a3e26ab809 # v7.4.0
- uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+3 -3
View File
@@ -26,7 +26,7 @@ jobs:
if: github.repository_owner == 'home-assistant'
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@f4ca6f671bd429efb108c0f2fa0ae8af0215986c # master
uses: home-assistant/actions/helpers/verify-version@e91ad1948e57189485b9c1ad608af0c303946f89 # 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Setup Node
+41 -10
View File
@@ -36,21 +36,52 @@ 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 { default: checkTaskAuthorization } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`
);
await 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
});
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']
});
@@ -21,7 +21,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
-15
View File
@@ -1,15 +0,0 @@
{
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 561513,
"core": 54473,
"authorize": 544272,
"onboarding": 647136
},
"frontend-legacy": {
"app": 790323,
"core": 237208,
"authorize": 765464,
"onboarding": 918679
}
}
-155
View File
@@ -1,155 +0,0 @@
/* global require, process, __dirname */
// Enforce a strict size budget on the initial JS of the most critical
// entrypoints (`app` and `core`). These two are downloaded on every cold load
// before anything interactive can happen, so unintended growth here hurts
// first-load performance directly.
//
// In production rspack does not split initial chunks (splitChunks only operates
// on `!chunk.canBeInitial()`), so each entrypoint resolves to a single initial
// JS asset. We read the per-build stats written by StatsWriterPlugin and compare
// the entrypoint's initial JS size against a committed budget.
//
// Usage:
// node build-scripts/check-bundle-size.cjs # enforce, exit 1 on regression
// node build-scripts/check-bundle-size.cjs --update # rewrite budgets from current sizes
// node build-scripts/check-bundle-size.cjs --update --headroom=3 # current + 3% headroom
const fs = require("fs");
const path = require("path");
const paths = require("./paths.cjs");
// Entrypoints whose initial JS we hold to a strict budget. These are all
// downloaded on a user-facing cold load before anything interactive can happen:
// `app`/`core` for the main app, plus the standalone `authorize` and
// `onboarding` pages. `custom-panel` is intentionally excluded (only loaded
// when a custom panel is opened).
const TRACKED_ENTRYPOINTS = ["app", "core", "authorize", "onboarding"];
// App build stats files, as written by StatsWriterPlugin (`${name}.json`).
const BUILDS = ["frontend-modern", "frontend-legacy"];
const BUDGET_FILE = path.join(__dirname, "bundle-budget.json");
const STATS_DIR = path.join(paths.build_dir, "stats");
const readStats = (build) => {
const file = path.join(STATS_DIR, `${build}.json`);
if (!fs.existsSync(file)) {
throw new Error(
`Missing stats file: ${path.relative(process.cwd(), file)}.\n` +
`Run a production build first (e.g. \`gulp build-app\`), then re-run this check.`
);
}
return JSON.parse(fs.readFileSync(file, "utf8"));
};
// Initial JS bytes for an entrypoint = sum of the .js asset sizes of its initial
// entry chunk(s). Sizes are raw (uncompressed) bytes, matching the stats output.
const entrypointInitialJS = (stats, entrypoint) => {
const assetSize = new Map(stats.assets.map((a) => [a.name, a.size]));
let total = 0;
let found = false;
for (const chunk of stats.chunks) {
if (!chunk.entry || !chunk.initial) {
continue;
}
if (!(chunk.names || []).includes(entrypoint)) {
continue;
}
found = true;
for (const file of chunk.files || []) {
if (file.endsWith(".js") && assetSize.has(file)) {
total += assetSize.get(file);
}
}
}
if (!found) {
throw new Error(`Entrypoint "${entrypoint}" not found in bundle stats.`);
}
return total;
};
const kib = (bytes) => `${(bytes / 1024).toFixed(1)} KiB`;
const main = () => {
const update = process.argv.includes("--update");
const headroomArg = process.argv.find((a) => a.startsWith("--headroom="));
const headroom = headroomArg ? Number(headroomArg.split("=")[1]) : 0;
const current = {};
for (const build of BUILDS) {
const stats = readStats(build);
current[build] = {};
for (const entrypoint of TRACKED_ENTRYPOINTS) {
current[build][entrypoint] = entrypointInitialJS(stats, entrypoint);
}
}
if (update) {
const budget = { _comment: BUDGET_COMMENT };
for (const build of BUILDS) {
budget[build] = {};
for (const entrypoint of TRACKED_ENTRYPOINTS) {
budget[build][entrypoint] = Math.ceil(
current[build][entrypoint] * (1 + headroom / 100)
);
}
}
fs.writeFileSync(BUDGET_FILE, `${JSON.stringify(budget, null, 2)}\n`);
console.log(
`Updated ${path.relative(process.cwd(), BUDGET_FILE)} from current sizes` +
(headroom ? ` (+${headroom}% headroom).` : ".")
);
return;
}
if (!fs.existsSync(BUDGET_FILE)) {
throw new Error(
`Missing budget file ${path.relative(process.cwd(), BUDGET_FILE)}.\n` +
`Seed it from a production build with: node build-scripts/check-bundle-size.cjs --update --headroom=3`
);
}
const budget = JSON.parse(fs.readFileSync(BUDGET_FILE, "utf8"));
let failed = false;
console.log("Initial JS budget (entry chunks, raw bytes):\n");
for (const build of BUILDS) {
for (const entrypoint of TRACKED_ENTRYPOINTS) {
const actual = current[build][entrypoint];
const limit = budget[build] && budget[build][entrypoint];
if (typeof limit !== "number") {
failed = true;
console.log(
`${build} / ${entrypoint}: no budget set (current ${kib(actual)})`
);
continue;
}
const ok = actual <= limit;
const delta = (((actual - limit) / limit) * 100).toFixed(1);
console.log(
` ${ok ? "✓" : "✗"} ${build} / ${entrypoint}: ` +
`${kib(actual)} / ${kib(limit)}${ok ? "" : ` (+${delta}% over budget)`}`
);
if (!ok) {
failed = true;
}
}
}
if (failed) {
console.error(
"\nInitial JS budget exceeded for a critical entrypoint.\n" +
"Investigate what was pulled into the entry chunk (a static import that should be lazy?).\n" +
"If the growth is intentional, re-seed the budget:\n" +
" node build-scripts/check-bundle-size.cjs --update --headroom=3"
);
process.exit(1);
}
console.log("\nAll tracked entrypoints within budget.");
};
const BUDGET_COMMENT =
"Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. " +
"Enforced by build-scripts/check-bundle-size.cjs in CI. " +
"Re-seed after an intentional change with `--update --headroom=<percent>`.";
main();
+2 -2
View File
@@ -1,7 +1,7 @@
import fs from "fs";
import { glob } from "glob";
import gulp from "gulp";
import { load as loadYaml } from "js-yaml";
import yaml from "js-yaml";
import { marked } from "marked";
import path from "path";
import paths from "../paths.cjs";
@@ -47,7 +47,7 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
if (descriptionContent.startsWith("---")) {
const metadataEnd = descriptionContent.indexOf("---", 3);
metadata = loadYaml(descriptionContent.substring(3, metadataEnd));
metadata = yaml.load(descriptionContent.substring(3, metadataEnd));
descriptionContent = descriptionContent
.substring(metadataEnd + 3)
.trim();
-1
View File
@@ -240,7 +240,6 @@ gulp.task("rspack-dev-server-e2e-test-app", () =>
),
contentBase: paths.e2eTestApp_output_root,
port: 8095,
open: false,
})
);
+1 -3
View File
@@ -66,9 +66,7 @@ export class HaDemo extends HomeAssistantAppEl {
this._updateHass(hassUpdate),
};
// `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 hass = provideHass(this, initial, true);
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
-6
View File
@@ -240,12 +240,6 @@ export default tseslint.config(
globals: globals.node,
},
},
{
files: [".github/scripts/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
plugins: {
html,
-91
View File
@@ -1,4 +1,3 @@
import { ContextProvider } from "@lit/context";
import { mdiCog, mdiMenu } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@@ -20,22 +19,6 @@ 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 {
@@ -130,65 +113,6 @@ 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];
@@ -652,21 +576,6 @@ 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;
}
+28 -1
View File
@@ -1,4 +1,5 @@
import type { TemplateResult } from "lit";
import { ContextProvider } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
@@ -14,6 +15,11 @@ 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";
@@ -522,6 +528,17 @@ 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);
@@ -543,6 +560,16 @@ 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);
const hass = provideHass(this._demoRoot, {}, false, true);
hass.updateTranslations(null, "en");
hass.updateTranslations("lovelace", "en");
hass.addEntities(ENTITIES);
+1 -1
View File
@@ -151,7 +151,7 @@ class DemoMoreInfoClimate extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
const hass = provideHass(this._demoRoot, {}, false, true);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
+1 -1
View File
@@ -54,7 +54,7 @@ class DemoMoreInfoHumidifier extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
const hass = provideHass(this._demoRoot, {}, false, true);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
+16 -17
View File
@@ -23,12 +23,10 @@
"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)",
@@ -38,14 +36,14 @@
"@babel/runtime": "8.0.0",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.10.4",
"@codemirror/commands": "6.10.3",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.4",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.0",
"@codemirror/view": "6.43.3",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.1",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.9",
@@ -82,7 +80,6 @@
"@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",
@@ -99,12 +96,13 @@
"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.9",
"js-yaml": "5.1.0",
"intl-messageformat": "11.2.8",
"js-yaml": "4.2.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
@@ -146,16 +144,17 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.61.1",
"@rsdoctor/rspack-plugin": "1.5.16",
"@playwright/test": "1.60.0",
"@rsdoctor/rspack-plugin": "1.5.15",
"@rspack/core": "2.0.8",
"@rspack/dev-server": "2.1.0",
"@rspack/dev-server": "2.0.3",
"@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",
@@ -172,7 +171,7 @@
"eslint": "10.5.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.0",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-lit": "2.3.1",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-unused-imports": "4.4.1",
@@ -181,7 +180,7 @@
"fs-extra": "11.3.5",
"generate-license-file": "4.2.1",
"glob": "13.0.6",
"globals": "17.7.0",
"globals": "17.6.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
@@ -202,11 +201,11 @@
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.17",
"tar": "7.5.16",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.62.0",
"typescript-eslint": "8.61.1",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.9",
"webpack-stats-plugin": "1.1.3",
@@ -219,7 +218,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.21",
"globals": "17.7.0",
"globals": "17.6.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
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260624.0"
version = "20260624.6"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+6 -15
View File
@@ -1,24 +1,17 @@
import type {
Condition,
TimeCondition,
VisibilityCondition,
} from "../../panels/lovelace/common/validate-condition";
/**
* Extract media queries from conditions recursively
*/
export function extractMediaQueries(
conditions: VisibilityCondition[]
): string[] {
export function extractMediaQueries(conditions: Condition[]): string[] {
return conditions.reduce<string[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractMediaQueries(c.conditions));
}
if (
"condition" in c &&
c.condition === "screen" &&
"media_query" in c &&
c.media_query
) {
if (c.condition === "screen" && c.media_query) {
array.push(c.media_query);
}
return array;
@@ -29,16 +22,14 @@ export function extractMediaQueries(
* Extract time conditions from conditions recursively
*/
export function extractTimeConditions(
conditions: VisibilityCondition[]
conditions: Condition[]
): TimeCondition[] {
return conditions.reduce<TimeCondition[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractTimeConditions(c.conditions));
}
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);
if (c.condition === "time") {
array.push(c);
}
return array;
}, []);
+78 -50
View File
@@ -1,9 +1,10 @@
import { listenMediaQuery } from "../dom/media_query";
import type { HomeAssistant } from "../../types";
import type {
TimeCondition,
VisibilityCondition,
Condition,
ConditionContext,
} from "../../panels/lovelace/common/validate-condition";
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
import { extractMediaQueries, extractTimeConditions } from "./extract";
import { calculateNextTimeUpdate } from "./time-calculator";
@@ -15,68 +16,95 @@ import { calculateNextTimeUpdate } from "./time-calculator";
const MAX_TIMEOUT_DELAY = 2147483647;
/**
* 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.
* Helper to setup media query listeners for conditional visibility
*/
function scheduleTimeBoundaryListener(
getHass: () => HomeAssistant,
timeCondition: Omit<TimeCondition, "condition">,
export function setupMediaQueryListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onBoundary: () => void
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
): void {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const mediaQueries = extractMediaQueries(conditions);
const scheduleUpdate = () => {
// Read hass lazily so timezone changes are picked up on the next boundary.
const delay = calculateNextTimeUpdate(getHass(), timeCondition);
if (mediaQueries.length === 0) return;
if (delay === undefined) return;
// Optimization for single media query
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
// Cap delay to prevent setTimeout overflow
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
timeoutId = setTimeout(() => {
if (delay <= MAX_TIMEOUT_DELAY) {
onBoundary();
mediaQueries.forEach((mediaQuery) => {
const unsub = listenMediaQuery(mediaQuery, (matches) => {
if (hasOnlyMediaQuery) {
onUpdate(matches);
} else {
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);
}
});
addListener(unsub);
});
scheduleUpdate();
}
/**
* 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.
* Helper to setup time-based listeners for conditional visibility
*/
export function observeConditionChanges(
conditions: VisibilityCondition[],
getHass: () => HomeAssistant,
export function setupTimeListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onChange: () => void
onUpdate: (conditionsMet: boolean) => void,
getContext?: () => ConditionContext
): void {
extractMediaQueries(conditions).forEach((mediaQuery) => {
addListener(listenMediaQuery(mediaQuery, () => onChange()));
});
const timeConditions = extractTimeConditions(conditions);
extractTimeConditions(conditions).forEach((timeCondition) => {
scheduleTimeBoundaryListener(getHass, timeCondition, addListener, 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();
});
}
/**
* 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);
}
-176
View File
@@ -1,176 +0,0 @@
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),
};
};
-251
View File
@@ -1,251 +0,0 @@
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 };
};
@@ -1,329 +0,0 @@
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;
}
}
@@ -0,0 +1,59 @@
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 = [];
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { timeZonesNames } from "@vvo/tzdb";
import timezones from "google-timezones-json";
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" ||
timeZonesNames.includes(RESOLVED_RAW))
RESOLVED_RAW in timezones)
? RESOLVED_RAW
: undefined;
@@ -1,3 +1,4 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume } from "@lit/context";
import type { HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@@ -15,7 +16,12 @@ import {
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { CallWS, HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import {
DEFAULT_ROW_RENDERER_CONTENT,
type PickerComboBoxItem,
} from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
const NO_AUTOMATION_KEY = "NO_AUTOMATION";
@@ -105,6 +111,7 @@ export abstract class HaDeviceAutomationPicker<
.disabled=${!this._automations || this._automations.length === 0}
.getItems=${this._getItems(value, this._automations)}
@value-changed=${this._automationChanged}
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer}
.unknownItemText=${this.hass.localize(
"ui.panel.config.devices.automation.actions.unknown_action"
@@ -160,6 +167,13 @@ export abstract class HaDeviceAutomationPicker<
}
);
// Device automation labels (entity name + subtype) are often longer than the
// field, so let the option wrap onto multiple lines instead of truncating.
private _rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) =>
html`<ha-combo-box-item type="button" compact multiline>
${DEFAULT_ROW_RENDERER_CONTENT(item)}
</ha-combo-box-item>`;
private _valueRenderer: PickerValueRenderer = (value: string) => {
const automation = this._automations?.find(
(a, idx) => value === `${a.device_id}_${idx}`
+9
View File
@@ -7,6 +7,11 @@ export class HaComboBoxItem extends HaMdListItem {
@property({ type: Boolean, reflect: true, attribute: "border-top" })
public borderTop = false;
// Allow the headline/supporting text to wrap onto multiple lines instead of
// truncating with an ellipsis. Off by default to preserve single-line rows.
@property({ type: Boolean, reflect: true })
public multiline = false;
static override styles = [
...haMdListStyles,
css`
@@ -41,6 +46,10 @@ export class HaComboBoxItem extends HaMdListItem {
font-size: var(--ha-font-size-s);
white-space: nowrap;
}
:host([multiline]) [slot="headline"],
:host([multiline]) [slot="supporting-text"] {
white-space: normal;
}
::slotted(state-badge),
::slotted(img) {
width: 32px;
+15 -8
View File
@@ -135,8 +135,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
@state()
private _bodyScrolled = false;
private _escapePressed = false;
public connectedCallback(): void {
super.connectedCallback();
this.addEventListener(
@@ -290,7 +288,6 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Escape") {
this._escapePressed = true;
if (this.preventScrimClose) {
ev.preventDefault();
}
@@ -300,13 +297,23 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
}
private _handleHide(ev: DialogHideEvent) {
const sourceIsDialog = ev.detail?.source === (ev.target as WaDialog).dialog;
if (this.preventScrimClose && this._escapePressed && sourceIsDialog) {
ev.preventDefault();
// Ignore wa-hide events bubbling up from nested overlays (pickers,
// popovers, bottom sheets, nested dialogs); only handle this dialog's own
// hide, like the sibling wa-* handlers above.
if (ev.eventPhase !== Event.AT_TARGET) {
return;
}
this._escapePressed = false;
const sourceIsDialog = ev.detail?.source === (ev.target as WaDialog).dialog;
// A dialog-sourced hide (Escape, native cancel, or scrim) while the host
// still wants the dialog open is a user dismissal, not a programmatic
// close. Block it when closing must be guarded, regardless of where focus
// currently is — e.g. after a picker overlay took focus and dropped it
// outside the dialog, the Escape keydown never reaches this element.
if (this.preventScrimClose && sourceIsDialog && this.open) {
ev.preventDefault();
}
}
static get styles() {
+35 -39
View File
@@ -20,18 +20,16 @@ class HaRelativeTime extends ReactiveElement {
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
private _interval?: number;
private _timeout?: number;
public disconnectedCallback(): void {
super.disconnectedCallback();
this._clearInterval();
this._clearTimeout();
}
public connectedCallback(): void {
super.connectedCallback();
if (this.datetime) {
this._startInterval();
}
this._updateRelative();
}
protected createRenderRoot() {
@@ -40,31 +38,19 @@ class HaRelativeTime extends ReactiveElement {
protected update(changedProps: PropertyValues<this>) {
super.update(changedProps);
if (changedProps.has("datetime")) {
if (this.datetime) {
this._startInterval();
} else {
this._clearInterval();
}
}
this._updateRelative();
}
private _clearInterval(): void {
if (this._interval) {
window.clearInterval(this._interval);
this._interval = undefined;
private _clearTimeout(): void {
if (this._timeout) {
window.clearTimeout(this._timeout);
this._timeout = undefined;
}
}
private _startInterval(): void {
this._clearInterval();
// update every 60 seconds
this._interval = window.setInterval(() => this._updateRelative(), 60000);
}
private _updateRelative(): void {
this._clearTimeout();
if (!this._i18n) {
return;
}
@@ -73,23 +59,33 @@ class HaRelativeTime extends ReactiveElement {
this.textContent = this._i18n.localize(
"ui.components.relative_time.never"
);
} else {
const date =
typeof this.datetime === "string"
? parseISO(this.datetime)
: this.datetime;
const relTime = relativeTime(
date,
this._i18n.locale,
undefined,
true,
this.format
);
this.textContent = this.capitalize
? capitalizeFirstLetter(relTime)
: relTime;
return;
}
const date =
typeof this.datetime === "string"
? parseISO(this.datetime)
: this.datetime;
const relTime = relativeTime(
date,
this._i18n.locale,
undefined,
true,
this.format
);
this.textContent = this.capitalize
? capitalizeFirstLetter(relTime)
: relTime;
// Keep the relative time counting up on its own. Refresh every second
// while the difference is still measured in seconds, otherwise every
// minute.
const secondsDiff = Math.abs(Date.now() - date.getTime()) / 1000;
this._timeout = window.setTimeout(
() => this._updateRelative(),
secondsDiff < 60 ? 1000 : 60000
);
}
}
@@ -3,6 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { NumberSelector } from "../../data/selector";
import { isSafari } from "../../util/is_safari";
import "../ha-input-helper-text";
import "../ha-slider";
import "../input/ha-input";
@@ -66,6 +67,16 @@ export class HaNumberSelector extends LitElement {
}
}
// On iOS/iPadOS the numeric and decimal on-screen keypads have no minus key,
// so negatives can only be typed with the full "text" keyboard. Other
// platforms include a minus on their number keypads, so restrict this
// workaround to Safari/WebKit and only when the selector allows negatives
// (e.g. numeric_state triggers/conditions).
const useTextInputMode =
isSafari &&
this.selector.number?.min !== undefined &&
this.selector.number.min < 0;
const translationKey = this.selector.number?.translation_key;
let unit = this.selector.number?.unit_of_measurement;
if (isBox && unit && this.localizeValue && translationKey) {
@@ -96,10 +107,12 @@ export class HaNumberSelector extends LitElement {
`
: nothing}
<ha-input
.inputMode=${this.selector.number?.step === "any" ||
(this.selector.number?.step ?? 1) % 1 !== 0
? "decimal"
: "numeric"}
.inputmode=${useTextInputMode
? "text"
: this.selector.number?.step === "any" ||
(this.selector.number?.step ?? 1) % 1 !== 0
? "decimal"
: "numeric"}
.label=${!isBox ? undefined : this.label}
.placeholder=${this.placeholder !== undefined
? this.placeholder.toString()
+2 -2
View File
@@ -521,10 +521,10 @@ export class HaServiceControl extends LitElement {
${description ? html`<p>${description}</p>` : ""}
${this._manifest
? html` <a
href=${this._manifest.is_built_in
href=${this._manifest.is_built_in && this._value?.action
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
`/actions/${this._value.action}`
)
: this._manifest.documentation}
title=${this.hass.localize(
+5 -16
View File
@@ -17,18 +17,11 @@ 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;
@@ -45,15 +38,11 @@ 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
);
}
@@ -83,7 +72,7 @@ export class HaStateIcon extends LitElement {
this._entities,
this._config,
this._connection,
this._stateObj,
this.stateObj,
this.stateValue,
] as const,
});
@@ -93,7 +82,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) {
@@ -108,7 +97,7 @@ export class HaStateIcon extends LitElement {
}
private _renderFallback() {
const domain = computeStateDomain(this._stateObj!);
const domain = computeStateDomain(this.stateObj!);
return html`
<ha-svg-icon
+1
View File
@@ -461,6 +461,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
<div class="add-target-wrapper">
<ha-generic-picker
.hass=${this.hass}
popover-placement="bottom-start"
.disabled=${this.disabled}
.autofocus=${this.autofocus}
.helper=${this.helper}
+23 -25
View File
@@ -1,4 +1,4 @@
import { getTimeZones, timeZonesNames } from "@vvo/tzdb";
import timezones from "google-timezones-json";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -13,40 +13,38 @@ const SEARCH_KEYS = [
{ name: "secondary", weight: 8 },
];
// @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.
// 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.
const ADDITIONAL_TIMEZONES: PickerComboBoxItem[] = [
{ id: "UTC", primary: "+00:00 UTC", secondary: "UTC" },
{ id: "UTC", primary: "(GMT+00:00) UTC", secondary: "UTC" },
{ id: "Etc/UTC", primary: "(GMT+00:00) UTC", secondary: "Etc/UTC" },
];
export const getTimezoneOptions = (): PickerComboBoxItem[] => {
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)
);
// 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",
};
return [timezone.name, ...filteredGroup].map((nameString) => ({
id: nameString,
primary: timezone.rawFormat,
secondary: nameString,
}));
})
.map((item) => [item.id, item])
).values()
);
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,
};
});
for (const timezone of ADDITIONAL_TIMEZONES) {
if (!options.some((option) => option.id === timezone.id)) {
options.push(timezone);
}
}
return options;
};
+3 -2
View File
@@ -1,5 +1,5 @@
import type { Schema } from "js-yaml";
import { dump, load, YAML11_SCHEMA } from "js-yaml";
import { DEFAULT_SCHEMA, dump, load } from "js-yaml";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -30,7 +30,7 @@ const isEmpty = (obj: Record<string, unknown>): boolean => {
export class HaYamlEditor extends LitElement {
@property() public value?: any;
@property({ attribute: false }) public yamlSchema: Schema = YAML11_SCHEMA;
@property({ attribute: false }) public yamlSchema: Schema = DEFAULT_SCHEMA;
@property({ attribute: false }) public defaultValue?: any;
@@ -70,6 +70,7 @@ export class HaYamlEditor extends LitElement {
this._yaml = !isEmpty(value)
? dump(value, {
schema: this.yamlSchema,
quotingType: '"',
noRefs: true,
})
: "";
+29 -2
View File
@@ -1,10 +1,11 @@
import { consume, type ContextType } from "@lit/context";
import { mdiDeleteOutline, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { uid } from "../../common/util/uid";
import { internationalizationContext } from "../../data/context";
import { haStyle } from "../../resources/styles";
import "../ha-button";
@@ -69,6 +70,25 @@ class HaInputMulti extends LitElement {
@query("ha-input[data-last]") private _lastInput?: HaInput;
// Stable key per row, kept in sync with `value`. Because items are plain
// strings we cannot use a WeakMap (as the object-based sortable lists do),
// so we track keys in a parallel array. Keys stay fixed while a row is
// edited (preserving input focus) and travel with the row when reordered.
@state() private _keys: string[] = [];
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (changedProps.has("value") && this._keys.length !== this._items.length) {
// Reconcile keys when `value` is (re)set from outside, reusing existing
// keys and minting new ones for added rows. Internal add/remove/reorder
// keep `_keys` in sync themselves, so this is skipped in those cases.
this._keys = Array.from(
{ length: this._items.length },
(_, i) => this._keys[i] ?? uid()
);
}
}
protected render() {
return html`
<ha-sortable
@@ -80,7 +100,7 @@ class HaInputMulti extends LitElement {
<div class="items">
${repeat(
this._items,
(item, index) => `${item}-${index}`,
(_item, index) => this._keys[index],
(item, index) => {
const indexSuffix = `${this.itemIndex ? ` ${index + 1}` : ""}`;
return html`
@@ -162,6 +182,7 @@ class HaInputMulti extends LitElement {
if (this.max != null && this._items.length >= this.max) {
return;
}
this._keys = [...this._keys, uid()];
const items = [...this._items, ""];
this._fireChanged(items);
await this.updateComplete;
@@ -194,11 +215,17 @@ class HaInputMulti extends LitElement {
const items = [...this._items];
const [moved] = items.splice(oldIndex, 1);
items.splice(newIndex, 0, moved);
// Move the row's key with it so its DOM (and identity) is preserved.
const keys = [...this._keys];
const [movedKey] = keys.splice(oldIndex, 1);
keys.splice(newIndex, 0, movedKey);
this._keys = keys;
this._fireChanged(items);
}
private async _removeItem(ev: Event) {
const index = (ev.target as any).index;
this._keys = this._keys.filter((_, i) => i !== index);
const items = [...this._items];
items.splice(index, 1);
this._fireChanged(items);
@@ -74,7 +74,7 @@ export interface MediaPlayerItemId {
media_content_type?: string | undefined;
}
const MANUAL_ITEM_BASE: Omit<MediaPlayerItem, "title"> = {
const MANUAL_ITEM: MediaPlayerItem = {
can_expand: true,
can_play: false,
can_search: false,
@@ -83,6 +83,7 @@ const MANUAL_ITEM_BASE: Omit<MediaPlayerItem, "title"> = {
media_content_id: MANUAL_MEDIA_SOURCE_PREFIX,
media_content_type: "",
iconPath: mdiKeyboard,
title: "Manual entry",
};
@customElement("ha-media-player-browse")
@@ -239,7 +240,7 @@ export class HaMediaPlayerBrowse extends LitElement {
currentId.media_content_id &&
isManualMediaSourceContentId(currentId.media_content_id)
) {
this._currentItem = this._manualItem();
this._currentItem = MANUAL_ITEM;
fireEvent(this, "media-browsed", {
ids: navigateIds,
current: this._currentItem,
@@ -800,21 +801,12 @@ export class HaMediaPlayerBrowse extends LitElement {
return prom.then((item) => {
if (!mediaContentId && this.action === "pick") {
item.children = item.children || [];
item.children.push(this._manualItem());
item.children.push(MANUAL_ITEM);
}
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;
}
+28 -8
View File
@@ -416,12 +416,22 @@ export const saveAutomationConfig = (
config: AutomationConfig
) => hass.callApi<undefined>("POST", `config/automation/config/${id}`, config);
/**
* Accumulates whether a deprecated config option was migrated while
* normalizing an automation or script config. Used to surface an alert
* offering to save the migrated configuration.
*/
export interface AutomationMigrationReport {
deprecated: boolean;
}
export const normalizeAutomationConfig = <
T extends Partial<AutomationConfig> | AutomationConfig,
>(
config: T
config: T,
report?: AutomationMigrationReport
): T => {
config = migrateAutomationConfig(config);
config = migrateAutomationConfig(config, report);
// Normalize data: ensure triggers, actions and conditions are lists
// Happens when people copy paste their automations into the config
@@ -438,7 +448,8 @@ export const normalizeAutomationConfig = <
export const migrateAutomationConfig = <
T extends Partial<AutomationConfig> | AutomationConfig,
>(
config: T
config: T,
report?: AutomationMigrationReport
) => {
if ("trigger" in config) {
if (!("triggers" in config)) {
@@ -460,29 +471,30 @@ export const migrateAutomationConfig = <
}
if (config.triggers) {
config.triggers = migrateAutomationTrigger(config.triggers);
config.triggers = migrateAutomationTrigger(config.triggers, report);
}
if (config.actions) {
config.actions = migrateAutomationAction(config.actions);
config.actions = migrateAutomationAction(config.actions, report);
}
return config;
};
export const migrateAutomationTrigger = (
trigger: Trigger | Trigger[]
trigger: Trigger | Trigger[],
report?: AutomationMigrationReport
): Trigger | Trigger[] => {
if (!trigger) {
return trigger;
}
if (Array.isArray(trigger)) {
return trigger.map(migrateAutomationTrigger) as Trigger[];
return trigger.map((t) => migrateAutomationTrigger(t, report)) as Trigger[];
}
if ("triggers" in trigger && trigger.triggers) {
trigger.triggers = migrateAutomationTrigger(trigger.triggers);
trigger.triggers = migrateAutomationTrigger(trigger.triggers, report);
}
if ("platform" in trigger) {
@@ -495,10 +507,18 @@ export const migrateAutomationTrigger = (
if ("options" in trigger) {
if (trigger.options && "behavior" in trigger.options) {
// Deprecated behavior values renamed in 2026; the backend raises a repair
// when they are still used, so flag the migration to offer saving.
if (trigger.options.behavior === "any") {
trigger.options.behavior = "each";
if (report) {
report.deprecated = true;
}
} else if (trigger.options.behavior === "last") {
trigger.options.behavior = "all";
if (report) {
report.deprecated = true;
}
}
}
}
+3 -16
View File
@@ -1,6 +1,6 @@
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { atLeastVersion } from "../common/config/version";
import type { HomeAssistant, LogFileDisabledReason } from "../types";
import type { HomeAssistant } from "../types";
import type { HassioAddonInfo } from "./hassio/addon";
export interface LogProvider {
@@ -9,24 +9,11 @@ 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) =>
hasSupervisorCoreLogDownload(hass)
isComponentLoaded(hass.config, "hassio") &&
atLeastVersion(hass.config.version, 2025, 10)
? "/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;
};
+16 -6
View File
@@ -27,6 +27,7 @@ export interface LogbookEntry {
source?: string; // The trigger source (English phrase, parsed for the cause)
domain?: string;
state?: string; // The state of the entity
attributes?: { event_type?: string }; // Selected attributes the backend surfaces
// Context data
context_id?: string;
context_user_id?: string;
@@ -244,13 +245,13 @@ export const parseTriggerSource = (source: string): ParsedTriggerSource => {
};
// Short label shown instead of the bare timestamp for each timestamp-state
// domain. Typed to TIMESTAMP_STATE_DOMAINS minus datetime (a real value), so a
// new timestamp domain won't compile until it gets a label here.
// domain. Typed to TIMESTAMP_STATE_DOMAINS minus datetime (a real value) and
// event (handled separately via its event type), so a new timestamp domain
// won't compile until it gets a label here.
type LogbookActionMessage =
| "pressed"
| "activated"
| "scanned"
| "detected_event_no_type"
| "updated"
| "sent"
| "detected"
@@ -261,14 +262,13 @@ type LogbookActionMessage =
| "command_sent";
const STATE_ACTION_MESSAGES: Record<
Exclude<TimestampStateDomain, "datetime">,
Exclude<TimestampStateDomain, "datetime" | "event">,
LogbookActionMessage
> = {
button: "pressed",
input_button: "pressed",
scene: "activated",
tag: "scanned",
event: "detected_event_no_type",
image: "updated",
notify: "sent",
wake_word: "detected",
@@ -284,8 +284,18 @@ export const localizeStateMessage = (
hass: HomeAssistant,
state: string,
stateObj: HassEntity,
domain: string
domain: string,
attributes?: LogbookEntry["attributes"]
): string => {
// Events show the triggered event type, falling back to a generic label when
// the type is unknown (the timestamp state is meaningless on its own).
if (domain === "event") {
const eventType = attributes?.event_type;
if (eventType != null) {
return hass.formatEntityAttributeValue(stateObj, "event_type", eventType);
}
return hass.localize(`${LOGBOOK_LOCALIZE_PATH}.detected_event_no_type`);
}
const actionKey: LogbookActionMessage | undefined =
STATE_ACTION_MESSAGES[domain as keyof typeof STATE_ACTION_MESSAGES];
if (actionKey) {
+1 -1
View File
@@ -13,7 +13,7 @@ import {
import { isComponentLoaded } from "../common/config/is_component_loaded";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import { configSections } from "../panels/config/config-sections";
import { configSections } from "../panels/config/ha-panel-config";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import type { HassioAddonInfo } from "./hassio/addon";
+18 -13
View File
@@ -22,6 +22,7 @@ import { hasTemplate } from "../common/string/has-template";
import { createSearchParam } from "../common/url/search-params";
import type { HomeAssistant } from "../types";
import type {
AutomationMigrationReport,
Condition,
ShorthandAndCondition,
ShorthandNotCondition,
@@ -452,14 +453,15 @@ export const requiredScriptFieldsFilled = (
requiredScriptFieldsFilledForServices(hass.services, entityId, data);
export const migrateAutomationAction = (
action: Action | Action[]
action: Action | Action[],
report?: AutomationMigrationReport
): Action | Action[] => {
if (!action) {
return action;
}
if (Array.isArray(action)) {
return action.map(migrateAutomationAction) as Action[];
return action.map((a) => migrateAutomationAction(a, report)) as Action[];
}
if (typeof action === "object" && action !== null && "service" in action) {
@@ -506,7 +508,7 @@ export const migrateAutomationAction = (
if (typeof action === "object" && action !== null && "sequence" in action) {
delete (action as SequenceAction).metadata;
for (const sequenceAction of (action as SequenceAction).sequence) {
migrateAutomationAction(sequenceAction);
migrateAutomationAction(sequenceAction, report);
}
}
@@ -514,45 +516,48 @@ export const migrateAutomationAction = (
if (actionType === "parallel") {
const _action = action as ParallelAction;
migrateAutomationAction(_action.parallel);
migrateAutomationAction(_action.parallel, report);
}
if (actionType === "choose") {
const _action = action as ChooseAction;
if (Array.isArray(_action.choose)) {
for (const choice of _action.choose) {
migrateAutomationAction(choice.sequence);
migrateAutomationAction(choice.sequence, report);
}
} else if (_action.choose) {
migrateAutomationAction(_action.choose.sequence);
migrateAutomationAction(_action.choose.sequence, report);
}
if (_action.default) {
migrateAutomationAction(_action.default);
migrateAutomationAction(_action.default, report);
}
}
if (actionType === "repeat") {
const _action = action as RepeatAction;
migrateAutomationAction(_action.repeat.sequence);
migrateAutomationAction(_action.repeat.sequence, report);
}
if (actionType === "if") {
const _action = action as IfAction;
migrateAutomationAction(_action.then);
migrateAutomationAction(_action.then, report);
if (_action.else) {
migrateAutomationAction(_action.else);
migrateAutomationAction(_action.else, report);
}
}
if (actionType === "wait_for_trigger") {
const _action = action as WaitForTriggerAction;
migrateAutomationTrigger(_action.wait_for_trigger);
migrateAutomationTrigger(_action.wait_for_trigger, report);
}
return action;
};
export const normalizeScriptConfig = (config: ScriptConfig): ScriptConfig => {
export const normalizeScriptConfig = (
config: ScriptConfig,
report?: AutomationMigrationReport
): ScriptConfig => {
// Normalize data: ensure sequence is a list
// Happens when people copy paste their scripts into the config
const value = config.sequence;
@@ -560,7 +565,7 @@ export const normalizeScriptConfig = (config: ScriptConfig): ScriptConfig => {
config.sequence = [value];
}
if (config.sequence) {
config.sequence = migrateAutomationAction(config.sequence);
config.sequence = migrateAutomationAction(config.sequence, report);
}
return config;
};
@@ -459,7 +459,9 @@ class MoreInfoWeather extends LitElement {
</div>
`;
})
: html`<ha-spinner size="medium"></ha-spinner>`}
: html`<div class="loading">
<ha-spinner size="medium"></ha-spinner>
</div>`}
</div>
`
: nothing}
@@ -702,7 +704,10 @@ class MoreInfoWeather extends LitElement {
--mdc-icon-size: 40px;
}
.forecast ha-spinner {
.forecast .loading {
display: flex;
justify-content: center;
align-items: center;
height: 120px;
}
`,
+18 -53
View File
@@ -9,17 +9,11 @@ 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";
@@ -103,15 +97,11 @@ export const provideHass = (
elements,
overrideData: Partial<HomeAssistant> = {},
setHassProperty = false,
// Provide the grouped Lit contexts (registries, internationalization, api,
// connection, ui, config, formatters) that the real app's root element
// provides via `contextMixin`. On by default so that any standalone hass root
// (e.g. a gallery demo) automatically feeds context-consuming components the
// same way the real app does, instead of each demo wiring up a partial set by
// hand. Pass `false` for hosts that already provide these contexts themselves
// via `contextMixin` (the full app shell — `ha-demo`, `ha-test`), to avoid
// registering duplicate providers on the same element.
provideContexts = true
// 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
): MockHomeAssistant => {
elements = ensureArray(elements);
// Can happen because we store sidebar, more info etc on hass.
@@ -138,46 +128,21 @@ 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) {
(
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]
);
});
if (!contextProviders) {
return;
}
(
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 = {};
+64 -154
View File
@@ -1,19 +1,13 @@
import { consume } from "@lit/context";
import type { PropertyValues, ReactiveElement } from "lit";
import { state } from "lit/decorators";
import type { ConditionEvaluation } from "../common/controllers/condition-evaluator-controller";
import { ConditionEvaluatorController } from "../common/controllers/condition-evaluator-controller";
import type { HomeAssistant } from "../types";
import { setupConditionListeners } from "../common/condition/listeners";
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;
@@ -26,30 +20,22 @@ export interface ConditionalConfig {
}
/**
* Mixin to handle conditional visibility control.
* Mixin to handle conditional listeners for visibility control
*
* 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.
* Provides lifecycle management for listeners that control conditional
* visibility of components.
*
* Usage:
* 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.
* 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)
*
* The mixin automatically:
* - 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).
* - 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
*/
export const ConditionalListenerMixin = <
TConfig extends ConditionalConfig = ConditionalConfig,
@@ -57,6 +43,8 @@ export const ConditionalListenerMixin = <
superClass: Constructor<ReactiveElement>
) => {
abstract class ConditionalListenerClass extends superClass {
private __listeners: (() => void)[] = [];
protected _config?: TConfig;
public config?: TConfig;
@@ -69,51 +57,6 @@ 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;
@@ -123,6 +66,11 @@ export const ConditionalListenerMixin = <
this.setupConditionalListeners();
}
public disconnectedCallback() {
super.disconnectedCallback();
this.clearConditionalListeners();
}
protected willUpdate(changedProperties: PropertyValues) {
super.willUpdate(changedProperties);
if (changedProperties.has("_maxColumns")) {
@@ -135,105 +83,67 @@ export const ConditionalListenerMixin = <
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
// 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();
if (changedProperties.has("_maxColumns")) {
this._updateVisibility?.();
}
}
/**
* Resolve the observed conditions to a visibility boolean.
* Clear conditional listeners
*
* 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.
* 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.
*/
protected _conditionsVisible(): boolean {
const conditions = this.__conditions;
if (!conditions || conditions.length === 0) {
return true;
}
if (this.__conditionResult !== "unknown") {
return this.__conditionResult === "visible";
}
if (!this.hass) {
return true;
}
return checkConditionsMet(
conditions as Condition[],
this.hass,
this._conditionContext
);
protected clearConditionalListeners(): void {
this.__listeners.forEach((unsub) => unsub());
this.__listeners = [];
}
/**
* Feed the current conditions to the evaluator.
* Add a conditional listener to the list of listeners
*
* Override to supply a custom condition set (e.g. the conditional card's
* `conditions`) and call `super.setupConditionalListeners(customConditions)`.
* This method is called when a new listener is added.
* It adds the listener to the list of listeners.
*
* @param conditions - Optional conditions. Defaults to
* `config.visibility` / `_config.visibility`.
* @param unsubscribe - The unsubscribe function to call when the listener is no longer needed
* @returns void
*/
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;
protected addConditionalListener(unsubscribe: () => void): void {
this.__listeners.push(unsubscribe);
}
this.__conditions = finalConditions;
/**
* 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;
// 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;
if (!finalConditions || !this.hass) {
return;
}
this.__conditionEvaluator.observe(
this.__observed,
setupConditionListeners(
finalConditions,
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 "../config-sections";
import { configSections } from "../ha-panel-config";
import { showAddApplicationCredentialDialog } from "./show-dialog-add-application-credential";
@customElement("ha-config-application-credentials")
@@ -1,5 +1,5 @@
import { mdiDotsVertical } from "@mdi/js";
import { defineScalarTag, YAML11_SCHEMA } from "js-yaml";
import { DEFAULT_SCHEMA, Type } from "js-yaml";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -47,11 +47,12 @@ const SUPPORTED_UI_TYPES = [
"schema",
];
const secretTag = defineScalarTag("!secret", {
resolve: (data) => `!secret ${data}`,
});
const ADDON_YAML_SCHEMA = YAML11_SCHEMA.withTags(secretTag);
const ADDON_YAML_SCHEMA = DEFAULT_SCHEMA.extend([
new Type("!secret", {
kind: "scalar",
construct: (data) => `!secret ${data}`,
}),
]);
const MASKED_FIELDS = ["password", "secret", "token"];
@@ -5,6 +5,7 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { extractSearchParam } from "../../../common/url/search-params";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
@@ -23,8 +24,14 @@ import type {
StoreAddon,
SupervisorStore,
} from "../../../data/supervisor/store";
import { fetchSupervisorStore } from "../../../data/supervisor/store";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import {
addStoreRepository,
fetchSupervisorStore,
} from "../../../data/supervisor/store";
import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-loading-screen";
import "../../../layouts/hass-subpage";
@@ -82,7 +89,15 @@ export class HaConfigAppsAvailable extends LitElement {
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this._loadData();
const repositoryUrl = extractSearchParam("repository_url");
if (repositoryUrl) {
navigate("/config/apps/available", { replace: true });
}
this._loadData().then(() => {
if (repositoryUrl) {
this._addRepository(repositoryUrl);
}
});
this.addEventListener("hass-api-called", (ev) => this._apiCalled(ev));
}
@@ -226,6 +241,40 @@ export class HaConfigAppsAvailable extends LitElement {
navigate("/config/apps/registries");
}
private async _addRepository(repositoryUrl: string): Promise<void> {
if (
!this._store ||
this._store.repositories.some((repo) => repo.source === repositoryUrl)
) {
return;
}
if (
!(await showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.apps.my.add_repository_title"
),
text: this.hass.localize(
"ui.panel.config.apps.my.add_repository_store_description",
{ repository: repositoryUrl }
),
confirmText: this.hass.localize("ui.common.add"),
dismissText: this.hass.localize("ui.common.cancel"),
}))
) {
return;
}
try {
await addStoreRepository(this.hass, repositoryUrl);
await this._loadData();
} catch (err: any) {
showAlertDialog(this, {
text: extractApiErrorMessage(err),
});
}
}
private async _loadData(): Promise<void> {
try {
const [addon, store] = await Promise.all([
@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { extractSearchParam } from "../../../common/url/search-params";
import "../../../components/data-table/ha-data-table";
import type { DataTableColumnContainer } from "../../../components/data-table/ha-data-table";
import "../../../components/ha-button";
@@ -56,12 +55,7 @@ export class HaConfigAppsRepositories extends LitElement {
@state() private _error?: string;
protected firstUpdated() {
this._loadData().then(() => {
const repositoryUrl = extractSearchParam("repository_url");
if (repositoryUrl) {
this._addRepository(repositoryUrl);
}
});
this._loadData();
}
private _columns = memoizeOne(
@@ -224,18 +218,6 @@ export class HaConfigAppsRepositories extends LitElement {
});
}
private async _addRepository(url: string) {
try {
await addStoreRepository(this.hass, url);
await this._loadData();
fireEvent(this, "apps-collection-refresh", { collection: "store" });
} catch (err: any) {
showAlertDialog(this, {
text: extractApiErrorMessage(err),
});
}
}
private _removeRepository = async (ev: Event) => {
const slug = (ev.currentTarget as any).slug;
const repo = this._repositories?.find((r) => r.slug === slug);
@@ -54,7 +54,7 @@ import {
import "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { showToast } from "../../../util/toast";
import { configSections } from "../config-sections";
import { configSections } from "../ha-panel-config";
import {
loadAreaRegistryDetailDialog,
showAreaRegistryDetailDialog,
@@ -195,7 +195,7 @@ export class HaPlatformCondition extends LitElement {
href=${this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
`/conditions/${this.condition.condition}`
)
: this._manifest.documentation}
title=${this.hass.localize(
@@ -170,6 +170,32 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
}
}
private _renderDeprecatedMigratedAlert(): TemplateResult | typeof nothing {
if (!this.deprecatedConfigMigrated || this.readOnly) {
return nothing;
}
return html`<ha-alert
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.automation.editor.deprecated_migrated.title"
)}
>
${this.hass.localize(
"ui.panel.config.automation.editor.deprecated_migrated.description"
)}
<ha-button
appearance="filled"
size="s"
variant="warning"
slot="action"
.disabled=${this.saving}
@click=${this._handleSaveAutomation}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-alert>`;
}
protected render(): TemplateResult | typeof nothing {
if (!this.config) {
return this.renderLoading();
@@ -443,6 +469,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
@editor-save=${this._handleSaveAutomation}
>
<div class="alert-wrapper" slot="alerts">
${this._renderDeprecatedMigratedAlert()}
${this.errors || stateObj?.state === UNAVAILABLE
? html`<ha-alert
alert-type="error"
@@ -527,7 +554,8 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
</div>
`
: this.mode === "yaml"
? html`${stateObj?.state === "off"
? html`${this._renderDeprecatedMigratedAlert()}
${stateObj?.state === "off"
? html`
<ha-alert alert-type="info">
${this.hass.localize(
@@ -618,8 +646,17 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
...baseConfig,
...(initData ? normalizeAutomationConfig(initData) : initData),
} as AutomationConfig;
this._initDirtyTracking({ type: "deep" }, baseConfig as AutomationConfig);
this._updateDirtyState(this.config);
this._initDirtyTracking(
{ type: "deep" },
{
config: baseConfig as AutomationConfig,
entityRegistryUpdate: this.entityRegistryUpdate,
}
);
this._updateDirtyState({
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.currentEntityId = undefined;
this.readOnly = false;
}
@@ -627,7 +664,13 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
if (changedProps.has("entityId") && this.entityId) {
getAutomationStateConfig(this.hass, this.entityId).then((c) => {
this.config = normalizeAutomationConfig(c.config);
this._initDirtyTracking({ type: "deep" }, this.config);
this._initDirtyTracking(
{ type: "deep" },
{
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
}
);
this._checkValidation();
});
this.currentEntityId = this.entityId;
@@ -693,7 +736,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
if (this.readOnly) {
return;
}
this._updateDirtyState(this.config);
this._updateDirtyState({
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.errors = undefined;
}
@@ -774,7 +820,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
id: this.config?.id,
...normalizeAutomationConfig(ev.detail.value),
};
this._updateDirtyState(this.config!);
this._updateDirtyState({
config: this.config!,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.errors = undefined;
}
@@ -790,7 +839,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this._updateDirtyState(this.config);
this._updateDirtyState({
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.requestUpdate();
const id = this.automationId || String(Date.now());
@@ -904,7 +956,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this._updateDirtyState(this.config);
this._updateDirtyState({
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.requestUpdate();
resolve(true);
},
@@ -921,7 +976,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
config: this.config!,
updateConfig: (config) => {
this.config = config;
this._updateDirtyState(config);
this._updateDirtyState({
config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.requestUpdate();
resolve();
},
@@ -941,7 +999,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this._manualEditor?.resetPastedConfig();
const id = this.automationId || String(Date.now());
if (!this.automationId) {
if (!this.automationId && !this.config?.alias) {
const saved = await this._promptAutomationAlias();
if (!saved) {
return;
@@ -1013,6 +1071,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
}
this._markDirtyStateClean();
this.deprecatedConfigMigrated = false;
} catch (errors: any) {
this.errors = errors.body?.message || errors.error || errors.body;
showEditorToast(this, {
@@ -1071,7 +1130,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
private _applyUndoRedo(config: AutomationConfig) {
this._manualEditor?.triggerCloseSidebar();
this.config = config;
this._updateDirtyState(this.config);
this._updateDirtyState({
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
}
private _undo() {
@@ -120,7 +120,7 @@ import {
getLabelsTableColumn,
getTriggeredAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../config-sections";
import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import {
getAssistantsSortableKey,
@@ -12,6 +12,7 @@ import { goBack, navigate } from "../../../common/navigate";
import { afterNextRender } from "../../../common/util/render-status";
import "../../../components/animation/ha-fade-in";
import "../../../components/ha-spinner"; // used by renderLoading() provided to both editors
import type { AutomationMigrationReport } from "../../../data/automation";
import { fireRelatedContext, fullEntitiesContext } from "../../../data/context";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import {
@@ -79,17 +80,22 @@ export const automationScriptEditorStyles: CSSResult = css`
export interface EditorDomainHooks<TConfig> {
fetchFileConfig(hass: HomeAssistant, id: string): Promise<TConfig>;
normalizeConfig(raw: TConfig): TConfig;
normalizeConfig(raw: TConfig, report?: AutomationMigrationReport): TConfig;
checkValidation(): Promise<void>;
domain: "automation" | "script";
}
interface AutomationEditorConfig<TConfig> {
config: TConfig;
entityRegistryUpdate?: EntityRegistryUpdate;
}
export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
superClass: Constructor<LitElement>
) => {
class AutomationScriptEditorClass extends DirtyStateProviderMixin<TConfig>()(
superClass
) {
class AutomationScriptEditorClass extends DirtyStateProviderMixin<
AutomationEditorConfig<TConfig>
>()(superClass) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@@ -116,6 +122,8 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
@state() protected saving = false;
@state() protected deprecatedConfigMigrated = false;
@state() protected validationErrors?: (string | TemplateResult)[];
@state() protected config?: TConfig;
@@ -217,8 +225,17 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
protected takeControlSave() {
this.readOnly = false;
// Force dirty: set baseline to null so current config always differs
this._initDirtyTracking({ type: "deep" }, null as unknown as TConfig);
this._updateDirtyState(this.config!);
this._initDirtyTracking(
{ type: "deep" },
{
config: null as unknown as TConfig,
entityRegistryUpdate: this.entityRegistryUpdate,
}
);
this._updateDirtyState({
config: this.config!,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.blueprintConfig = undefined;
}
@@ -257,8 +274,19 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
try {
const config = await hooks.fetchFileConfig(this.hass, id);
this.readOnly = false;
this.config = hooks.normalizeConfig(config);
this._initDirtyTracking({ type: "deep" }, this.config);
const report: AutomationMigrationReport = { deprecated: false };
this.config = hooks.normalizeConfig(config, report);
// The config is loaded as its migrated (clean) version, so it never
// looks dirty. Surface an alert offering to save when deprecated
// options were migrated.
this.deprecatedConfigMigrated = report.deprecated;
this._initDirtyTracking(
{ type: "deep" },
{
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
}
);
hooks.checkValidation();
} catch (err: any) {
if (err.status_code !== 404) {
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { load, YAML11_SCHEMA } from "js-yaml";
import { load } from "js-yaml";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
@@ -224,7 +224,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
let loaded: any;
try {
loaded = load(paste, { schema: YAML11_SCHEMA });
loaded = load(paste);
} catch (_err: any) {
showEditorToast(this, {
message: this.hass.localize(
@@ -189,7 +189,7 @@ export class HaPlatformTrigger extends LitElement {
href=${this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
`/triggers/${this.trigger.trigger}`
)
: this._manifest.documentation}
title=${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 "../config-sections";
import { configSections } from "../ha-panel-config";
import { showAddBlueprintDialog } from "./show-dialog-import-blueprint";
type BlueprintMetaDataPath = BlueprintMetaData & {
@@ -41,9 +41,7 @@ export class DialogSupportPackage extends LitElement {
<ha-dialog
.open=${this._open}
width="full"
.headerTitle=${this.hass.localize(
"ui.panel.config.cloud.account.download_support_package"
)}
header-title="Download support package"
@closed=${this._dialogClosed}
>
${this._supportPackage
@@ -54,16 +52,13 @@ export class DialogSupportPackage extends LitElement {
: html`
<div class="progress-container">
<ha-spinner></ha-spinner>
${this.hass.localize(
"ui.panel.config.cloud.account.support_package_generating_preview"
)}...
Generating preview...
</div>
`}
<div slot="footer" class="footer">
<ha-alert>
${this.hass.localize(
"ui.panel.config.cloud.account.support_package_privacy_warning"
)}
This file may contain personal data about your home. Avoid sharing
them with unverified or untrusted parties.
</ha-alert>
<hr />
<ha-dialog-footer>
@@ -72,10 +67,10 @@ export class DialogSupportPackage extends LitElement {
appearance="plain"
@click=${this.closeDialog}
>
${this.hass.localize("ui.common.close")}
Close
</ha-button>
<ha-button slot="primaryAction" @click=${this._download}>
${this.hass.localize("ui.common.download")}
Download
</ha-button>
</ha-dialog-footer>
</div>
-555
View File
@@ -1,555 +0,0 @@
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 "../config-sections";
import { configSections } from "../ha-panel-config";
@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 "../config-sections";
import { configSections } from "../ha-panel-config";
import "../repairs/ha-config-repairs";
import "./ha-config-navigation";
import "./ha-config-updates";
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
@@ -40,6 +41,15 @@ For loop example getting entity values in the weather domain:
{{ state.name | lower }} is {{state.state_with_unit}}
{%- endfor %}.`;
// key resolves the label/description translation keys; path is passed through
// documentationUrl().
const TEMPLATE_DOCS_LINKS: { key: string; path: string }[] = [
{ key: "docs_introduction", path: "/docs/templating/introduction/" },
{ key: "docs_states", path: "/docs/templating/states/" },
{ key: "docs_debugging", path: "/docs/templating/debugging/" },
{ key: "docs_functions", path: "/template-functions/" },
];
@customElement("developer-tools-template")
class HaPanelDevTemplate extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -120,31 +130,36 @@ class HaPanelDevTemplate extends LitElement {
"ui.panel.config.developer-tools.tabs.templates.description"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.templates.engine_info"
)}
</p>
<h3>
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.templates.learn_more"
)}
</h3>
<ul>
<li>
<a
href="https://jinja.palletsprojects.com/en/latest/templates/"
target="_blank"
rel="noreferrer"
>${this.hass.localize(
"ui.panel.config.developer-tools.tabs.templates.jinja_documentation"
)}
</a>
</li>
<li>
<a
href=${documentationUrl(
this.hass,
"/docs/configuration/templating/"
)}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.developer-tools.tabs.templates.template_extensions"
)}</a
>
</li>
${TEMPLATE_DOCS_LINKS.map(
(link) => html`
<li>
<a
href=${documentationUrl(this.hass, link.path)}
target="_blank"
rel="noreferrer"
>${this.hass.localize(
`ui.panel.config.developer-tools.tabs.templates.${link.key}` as LocalizeKeys
)}</a
>
<span class="link-description"
>${this.hass.localize(
`ui.panel.config.developer-tools.tabs.templates.${link.key}_description` as LocalizeKeys
)}</span
>
</li>
`
)}
</ul>
</div>
</ha-expansion-panel>
@@ -430,6 +445,17 @@ ${type === "object"
margin-block-start: var(--ha-space-1);
margin-block-end: var(--ha-space-1);
}
.description > h3 {
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
margin-block-end: var(--ha-space-1);
}
.description li {
margin-block-end: var(--ha-space-1);
}
.description .link-description {
color: var(--secondary-text-color);
}
.render-pane .card-content {
user-select: text;
@@ -93,7 +93,7 @@ import {
getLabelsTableColumn,
getModifiedAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../config-sections";
import { configSections } from "../ha-panel-config";
import "../integrations/ha-integration-overflow-menu";
import { showAddIntegrationDialog } from "../integrations/show-add-integration-dialog";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
@@ -386,9 +386,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._dirtyState?.setState(
{
name: this._name.trim() || null,
icon: this._icon.trim() || null,
entityId: this._entityId.trim(),
name: this._name || null,
icon: this._icon || null,
entityId: this._entityId,
areaId: this._areaId ?? null,
labels: this._labels ?? [],
deviceClass: this._deviceClass,
@@ -118,7 +118,7 @@ import {
getLabelsTableColumn,
getModifiedAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../config-sections";
import { configSections } from "../ha-panel-config";
import type { Helper } from "../helpers/const";
import { isHelperDomain } from "../helpers/const";
import "../integrations/ha-integration-overflow-menu";
+554
View File
@@ -1,5 +1,43 @@
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";
@@ -10,6 +48,7 @@ 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 {
@@ -19,6 +58,521 @@ 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;
+7 -28
View File
@@ -122,7 +122,7 @@ import {
getEntityIdTableColumn,
getLabelsTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../config-sections";
import { configSections } from "../ha-panel-config";
import { renderConfigEntryError } from "../integrations/ha-config-integration-page";
import "../integrations/ha-integration-overflow-menu";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
@@ -133,23 +133,6 @@ 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;
@@ -159,7 +142,7 @@ interface HelperItem {
editable?: boolean;
type: string;
configEntry?: ConfigEntry;
entity?: LimitedEntity;
entity?: HassEntity;
category: string | undefined;
area?: string;
label_entries: LabelRegistryEntry[];
@@ -238,7 +221,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
})
private _activeHiddenColumns?: string[];
@state() private _helperEntities?: LimitedEntity[];
@state() private _helperEntities?: HassEntity[];
@state() private _disabledEntityEntries?: EntityRegistryEntry[];
@@ -355,9 +338,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
moveable: false,
template: (helper) =>
helper.entity
? html`<ha-state-icon
.entityId=${helper.entity_id}
></ha-state-icon>`
? html`<ha-state-icon .stateObj=${helper.entity}></ha-state-icon>`
: html`<ha-svg-icon
.path=${helper.icon}
style="color: var(--error-color)"
@@ -484,7 +465,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
private _getItems = memoizeOne(
(
localize: LocalizeFunc,
stateItems: LimitedEntity[],
stateItems: HassEntity[],
disabledEntries: EntityRegistryEntry[],
entityEntries: Record<string, EntityRegistryEntry>,
configEntries: Record<string, ConfigEntry>,
@@ -519,7 +500,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
type: configEntry
? configEntry.domain
: this._entitySource![entityState.entity_id] ||
computeDomain(entityState.entity_id),
computeStateDomain(entityState),
configEntry,
entity: entityState,
};
@@ -1288,9 +1269,7 @@ ${rejected
if (
!this._helperEntities ||
this._helperEntities.length !== newHelpers.length ||
!this._helperEntities.every((val, idx) =>
equalLimitedEntity(newHelpers[idx], val)
)
!this._helperEntities.every((val, idx) => newHelpers[idx] === val)
) {
this._helperEntities = newHelpers;
if (Object.keys(this._filters).length > 0) {
@@ -47,10 +47,7 @@ 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 {
getCoreLogFileDownloadUnavailableReason,
getErrorLogDownloadUrl,
} from "../../../data/error_log";
import { getErrorLogDownloadUrl } from "../../../data/error_log";
import type {
IntegrationLogInfo,
IntegrationManifest,
@@ -1182,20 +1179,6 @@ 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 "../config-sections";
import { configSections } from "../ha-panel-config";
import { isHelperDomain } from "../helpers/const";
import "./ha-config-flow-card";
import type { DataEntryFlowProgressExtended } from "./ha-config-integrations";
@@ -221,10 +221,6 @@ export class ZHAGroupPage extends LitElement {
static get styles(): CSSResultGroup {
return [
css`
hass-subpage {
--app-header-text-color: var(--sidebar-icon-color);
}
.container {
box-sizing: border-box;
max-width: 720px;
+1 -1
View File
@@ -57,7 +57,7 @@ import {
getCreatedAtTableColumn,
getModifiedAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../config-sections";
import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "./show-dialog-label-detail";
type ConfigTranslationKey = FlattenObjectKeys<
+11 -47
View File
@@ -45,11 +45,7 @@ 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,
getCoreLogFileDownloadUnavailableReason,
getErrorLogDownloadUrl,
} from "../../../data/error_log";
import { fetchErrorLog, getErrorLogDownloadUrl } from "../../../data/error_log";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import {
fetchHassioBoots,
@@ -135,11 +131,6 @@ 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
@@ -196,13 +187,11 @@ class ErrorLogCard extends LitElement {
</ha-dropdown>
`
: nothing}
${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=${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}
@@ -259,21 +248,11 @@ class ErrorLogCard extends LitElement {
<ha-spinner></ha-spinner>
</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 === "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
@@ -388,13 +367,6 @@ 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,
@@ -428,14 +400,6 @@ 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") &&
+22 -51
View File
@@ -6,7 +6,6 @@ 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";
@@ -15,10 +14,7 @@ import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-spinner";
import { getSignedPath } from "../../../data/auth";
import {
getCoreLogFileDownloadUnavailableReason,
getErrorLogDownloadUrl,
} from "../../../data/error_log";
import { getErrorLogDownloadUrl } from "../../../data/error_log";
import { domainToName } from "../../../data/integration";
import type { LoggedError } from "../../../data/system_log";
import {
@@ -92,8 +88,6 @@ export class SystemLogCard extends LitElement {
);
protected render() {
const logFileDownloadUnavailableReason =
getCoreLogFileDownloadUnavailableReason(this.hass);
const filteredItems = this._items
? this._getFilteredItems(
this.hass.localize,
@@ -115,55 +109,36 @@ export class SystemLogCard extends LitElement {
`
: html`
<div class="header">
<h1 class="card-header">
${this.header ||
this.hass.localize("ui.panel.config.logs.caption")}
</h1>
<h1 class="card-header">${this.header || "Logs"}</h1>
<div class="header-buttons">
${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=${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>
${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>`}
<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">
@@ -254,10 +229,6 @@ 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`;
+1 -1
View File
@@ -27,7 +27,7 @@ import "../../../layouts/hass-tabs-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import "../ha-config-section";
import { configSections } from "../config-sections";
import { configSections } from "../ha-panel-config";
import {
loadPersonDetailDialog,
showPersonDetailDialog,
@@ -108,7 +108,7 @@ import {
getLabelsTableColumn,
renderRelativeTimeColumn,
} from "../common/data-table-columns";
import { configSections } from "../config-sections";
import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import {
getAssistantsSortableKey,
+81 -14
View File
@@ -130,6 +130,32 @@ export class HaScriptEditor extends SubscribeMixin(
}
}
private _renderDeprecatedMigratedAlert(): TemplateResult | typeof nothing {
if (!this.deprecatedConfigMigrated || this.readOnly) {
return nothing;
}
return html`<ha-alert
alert-type="warning"
.title=${this.hass.localize(
"ui.panel.config.script.editor.deprecated_migrated.title"
)}
>
${this.hass.localize(
"ui.panel.config.script.editor.deprecated_migrated.description"
)}
<ha-button
appearance="filled"
size="s"
variant="warning"
slot="action"
.disabled=${this.saving}
@click=${this._handleSaveScript}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</ha-alert>`;
}
protected render(): TemplateResult | typeof nothing {
if (!this.config) {
return this.renderLoading();
@@ -398,6 +424,7 @@ export class HaScriptEditor extends SubscribeMixin(
@save-script=${this._handleSaveScript}
>
<div class="alert-wrapper" slot="alerts">
${this._renderDeprecatedMigratedAlert()}
${this.errors || stateObj?.state === UNAVAILABLE
? html`<ha-alert
alert-type="error"
@@ -462,7 +489,8 @@ export class HaScriptEditor extends SubscribeMixin(
</div>
`
: this.mode === "yaml"
? html`<ha-yaml-editor
? html`${this._renderDeprecatedMigratedAlert()}
<ha-yaml-editor
.defaultValue=${this._preprocessYaml()}
.readOnly=${this.readOnly}
disable-fullscreen
@@ -532,15 +560,30 @@ export class HaScriptEditor extends SubscribeMixin(
...baseConfig,
...initData,
} as ScriptConfig;
this._initDirtyTracking({ type: "deep" }, baseConfig as ScriptConfig);
this._updateDirtyState(this.config);
this._initDirtyTracking(
{ type: "deep" },
{
config: baseConfig as ScriptConfig,
entityRegistryUpdate: this.entityRegistryUpdate,
}
);
this._updateDirtyState({
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.readOnly = false;
}
if (changedProps.has("entityId") && this.entityId) {
getScriptStateConfig(this.hass, this.entityId).then((c) => {
this.config = normalizeScriptConfig(c.config);
this._initDirtyTracking({ type: "deep" }, this.config);
this._initDirtyTracking(
{ type: "deep" },
{
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
}
);
this._checkValidation();
});
const regEntry = this.entityRegistry?.find(
@@ -585,7 +628,10 @@ export class HaScriptEditor extends SubscribeMixin(
this.config = ev.detail.value;
this.errors = undefined;
this._updateDirtyState(this.config!);
this._updateDirtyState({
config: this.config!,
entityRegistryUpdate: this.entityRegistryUpdate,
});
}
private async _runScript() {
@@ -672,7 +718,10 @@ export class HaScriptEditor extends SubscribeMixin(
}
this._manualEditor?.addFields();
this._updateDirtyState(this.config!);
this._updateDirtyState({
config: this.config!,
entityRegistryUpdate: this.entityRegistryUpdate,
});
}
private _preprocessYaml() {
@@ -687,7 +736,10 @@ export class HaScriptEditor extends SubscribeMixin(
}
this.yamlErrors = undefined;
this.config = ev.detail.value;
this._updateDirtyState(this.config!);
this._updateDirtyState({
config: this.config!,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.errors = undefined;
}
@@ -703,7 +755,10 @@ export class HaScriptEditor extends SubscribeMixin(
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this._updateDirtyState(this.config);
this._updateDirtyState({
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.requestUpdate();
const id = this.scriptId || String(Date.now());
@@ -818,7 +873,10 @@ export class HaScriptEditor extends SubscribeMixin(
updateConfig: async (config, entityRegistryUpdate) => {
this.config = config;
this.entityRegistryUpdate = entityRegistryUpdate;
this._updateDirtyState(this.config);
this._updateDirtyState({
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.requestUpdate();
resolve(true);
},
@@ -837,7 +895,10 @@ export class HaScriptEditor extends SubscribeMixin(
config: this.config!,
updateConfig: (config) => {
this.config = config;
this._updateDirtyState(config);
this._updateDirtyState({
config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
this.requestUpdate();
resolve();
},
@@ -857,9 +918,11 @@ export class HaScriptEditor extends SubscribeMixin(
this._manualEditor?.resetPastedConfig();
if (!this.scriptId) {
const saved = await this._promptScriptAlias();
if (!saved) {
return;
if (!this.config?.alias) {
const saved = await this._promptScriptAlias();
if (!saved) {
return;
}
}
this.currentEntityId = this._computeEntityIdFromAlias(this.config!.alias);
}
@@ -934,6 +997,7 @@ export class HaScriptEditor extends SubscribeMixin(
}
this._markDirtyStateClean();
this.deprecatedConfigMigrated = false;
} catch (errors: any) {
this.errors = errors.body?.message || errors.error || errors.body;
showEditorToast(this, {
@@ -983,7 +1047,10 @@ export class HaScriptEditor extends SubscribeMixin(
private _applyUndoRedo(config: ScriptConfig) {
this._manualEditor?.triggerCloseSidebar();
this.config = config;
this._updateDirtyState(this.config);
this._updateDirtyState({
config: this.config,
entityRegistryUpdate: this.entityRegistryUpdate,
});
}
private _undo() {
+1 -1
View File
@@ -113,7 +113,7 @@ import {
getLabelsTableColumn,
getTriggeredAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../config-sections";
import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import {
getAssistantsSortableKey,
+1 -3
View File
@@ -197,9 +197,7 @@ export class HaScriptTrace extends LitElement {
</div>
${this._traces === undefined
? html`<div class="container">
${this.hass.localize("ui.panel.config.script.trace.loading")}
</div>`
? html`<div class="container">Loading…</div>`
: this._traces.length === 0
? html`<div class="container">
${this.hass!.localize(
@@ -1,5 +1,5 @@
import { mdiHelpCircleOutline } from "@mdi/js";
import { load, YAML11_SCHEMA } from "js-yaml";
import { load } from "js-yaml";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, query, queryAll } from "lit/decorators";
@@ -197,7 +197,7 @@ export class HaManualScriptEditor extends ManualEditorMixin<ScriptConfig>(
let loaded: any;
try {
loaded = load(paste, { schema: YAML11_SCHEMA });
loaded = load(paste);
} catch (_err: any) {
showEditorToast(this, {
message: this.hass.localize(
+1 -1
View File
@@ -37,7 +37,7 @@ import "../../../layouts/hass-tabs-subpage-data-table";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant, Route } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { configSections } from "../config-sections";
import { configSections } from "../ha-panel-config";
import { showTagDetailDialog } from "./show-dialog-tag-detail";
import "./tag-image";
+1 -1
View File
@@ -23,7 +23,7 @@ import {
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage-data-table";
import type { HomeAssistant, Route } from "../../../types";
import { configSections } from "../config-sections";
import { configSections } from "../ha-panel-config";
import { showAddUserDialog } from "./show-dialog-add-user";
import { showUserDetailDialog } from "./show-dialog-user-detail";
import { storage } from "../../../common/decorators/storage";
+1 -1
View File
@@ -43,7 +43,7 @@ import "../../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant, Route } from "../../../types";
import "../ha-config-section";
import { configSections } from "../config-sections";
import { configSections } from "../ha-panel-config";
import { showHomeZoneDetailDialog } from "./show-dialog-home-zone-detail";
import { showZoneDetailDialog } from "./show-dialog-zone-detail";
+14 -6
View File
@@ -64,13 +64,17 @@ class HaPanelHistory extends LitElement {
@state() private _endDate: Date;
@state()
@state() private _targetPickerValue: HassServiceTarget = {};
// Remembers the last user-picked selection as a fallback for visits without
// URL params. Kept separate from _targetPickerValue because localStorage is
// synced across tabs and would leak one tab's selection into the others.
@storage({
key: "historyPickedValue",
state: true,
state: false,
subscribe: false,
})
private _targetPickerValue: HassServiceTarget = {};
private _storedTargetPickerValue?: HassServiceTarget;
@state() private _isLoading = false;
@@ -224,9 +228,11 @@ class HaPanelHistory extends LitElement {
const queryParams = decodeHistoryLogbookQueryParams(
extractSearchParamsObject()
);
const targetPickerValue = historyLogbookTargetFromQueryParams(queryParams);
if (targetPickerValue) {
this._targetPickerValue = targetPickerValue;
const initialValue =
historyLogbookTargetFromQueryParams(queryParams) ??
this._storedTargetPickerValue;
if (initialValue) {
this._targetPickerValue = initialValue;
}
if (queryParams.start_date) {
this._startDate = queryParams.start_date;
@@ -264,6 +270,7 @@ class HaPanelHistory extends LitElement {
private _removeAll() {
this._targetPickerValue = {};
this._storedTargetPickerValue = this._targetPickerValue;
this._updatePath();
}
@@ -398,6 +405,7 @@ class HaPanelHistory extends LitElement {
private _targetsChanged(ev) {
this._targetPickerValue = ev.detail.value || {};
this._storedTargetPickerValue = this._targetPickerValue;
this._updatePath();
}
+10 -3
View File
@@ -40,13 +40,17 @@ export class HaPanelLogbook extends LitElement {
@state()
private _showBack?: boolean;
@state()
@state() private _targetPickerValue: HassServiceTarget = {};
// Remembers the last user-picked selection as a fallback for visits without
// URL params. Kept separate from _targetPickerValue because localStorage is
// synced across tabs and would leak one tab's selection into the others.
@storage({
key: "logbookPickedValue",
state: true,
state: false,
subscribe: false,
})
private _targetPickerValue: HassServiceTarget = {};
private _storedTargetPickerValue?: HassServiceTarget;
public constructor() {
super();
@@ -176,6 +180,8 @@ export class HaPanelLogbook extends LitElement {
const targetPickerValue = historyLogbookTargetFromQueryParams(queryParams);
if (targetPickerValue) {
this._targetPickerValue = targetPickerValue;
} else if (!this.hasUpdated && this._storedTargetPickerValue) {
this._targetPickerValue = this._storedTargetPickerValue;
}
if (queryParams.start_date || queryParams.end_date) {
@@ -208,6 +214,7 @@ export class HaPanelLogbook extends LitElement {
private _targetsChanged(ev) {
this._targetPickerValue = ev.detail.value || {};
this._storedTargetPickerValue = this._targetPickerValue;
this._updatePath();
}
+7 -1
View File
@@ -273,7 +273,13 @@ const computeLogbookValue = (
if (item.entity_id && item.state) {
return {
text: stateObj
? localizeStateMessage(hass, item.state, stateObj, domain!)
? localizeStateMessage(
hass,
item.state,
stateObj,
domain!,
item.attributes
)
: item.state,
type: "state",
};
+9 -1
View File
@@ -7,6 +7,7 @@ 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";
@@ -164,7 +165,14 @@ export class HuiBadge extends ConditionalListenerMixin<LovelaceBadgeConfig>(
return;
}
const visible = conditionsMet ?? this._conditionsVisible();
const visible =
conditionsMet ??
(!this.config?.visibility ||
checkConditionsMet(
this.config.visibility,
this.hass,
this._conditionContext
));
this._setElementVisibility(visible);
}
@@ -58,9 +58,7 @@ export class HuiErrorBadge extends LitElement implements LovelaceBadge {
class="error"
@click=${this._viewDetail}
type="button"
.label=${this.hass?.localize(
"ui.panel.lovelace.editor.error_section.title"
) ?? ""}
label="Error"
>
<ha-svg-icon slot="icon" .path=${mdiAlertCircle}></ha-svg-icon>
<div class="content">${this._config.error}</div>

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