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