mirror of
https://github.com/home-assistant/frontend.git
synced 2026-07-08 09:33:00 +00:00
Compare commits
124 Commits
20260624.3
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| db0813904c | |||
| b30668995d | |||
| 9ba003f86a | |||
| 0536e2cd2a | |||
| 0c25f061ef | |||
| 62e5c9dee9 | |||
| 36fa14ee26 | |||
| 14294998e7 | |||
| 60549695b0 | |||
| 41068657bd | |||
| 7d11f283d2 | |||
| 0927a55a0c | |||
| 9e75f9282e | |||
| 56af189032 | |||
| 02c99dd61d | |||
| f00342d522 | |||
| 5538be0d0a | |||
| 96b5c2910e | |||
| 30c44403de | |||
| 43c16f0c77 | |||
| 42fe3c6f97 | |||
| 903f954947 | |||
| 50e6691c30 | |||
| b0e8d45003 | |||
| 14e7f434e0 | |||
| 535b1b03f2 | |||
| 14db6c99fe | |||
| 97226acab9 | |||
| df0d1a945f | |||
| dccd798c26 | |||
| 757c7a3e7f | |||
| 7ccd1371bf | |||
| fe9017fbdf | |||
| 86181e293a | |||
| 0879d01dee | |||
| de358e4834 | |||
| d4855bfddd | |||
| ce3b38f2b4 | |||
| 18608a17ca | |||
| 80bffd605f | |||
| f4778bef29 | |||
| 0533b11816 | |||
| 5f1e495203 | |||
| 6f1b4fdc6c | |||
| 67e1ca3f43 | |||
| 36330d8220 | |||
| 0df3919a49 | |||
| b5e03b23b9 | |||
| ca1ab06384 | |||
| fb7ed8bfd4 | |||
| b72b6c77bf | |||
| 7f0ddae91e | |||
| 71e4303fa5 | |||
| 9bb7704a3a | |||
| 3cc9817b90 | |||
| 674755e430 | |||
| f84664909f | |||
| 4fd631f229 | |||
| 18cf41b793 | |||
| e28788cb95 | |||
| f81b43491d | |||
| 23335fffdb | |||
| 0a93a681e3 | |||
| 7bc2cad83e | |||
| 39ee60a8ef | |||
| e06d46e87f | |||
| 45704587f3 | |||
| f7c9033b22 | |||
| 0edb5c5241 | |||
| 3ca9b8b9aa | |||
| ba10fd0447 | |||
| 3171e00929 | |||
| 032790750c | |||
| 9807b9bb33 | |||
| 29853ab37d | |||
| d9e996d901 | |||
| 3d1630e497 | |||
| 301c08dc86 | |||
| bc9af7fc2f | |||
| 86c1a81aca | |||
| 05afa19a76 | |||
| 66775f03dd | |||
| 53e47e58f1 | |||
| 7b2569346f | |||
| 72fe6e1cbb | |||
| 26f270720a | |||
| e01bef53dc | |||
| 04226dda32 | |||
| b8fc05d5c4 | |||
| d602e77fc3 | |||
| a19842bd4d | |||
| 13872baa8c | |||
| a1aaf3fe33 | |||
| 84840dc922 | |||
| 8e43688ed8 | |||
| d9037b84c8 | |||
| c070765f54 | |||
| 8e5d976f7b | |||
| 2dbb052200 | |||
| 3b04f29755 | |||
| 865b5b1b80 | |||
| b44c69b1b0 | |||
| 27787e51f8 | |||
| dc7daf3156 | |||
| b898468193 | |||
| 781aa116b8 | |||
| 60c86899f3 | |||
| f8d870d6bb | |||
| 4d82b352a9 | |||
| 179b4cf77c | |||
| 542f07606a | |||
| cf2c440e7b | |||
| 27fbabb71b | |||
| 389af6e00c | |||
| 7ff4cf58e8 | |||
| f849302876 | |||
| 1db707937b | |||
| 70f0d12e43 | |||
| 12bb09dad2 | |||
| f08ffefe28 | |||
| 9de89278cd | |||
| 207d997a3a | |||
| 0bb32aa1b4 | |||
| ba0310ee58 |
@@ -67,7 +67,7 @@ DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed w
|
||||
<!--
|
||||
If your issue is about how an entity is shown in the UI, please add the state
|
||||
and attributes for all situations with a screenshot of the UI.
|
||||
You can find this information at `/config/developer-tools/state`
|
||||
You can find this information at `/config/tools/state`
|
||||
-->
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -94,8 +94,8 @@ body:
|
||||
label: State of relevant entities
|
||||
description: >
|
||||
If your issue is about how an entity is shown in the UI, please add the
|
||||
state and attributes for all situations. You can find this information
|
||||
at Developer Tools -> States.
|
||||
state and attributes for all situations. You can find this
|
||||
information in the Details view of the More info dialog.
|
||||
render: txt
|
||||
- type: textarea
|
||||
attributes:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Build frontend target
|
||||
description: Run a gulp build target
|
||||
|
||||
inputs:
|
||||
target:
|
||||
description: gulp target to run
|
||||
required: true
|
||||
github-token:
|
||||
description: GitHub token for fetching nightly translations; omit to build English-only
|
||||
default: ""
|
||||
is-test:
|
||||
description: Set IS_TEST for the build (skips source maps and compression)
|
||||
default: "false"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Build ${{ inputs.target }}
|
||||
shell: bash
|
||||
run: ./node_modules/.bin/gulp ${{ inputs.target }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.github-token }}
|
||||
IS_TEST: ${{ inputs.is-test }}
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Deploy to Netlify
|
||||
description: Deploy a directory to Netlify (production when alias is empty, otherwise to the alias)
|
||||
|
||||
inputs:
|
||||
dir:
|
||||
description: Directory to deploy
|
||||
required: true
|
||||
alias:
|
||||
description: Deploy alias; leave empty to deploy to production
|
||||
default: ""
|
||||
auth-token:
|
||||
description: NETLIFY_AUTH_TOKEN
|
||||
required: true
|
||||
site-id:
|
||||
description: NETLIFY_SITE_ID
|
||||
required: true
|
||||
|
||||
outputs:
|
||||
netlify_url:
|
||||
description: The deployed URL
|
||||
value: ${{ steps.deploy.outputs.netlify_url }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
shell: bash
|
||||
env:
|
||||
DIR: ${{ inputs.dir }}
|
||||
ALIAS: ${{ inputs.alias }}
|
||||
NETLIFY_AUTH_TOKEN: ${{ inputs.auth-token }}
|
||||
NETLIFY_SITE_ID: ${{ inputs.site-id }}
|
||||
run: |
|
||||
if [ -n "$ALIAS" ]; then
|
||||
npx -y netlify-cli deploy --dir="$DIR" --alias "$ALIAS" --json > deploy_output.json
|
||||
else
|
||||
npx -y netlify-cli deploy --dir="$DIR" --prod --json > deploy_output.json
|
||||
fi
|
||||
echo "netlify_url=$(jq -r '.url // .deploy_url' deploy_output.json)" >> "$GITHUB_OUTPUT"
|
||||
@@ -0,0 +1,23 @@
|
||||
name: Setup Node and install
|
||||
description: Set up Node from .nvmrc and install yarn dependencies
|
||||
|
||||
inputs:
|
||||
immutable:
|
||||
description: Pass --immutable to yarn install
|
||||
default: "true"
|
||||
cache:
|
||||
description: Enable the yarn cache in setup-node
|
||||
default: "true"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: ${{ inputs.cache == 'true' && 'yarn' || '' }}
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: yarn install ${{ inputs.immutable == 'true' && '--immutable' || '' }}
|
||||
@@ -25,7 +25,8 @@ yarn lint # ESLint + Prettier + TypeScript + Lit
|
||||
yarn format # Auto-fix ESLint + Prettier
|
||||
yarn lint:types # TypeScript compiler (run WITHOUT file arguments)
|
||||
yarn test # Vitest
|
||||
script/develop # Development server
|
||||
yarn dev # Dev server (app; --background/--status/--stop/--logs)
|
||||
yarn dev:serve # Dev server with serve (-c core URL, -p port; --background/--status/--stop/--logs)
|
||||
```
|
||||
|
||||
> **WARNING:** Never run `tsc` or `yarn lint:types` with file arguments (e.g., `yarn lint:types src/file.ts`). When `tsc` receives file arguments, it ignores `tsconfig.json` and emits `.js` files into `src/`, polluting the codebase. Always run `yarn lint:types` without arguments. For individual file type checking, rely on IDE diagnostics. If `.js` files are accidentally generated, clean up with `git clean -fd src/`.
|
||||
@@ -495,6 +496,26 @@ this.hass.localize("ui.panel.config.updates.update_available", {
|
||||
4. **Test**: `yarn test` - Add and run tests
|
||||
5. **Build**: `script/build_frontend` - Test production build
|
||||
|
||||
### Dev servers
|
||||
|
||||
`yarn dev` builds and watches the app, served by a running Home Assistant core (`development_repo` setting). `yarn dev:serve` also serves it locally (`-c` core URL, `-p` port, default 8124).
|
||||
|
||||
These and the e2e dev servers below take `--background`, `--status`, `--stop`, and `--logs [--follow]`.
|
||||
|
||||
### End-to-end (e2e) tests
|
||||
|
||||
Each Playwright suite has a dev server on its own port. Playwright reuses a server already on the port (`reuseExistingServer` locally); otherwise it does a slow full build. The rspack watcher recompiles on save, so re-runs need no restart.
|
||||
|
||||
Start the suite's dev server, then run the suite:
|
||||
|
||||
- **App** (8095): `yarn test:e2e:app:dev`, then `yarn test:e2e:app`
|
||||
- **Demo** (8090): `yarn dev:demo`, then `yarn test:e2e:demo`
|
||||
- **Gallery** (8100): `yarn dev:gallery`, then `yarn test:e2e:gallery`
|
||||
|
||||
Server reuse and `--stop` key off a `/__ha_dev_status` health check, so starting or stopping twice is harmless. The app suite uses a stripped-down harness built only for e2e; demo and gallery use their normal dev servers.
|
||||
|
||||
Add `-g "<title>" --project=chromium` to narrow a run; `yarn test:e2e` runs all three. Run the suite directly, since piping through `tail`/`head` hides progress and truncates results.
|
||||
|
||||
### Gallery
|
||||
|
||||
For Gallery-specific structure, page/demo naming, sidebar behavior, content standards, and commands, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
# Dependabot only scans .github/workflows by default; composite actions
|
||||
# under .github/actions must be listed explicitly to stay updated.
|
||||
# https://github.com/dependabot/dependabot-core/issues/6704
|
||||
directories:
|
||||
- "/"
|
||||
- "/.github/actions/setup"
|
||||
- "/.github/actions/build"
|
||||
- "/.github/actions/netlify-deploy"
|
||||
schedule:
|
||||
interval: weekly
|
||||
time: "06:00"
|
||||
|
||||
@@ -42,5 +42,36 @@ Dependencies:
|
||||
GitHub Actions:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- .github/actions/**
|
||||
- .github/workflows/**
|
||||
- .github/*.yml
|
||||
|
||||
"Tests: E2E":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/e2e/**
|
||||
|
||||
"Tests: App":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/e2e/app.spec.ts
|
||||
- test/e2e/app/**
|
||||
- test/e2e/playwright.app.config.ts
|
||||
|
||||
"Tests: Demo":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/e2e/demo.spec.ts
|
||||
- test/e2e/playwright.demo.config.ts
|
||||
|
||||
"Tests: Design":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/e2e/gallery.spec.ts
|
||||
- test/e2e/playwright.gallery.config.ts
|
||||
|
||||
"Tests: Unit":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- test/*.ts
|
||||
- "test/!(e2e)/**"
|
||||
|
||||
@@ -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.yaml 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"],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
name: Lint workflow files
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/workflows/**"
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/workflows/**"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
name: Check workflow files
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# renovate: datasource=github-releases depName=rhysd/actionlint
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run actionlint
|
||||
run: |
|
||||
curl -sSfL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz actionlint
|
||||
./actionlint -color
|
||||
@@ -20,31 +20,16 @@ jobs:
|
||||
name: Check for labels which block the Pull Request from being merged
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Check for blocking labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const blockingLabels = [
|
||||
"wait for backend",
|
||||
"Needs UX",
|
||||
"Do Not Review",
|
||||
"Blocked",
|
||||
"has-parent",
|
||||
];
|
||||
const prLabels = context.payload.pull_request.labels.map(
|
||||
(l) => l.name
|
||||
const { default: checkBlockingLabels } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`
|
||||
);
|
||||
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
|
||||
if (found.length > 0) {
|
||||
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
|
||||
await core.summary
|
||||
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
|
||||
.addRaw(message)
|
||||
.write();
|
||||
core.setFailed(message);
|
||||
} else {
|
||||
await core.summary
|
||||
.addHeading(":white_check_mark: Pull Request is clear to merge after review", 2)
|
||||
.addRaw("This Pull Request is not blocked by any labels which prevent it from being merged.")
|
||||
.write();
|
||||
}
|
||||
await checkBlockingLabels({ github, context, core });
|
||||
|
||||
@@ -21,35 +21,31 @@ jobs:
|
||||
if: github.event_name != 'push'
|
||||
environment:
|
||||
name: Cast Development
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- 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: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Cast
|
||||
run: ./node_modules/.bin/gulp build-cast
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-cast
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=cast/dist --alias dev
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: cast/dist
|
||||
alias: dev
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
|
||||
deploy_master:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -57,32 +53,27 @@ jobs:
|
||||
if: github.event_name == 'push'
|
||||
environment:
|
||||
name: Cast Production
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
- 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: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Cast
|
||||
run: ./node_modules/.bin/gulp build-cast
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-cast
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=cast/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: cast/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
|
||||
+29
-30
@@ -12,7 +12,6 @@ on:
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -27,22 +26,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
- name: Check for duplicate dependencies
|
||||
run: yarn dedupe --check
|
||||
- name: Build resources
|
||||
id: build_resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Setup lint cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
node_modules/.cache/prettier
|
||||
@@ -53,58 +50,60 @@ jobs:
|
||||
- name: Run eslint
|
||||
run: yarn run lint:eslint --quiet
|
||||
- name: Run tsc
|
||||
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
|
||||
run: yarn run lint:types
|
||||
- name: Run lit-analyzer
|
||||
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
|
||||
run: yarn run lint:lit --quiet
|
||||
- name: Run prettier
|
||||
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
|
||||
run: yarn run lint:prettier
|
||||
- name: Check dependency licenses
|
||||
if: ${{ !cancelled() && steps.build_resources.outcome == 'success' }}
|
||||
run: yarn run lint:licenses
|
||||
test:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Run Tests
|
||||
run: yarn run test
|
||||
build:
|
||||
name: Build frontend
|
||||
needs: [lint, test]
|
||||
needs:
|
||||
- lint
|
||||
- test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Application
|
||||
run: ./node_modules/.bin/gulp build-app
|
||||
env:
|
||||
IS_TEST: "true"
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-app
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
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,43 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches:
|
||||
- dev
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 360
|
||||
permissions:
|
||||
contents: read # To check out the repository
|
||||
security-events: write # To upload CodeQL results
|
||||
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
@@ -1,65 +0,0 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, master]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [dev]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Override automatic language detection by changing the below list
|
||||
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
|
||||
language: ["javascript"]
|
||||
# Learn more...
|
||||
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
|
||||
# If this run was triggered by a pull request event, then checkout
|
||||
# the head of the pull request instead of the merge commit.
|
||||
- run: git checkout HEAD^2
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
@@ -22,35 +22,30 @@ jobs:
|
||||
if: github.event_name != 'push' || github.ref_name != 'master'
|
||||
environment:
|
||||
name: Demo Development
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- 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: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Demo
|
||||
run: ./node_modules/.bin/gulp build-demo
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=demo/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: demo/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
|
||||
|
||||
deploy_master:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -58,32 +53,27 @@ jobs:
|
||||
if: github.event_name == 'push' && github.ref_name == 'master'
|
||||
environment:
|
||||
name: Demo Production
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
- 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: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Demo
|
||||
run: ./node_modules/.bin/gulp build-demo
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=demo/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: demo/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_DEMO_SITE_ID }}
|
||||
|
||||
@@ -16,31 +16,26 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: Design
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
url: ${{ steps.deploy.outputs.netlify_url }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Gallery
|
||||
run: ./node_modules/.bin/gulp build-gallery
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=gallery/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: gallery/dist
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
|
||||
|
||||
@@ -24,34 +24,27 @@ jobs:
|
||||
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build Gallery
|
||||
run: ./node_modules/.bin/gulp build-gallery
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Deploy preview to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
|
||||
--json > deploy_output.json
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
|
||||
uses: ./.github/actions/netlify-deploy
|
||||
with:
|
||||
dir: gallery/dist
|
||||
alias: deploy-preview-${{ github.event.number }}
|
||||
auth-token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site-id: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
|
||||
|
||||
- name: Generate summary
|
||||
run: |
|
||||
NETLIFY_LIVE_URL=$(jq -r '.deploy_url' deploy_output.json)
|
||||
echo "$NETLIFY_LIVE_URL" >> "$GITHUB_STEP_SUMMARY"
|
||||
run: echo "${{ steps.deploy.outputs.netlify_url }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
+46
-71
@@ -28,23 +28,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build demo
|
||||
run: ./node_modules/.bin/gulp build-demo
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-demo
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload demo build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -60,23 +55,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build e2e test app
|
||||
run: ./node_modules/.bin/gulp build-e2e-test-app
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-e2e-test-app
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload e2e test app build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -92,23 +82,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Build gallery
|
||||
run: ./node_modules/.bin/gulp build-gallery
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload gallery build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -129,45 +114,45 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
# Resolve the installed Playwright version so the browser cache tracks
|
||||
# Playwright itself, not every unrelated dependency bump.
|
||||
- name: Resolve Playwright version
|
||||
id: playwright-version
|
||||
run: echo "version=$(node -p 'require("@playwright/test/package.json").version')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Cache the downloaded browser build keyed on the pinned Playwright
|
||||
# version (yarn.lock), so re-runs skip the ~170 MB download.
|
||||
# Cache the downloaded browser build keyed on the installed Playwright
|
||||
# version, so re-runs skip the ~170 MB download unless Playwright changes.
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
|
||||
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
|
||||
|
||||
- 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
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
@@ -195,21 +180,15 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- 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: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Download blob report (local)
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: blob-report-local
|
||||
@@ -229,16 +208,12 @@ jobs:
|
||||
path: test/e2e/reports/combined/
|
||||
retention-days: 14
|
||||
|
||||
- name: Post report link to PR
|
||||
- name: Post report to PR
|
||||
if: github.event_name == 'pull_request' && needs.e2e-local.result == 'failure'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
const body = `## Playwright E2E tests failed\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
const { default: postReportComment } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/test/e2e/post-report-comment.mjs`
|
||||
);
|
||||
await postReportComment({ github, context, core });
|
||||
|
||||
@@ -20,23 +20,19 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
immutable: false
|
||||
|
||||
- name: Download translations
|
||||
run: ./script/translations_download
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Pull request standards
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, no PR code checkout
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, checks out base repo scripts only, never PR head code
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
@@ -23,168 +23,16 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write # To label and comment on pull requests
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Check pull request standards
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
|
||||
if (pr.user.type === "Bot") {
|
||||
core.info(`Skipping bot author: ${pr.user.login}`);
|
||||
return;
|
||||
}
|
||||
if (pr.draft) {
|
||||
core.info("Skipping draft pull request");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: pr.user.login,
|
||||
});
|
||||
core.info(`Skipping organization member: ${pr.user.login}`);
|
||||
return;
|
||||
} catch (error) {
|
||||
core.info(`${pr.user.login} is not an organization member, checking standards`);
|
||||
}
|
||||
|
||||
const label = "Needs Template";
|
||||
const marker = "<!-- pr-standards-check -->";
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = pr.number;
|
||||
|
||||
const body = (pr.body || "").replace(/<!--[\s\S]*?-->/g, "");
|
||||
const normalized = body.toLowerCase();
|
||||
|
||||
// Ignore 404s from mutations that race manual edits or cancelled runs.
|
||||
const ignoreMissing = async (fn) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info("Target already removed, nothing to do");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Hide/restore our comment via GraphQL (REST cannot minimize).
|
||||
const setMinimized = async (subjectId, minimized) => {
|
||||
const mutation = minimized
|
||||
? `mutation($id: ID!) {
|
||||
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
|
||||
clientMutationId
|
||||
}
|
||||
}`
|
||||
: `mutation($id: ID!) {
|
||||
unminimizeComment(input: { subjectId: $id }) {
|
||||
clientMutationId
|
||||
}
|
||||
}`;
|
||||
try {
|
||||
await github.graphql(mutation, { id: subjectId });
|
||||
} catch (error) {
|
||||
core.info(
|
||||
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Content of a "## <name>" section, or null when the heading is absent.
|
||||
const section = (name) => {
|
||||
const match = body.match(
|
||||
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
|
||||
);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const problems = [];
|
||||
|
||||
const requiredHeadings = [
|
||||
"## proposed change",
|
||||
"## type of change",
|
||||
"## checklist",
|
||||
];
|
||||
if (requiredHeadings.some((h) => !normalized.includes(h))) {
|
||||
problems.push(
|
||||
"Use the pull request template without removing its sections."
|
||||
);
|
||||
}
|
||||
|
||||
const typeOfChange = section("type of change");
|
||||
if (typeOfChange !== null) {
|
||||
const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length;
|
||||
if (ticked !== 1) {
|
||||
problems.push(
|
||||
'Select exactly one option under "Type of change".'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const proposedChange = section("proposed change");
|
||||
if (proposedChange !== null && proposedChange.trim().length === 0) {
|
||||
problems.push('Describe your changes under "Proposed change".');
|
||||
}
|
||||
|
||||
const isValid = problems.length === 0;
|
||||
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ owner, repo, issue_number, per_page: 100 }
|
||||
);
|
||||
const existing = comments.find((c) => c.body.includes(marker));
|
||||
const hasLabel = pr.labels.some((l) => l.name === label);
|
||||
|
||||
if (isValid) {
|
||||
core.info("Pull request standards met");
|
||||
|
||||
if (hasLabel) {
|
||||
await ignoreMissing(() =>
|
||||
github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number, name: label,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (existing) {
|
||||
await setMinimized(existing.node_id, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
|
||||
|
||||
if (!hasLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number, labels: [label],
|
||||
});
|
||||
}
|
||||
|
||||
const message =
|
||||
`${marker}\n` +
|
||||
`Hey @${pr.user.login}!\n\n` +
|
||||
`Thank you for your contribution! To help reviewers, please update ` +
|
||||
`this pull request to follow our pull request standards:\n\n` +
|
||||
problems.map((p) => `- ${p}`).join("\n") +
|
||||
`\n\n` +
|
||||
`Please complete the ` +
|
||||
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
|
||||
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
|
||||
`for more on creating a great pull request (see point 6).`;
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner, repo, comment_id: existing.id, body: message,
|
||||
});
|
||||
await setMinimized(existing.node_id, false);
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number, body: message,
|
||||
});
|
||||
}
|
||||
|
||||
// Fail this check so it can block the PR from being merged
|
||||
core.setFailed(
|
||||
`Pull request standards not met:\n- ${problems.join("\n- ")}`
|
||||
const { default: checkStandards } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-pull-request-standards.mjs`
|
||||
);
|
||||
await checkStandards({ github, context, core });
|
||||
|
||||
@@ -18,6 +18,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
|
||||
- uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -26,25 +26,23 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@e91ad1948e57189485b9c1ad608af0c303946f89 # master
|
||||
uses: home-assistant/actions/helpers/verify-version@f4ca6f671bd429efb108c0f2fa0ae8af0215986c # master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
immutable: false
|
||||
cache: false
|
||||
|
||||
- name: Download Translations
|
||||
run: ./script/translations_download
|
||||
@@ -97,7 +95,7 @@ jobs:
|
||||
|
||||
# home-assistant/wheels doesn't support SHA pinning
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
|
||||
uses: home-assistant/wheels@9e17ab1ed5c4c79d8b61e29fa63de25ca2710716 # 2026.07.0
|
||||
with:
|
||||
abi: cp314
|
||||
tag: musllinux_1_2
|
||||
@@ -113,15 +111,14 @@ jobs:
|
||||
contents: write # Required to upload release assets
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
immutable: false
|
||||
cache: false
|
||||
- name: Download Translations
|
||||
run: ./script/translations_download
|
||||
env:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Restrict task creation
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}
|
||||
|
||||
jobs:
|
||||
add-no-stale:
|
||||
name: Add no-stale label
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write # To add labels to issues
|
||||
if: >-
|
||||
github.event.issue.type.name == 'Task'
|
||||
|| github.event.issue.type.name == 'Epic'
|
||||
|| github.event.issue.type.name == 'Opportunity'
|
||||
steps:
|
||||
- name: Add no-stale label
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['no-stale']
|
||||
});
|
||||
|
||||
check-authorization:
|
||||
name: Check authorization
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # To check out workflow scripts
|
||||
issues: write # To comment on, label, and close issues
|
||||
# Only run if this is a Task issue type (from the issue form)
|
||||
if: github.event.issue.type.name == 'Task'
|
||||
steps:
|
||||
- name: Check out workflow scripts
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: .github/scripts
|
||||
- name: Check if user is authorized
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { default: checkTaskAuthorization } = await import(
|
||||
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-task-authorization.mjs`
|
||||
);
|
||||
await checkTaskAuthorization({ github, context, core });
|
||||
@@ -1,87 +0,0 @@
|
||||
name: Restrict task creation
|
||||
|
||||
# yamllint disable-line rule:truthy
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}
|
||||
|
||||
jobs:
|
||||
add-no-stale:
|
||||
name: Add no-stale label
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write # To add labels to issues
|
||||
if: >-
|
||||
github.event.issue.type.name == 'Task'
|
||||
|| github.event.issue.type.name == 'Epic'
|
||||
|| github.event.issue.type.name == 'Opportunity'
|
||||
steps:
|
||||
- name: Add no-stale label
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['no-stale']
|
||||
});
|
||||
|
||||
check-authorization:
|
||||
name: Check authorization
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
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 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']
|
||||
});
|
||||
@@ -21,16 +21,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Regenerate numeric device classes
|
||||
run: ./script/gen_numeric_device_classes
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- .github/workflows/translations.yaml
|
||||
- src/translations/en.json
|
||||
|
||||
permissions:
|
||||
@@ -17,11 +18,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Upload Translations
|
||||
run: |
|
||||
export LOKALISE_TOKEN="${{ secrets.LOKALISE_TOKEN }}"
|
||||
./script/translations_upload_base
|
||||
run: ./script/translations_upload_base
|
||||
env:
|
||||
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
|
||||
|
||||
@@ -6,6 +6,8 @@ build/
|
||||
dist/
|
||||
/hass_frontend/
|
||||
/translations/
|
||||
# Composite action source, not build output
|
||||
!/.github/actions/build/
|
||||
|
||||
# yarn
|
||||
.yarn/*
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 561513,
|
||||
"core": 54473,
|
||||
"authorize": 544272,
|
||||
"onboarding": 647136
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 790323,
|
||||
"core": 237208,
|
||||
"authorize": 765464,
|
||||
"onboarding": 918679
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/* global require, process, __dirname */
|
||||
// Enforce a strict size budget on the initial JS of the most critical
|
||||
// entrypoints (`app` and `core`). These two are downloaded on every cold load
|
||||
// before anything interactive can happen, so unintended growth here hurts
|
||||
// first-load performance directly.
|
||||
//
|
||||
// In production rspack does not split initial chunks (splitChunks only operates
|
||||
// on `!chunk.canBeInitial()`), so each entrypoint resolves to a single initial
|
||||
// JS asset. We read the per-build stats written by StatsWriterPlugin and compare
|
||||
// the entrypoint's initial JS size against a committed budget.
|
||||
//
|
||||
// Usage:
|
||||
// node build-scripts/check-bundle-size.cjs # enforce, exit 1 on regression
|
||||
// node build-scripts/check-bundle-size.cjs --update # rewrite budgets from current sizes
|
||||
// node build-scripts/check-bundle-size.cjs --update --headroom=3 # current + 3% headroom
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const paths = require("./paths.cjs");
|
||||
|
||||
// Entrypoints whose initial JS we hold to a strict budget. These are all
|
||||
// downloaded on a user-facing cold load before anything interactive can happen:
|
||||
// `app`/`core` for the main app, plus the standalone `authorize` and
|
||||
// `onboarding` pages. `custom-panel` is intentionally excluded (only loaded
|
||||
// when a custom panel is opened).
|
||||
const TRACKED_ENTRYPOINTS = ["app", "core", "authorize", "onboarding"];
|
||||
|
||||
// App build stats files, as written by StatsWriterPlugin (`${name}.json`).
|
||||
const BUILDS = ["frontend-modern", "frontend-legacy"];
|
||||
|
||||
const BUDGET_FILE = path.join(__dirname, "bundle-budget.json");
|
||||
const STATS_DIR = path.join(paths.build_dir, "stats");
|
||||
|
||||
const readStats = (build) => {
|
||||
const file = path.join(STATS_DIR, `${build}.json`);
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(
|
||||
`Missing stats file: ${path.relative(process.cwd(), file)}.\n` +
|
||||
`Run a production build first (e.g. \`gulp build-app\`), then re-run this check.`
|
||||
);
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
};
|
||||
|
||||
// Initial JS bytes for an entrypoint = sum of the .js asset sizes of its initial
|
||||
// entry chunk(s). Sizes are raw (uncompressed) bytes, matching the stats output.
|
||||
const entrypointInitialJS = (stats, entrypoint) => {
|
||||
const assetSize = new Map(stats.assets.map((a) => [a.name, a.size]));
|
||||
let total = 0;
|
||||
let found = false;
|
||||
for (const chunk of stats.chunks) {
|
||||
if (!chunk.entry || !chunk.initial) {
|
||||
continue;
|
||||
}
|
||||
if (!(chunk.names || []).includes(entrypoint)) {
|
||||
continue;
|
||||
}
|
||||
found = true;
|
||||
for (const file of chunk.files || []) {
|
||||
if (file.endsWith(".js") && assetSize.has(file)) {
|
||||
total += assetSize.get(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
throw new Error(`Entrypoint "${entrypoint}" not found in bundle stats.`);
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
const kib = (bytes) => `${(bytes / 1024).toFixed(1)} KiB`;
|
||||
|
||||
const main = () => {
|
||||
const update = process.argv.includes("--update");
|
||||
const headroomArg = process.argv.find((a) => a.startsWith("--headroom="));
|
||||
const headroom = headroomArg ? Number(headroomArg.split("=")[1]) : 0;
|
||||
|
||||
const current = {};
|
||||
for (const build of BUILDS) {
|
||||
const stats = readStats(build);
|
||||
current[build] = {};
|
||||
for (const entrypoint of TRACKED_ENTRYPOINTS) {
|
||||
current[build][entrypoint] = entrypointInitialJS(stats, entrypoint);
|
||||
}
|
||||
}
|
||||
|
||||
if (update) {
|
||||
const budget = { _comment: BUDGET_COMMENT };
|
||||
for (const build of BUILDS) {
|
||||
budget[build] = {};
|
||||
for (const entrypoint of TRACKED_ENTRYPOINTS) {
|
||||
budget[build][entrypoint] = Math.ceil(
|
||||
current[build][entrypoint] * (1 + headroom / 100)
|
||||
);
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(BUDGET_FILE, `${JSON.stringify(budget, null, 2)}\n`);
|
||||
console.log(
|
||||
`Updated ${path.relative(process.cwd(), BUDGET_FILE)} from current sizes` +
|
||||
(headroom ? ` (+${headroom}% headroom).` : ".")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(BUDGET_FILE)) {
|
||||
throw new Error(
|
||||
`Missing budget file ${path.relative(process.cwd(), BUDGET_FILE)}.\n` +
|
||||
`Seed it from a production build with: node build-scripts/check-bundle-size.cjs --update --headroom=3`
|
||||
);
|
||||
}
|
||||
const budget = JSON.parse(fs.readFileSync(BUDGET_FILE, "utf8"));
|
||||
|
||||
let failed = false;
|
||||
console.log("Initial JS budget (entry chunks, raw bytes):\n");
|
||||
for (const build of BUILDS) {
|
||||
for (const entrypoint of TRACKED_ENTRYPOINTS) {
|
||||
const actual = current[build][entrypoint];
|
||||
const limit = budget[build] && budget[build][entrypoint];
|
||||
if (typeof limit !== "number") {
|
||||
failed = true;
|
||||
console.log(
|
||||
` ✗ ${build} / ${entrypoint}: no budget set (current ${kib(actual)})`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const ok = actual <= limit;
|
||||
const delta = (((actual - limit) / limit) * 100).toFixed(1);
|
||||
console.log(
|
||||
` ${ok ? "✓" : "✗"} ${build} / ${entrypoint}: ` +
|
||||
`${kib(actual)} / ${kib(limit)}${ok ? "" : ` (+${delta}% over budget)`}`
|
||||
);
|
||||
if (!ok) {
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
console.error(
|
||||
"\nInitial JS budget exceeded for a critical entrypoint.\n" +
|
||||
"Investigate what was pulled into the entry chunk (a static import that should be lazy?).\n" +
|
||||
"If the growth is intentional, re-seed the budget:\n" +
|
||||
" node build-scripts/check-bundle-size.cjs --update --headroom=3"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nAll tracked entrypoints within budget.");
|
||||
};
|
||||
|
||||
const BUDGET_COMMENT =
|
||||
"Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. " +
|
||||
"Enforced by build-scripts/check-bundle-size.cjs in CI. " +
|
||||
"Re-seed after an intentional change with `--update --headroom=<percent>`.";
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,678 @@
|
||||
// Manage a Home Assistant frontend dev server with an agent-friendly interface.
|
||||
//
|
||||
// node build-scripts/dev-server.mjs --suite <suite> [mode] [extra args]
|
||||
//
|
||||
// (no mode) Run in the foreground.
|
||||
// --background Start detached, wait until it is ready, print the URL
|
||||
// (when it has one) and pid, then exit and leave it running.
|
||||
// --status Report whether the suite's dev server is running.
|
||||
// --stop Stop a running background dev server.
|
||||
// --logs [--follow] Print (or follow) the background dev server log.
|
||||
//
|
||||
// Extra args (for example -p or -c on app-serve) are forwarded to the underlying
|
||||
// script. Suites use one of two liveness models:
|
||||
//
|
||||
// health demo, gallery, e2e-app: a fixed port plus the /__ha_dev_status
|
||||
// endpoint each dev server exposes (see runDevServer in
|
||||
// build-scripts/gulp/rspack.js). The port is the source of truth and
|
||||
// the pid is found from it; no state file.
|
||||
// process app (yarn dev) and app-serve (yarn dev:serve): the app watcher has
|
||||
// no health endpoint, and plain yarn dev has no port at all, so these
|
||||
// track a pidfile and treat the first "Build done" log line as ready.
|
||||
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
".."
|
||||
);
|
||||
const gulpBin = path.join(repoRoot, "node_modules", ".bin", "gulp");
|
||||
const developAndServeScript = path.join(
|
||||
repoRoot,
|
||||
"script",
|
||||
"develop_and_serve"
|
||||
);
|
||||
const logDir = path.join(repoRoot, "node_modules", ".cache", "ha-dev-server");
|
||||
|
||||
// Each suite names its yarn alias (for hints), a liveness model, and how to
|
||||
// spawn it. health suites carry a fixed port; process suites carry the log line
|
||||
// that means "ready" and, for app-serve, forward extra args to the script.
|
||||
const SUITES = {
|
||||
"e2e-app": {
|
||||
alias: "test:e2e:app:dev",
|
||||
liveness: "health",
|
||||
port: 8095,
|
||||
spawn: { cmd: gulpBin, args: ["develop-e2e-test-app"] },
|
||||
},
|
||||
demo: {
|
||||
alias: "dev:demo",
|
||||
liveness: "health",
|
||||
port: 8090,
|
||||
spawn: { cmd: gulpBin, args: ["develop-demo"] },
|
||||
},
|
||||
gallery: {
|
||||
alias: "dev:gallery",
|
||||
liveness: "health",
|
||||
port: 8100,
|
||||
spawn: { cmd: gulpBin, args: ["develop-gallery"] },
|
||||
},
|
||||
app: {
|
||||
alias: "dev",
|
||||
liveness: "process",
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: gulpBin, args: ["develop-app"] },
|
||||
},
|
||||
"app-serve": {
|
||||
alias: "dev:serve",
|
||||
liveness: "process",
|
||||
acceptsArgs: true,
|
||||
readyLog: /Build done @/,
|
||||
spawn: { cmd: developAndServeScript, args: [] },
|
||||
},
|
||||
};
|
||||
|
||||
// Cover a cold build on a slow machine before the server starts listening.
|
||||
// Override with HA_DEV_SERVER_TIMEOUT (seconds).
|
||||
const READY_TIMEOUT_MS =
|
||||
Number(process.env.HA_DEV_SERVER_TIMEOUT || "180") * 1000;
|
||||
|
||||
// Detect a coding agent from a small set of environment markers set by common
|
||||
// agent CLIs (env-only; no process-ancestry detection).
|
||||
const detectAgent = () => {
|
||||
const env = process.env;
|
||||
const has = (name) => Boolean(env[name]);
|
||||
const eq = (name, value) => env[name] === value;
|
||||
const signals = {
|
||||
opencode: () =>
|
||||
[
|
||||
"OPENCODE",
|
||||
"OPENCODE_BIN_PATH",
|
||||
"OPENCODE_SERVER",
|
||||
"OPENCODE_APP_INFO",
|
||||
].some(has),
|
||||
"claude-code": () => has("CLAUDECODE"),
|
||||
cursor: () => has("CURSOR_TRACE_ID"),
|
||||
"github-copilot": () =>
|
||||
eq("TERM_PROGRAM", "vscode") && eq("GIT_PAGER", "cat"),
|
||||
// Convention shared by several agents (Crush, Amp, ...).
|
||||
generic: () => has("AGENT") || has("AI_AGENT"),
|
||||
};
|
||||
return Object.keys(signals).find((id) => signals[id]());
|
||||
};
|
||||
|
||||
const usage = () => {
|
||||
const suites = Object.keys(SUITES).join("|");
|
||||
process.stderr.write(
|
||||
`Usage: node build-scripts/dev-server.mjs --suite <${suites}> ` +
|
||||
`[--background | --status | --stop | --logs [--follow]]\n`
|
||||
);
|
||||
};
|
||||
|
||||
const parseArgs = (argv) => {
|
||||
const args = {
|
||||
mode: "foreground",
|
||||
follow: false,
|
||||
suite: undefined,
|
||||
passthrough: [],
|
||||
};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
switch (arg) {
|
||||
case "--suite":
|
||||
args.suite = argv[++i];
|
||||
break;
|
||||
case "--background":
|
||||
args.mode = "background";
|
||||
break;
|
||||
case "--status":
|
||||
args.mode = "status";
|
||||
break;
|
||||
case "--stop":
|
||||
args.mode = "stop";
|
||||
break;
|
||||
case "--logs":
|
||||
args.mode = "logs";
|
||||
break;
|
||||
case "--follow":
|
||||
args.follow = true;
|
||||
break;
|
||||
default:
|
||||
// Anything unrecognised is forwarded to the underlying script.
|
||||
args.passthrough.push(arg);
|
||||
}
|
||||
}
|
||||
return args;
|
||||
};
|
||||
|
||||
const sleep = (ms) =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
const logFileFor = (suite) => path.join(logDir, `${suite}.log`);
|
||||
const pidFileFor = (suite) => path.join(logDir, `${suite}.pid`);
|
||||
|
||||
const hints = (suite) => {
|
||||
const alias = `yarn ${SUITES[suite].alias}`;
|
||||
return (
|
||||
` Stop: ${alias} --stop\n` +
|
||||
` Status: ${alias} --status\n` +
|
||||
` Logs: ${alias} --logs\n`
|
||||
);
|
||||
};
|
||||
|
||||
// --- shared spawning and lifecycle ------------------------------------------
|
||||
|
||||
// Signal the whole process group (the background server is its group leader),
|
||||
// falling back to the bare pid if that is not permitted.
|
||||
const killProcessTree = (pid, sig) => {
|
||||
try {
|
||||
process.kill(-pid, sig);
|
||||
} catch {
|
||||
try {
|
||||
process.kill(pid, sig);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const urlSuffix = (port) => (port ? ` at http://localhost:${port}` : "");
|
||||
|
||||
// Run a server in the foreground, inheriting stdio; resolve with its exit code.
|
||||
const spawnInherit = (cmd, args) =>
|
||||
new Promise((resolve) => {
|
||||
const child = spawn(cmd, args, { cwd: repoRoot, stdio: "inherit" });
|
||||
child.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
|
||||
// Spawn a detached server that writes stdout and stderr to the suite's log file.
|
||||
const spawnDetachedToLog = (suite, cmd, args) => {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
const logFile = logFileFor(suite);
|
||||
const fd = fs.openSync(logFile, "w");
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: repoRoot,
|
||||
detached: true,
|
||||
stdio: ["ignore", fd, fd],
|
||||
});
|
||||
fs.closeSync(fd);
|
||||
child.unref();
|
||||
return { child, logFile };
|
||||
};
|
||||
|
||||
// Poll until the server is ready, the child exits, or we time out. Prints the
|
||||
// progress dots and outcome; returns 0 when ready, 1 otherwise. onExit runs if
|
||||
// the child dies before it is ready (used to clear a stale pidfile).
|
||||
const awaitReady = async ({ suite, child, logFile, port, isReady, onExit }) => {
|
||||
let childExited = false;
|
||||
child.on("exit", () => {
|
||||
childExited = true;
|
||||
});
|
||||
const deadline = Date.now() + READY_TIMEOUT_MS;
|
||||
process.stdout.write(`Starting ${suite} dev server`);
|
||||
/* eslint-disable no-await-in-loop -- poll until the server is ready */
|
||||
while (Date.now() < deadline) {
|
||||
if (childExited) {
|
||||
process.stdout.write("\n");
|
||||
process.stderr.write(
|
||||
`Dev server (${suite}) exited before it was ready. See ${logFile}\n`
|
||||
);
|
||||
onExit?.();
|
||||
return 1;
|
||||
}
|
||||
if (await isReady()) {
|
||||
process.stdout.write("\n");
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) running${urlSuffix(port)} ` +
|
||||
`(pid ${child.pid})\n${hints(suite)}`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
process.stdout.write(".");
|
||||
await sleep(1000);
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
process.stdout.write("\n");
|
||||
process.stderr.write(
|
||||
`Dev server (${suite}) did not become ready within ${
|
||||
READY_TIMEOUT_MS / 1000
|
||||
}s. See ${logFile}\n`
|
||||
);
|
||||
return 1;
|
||||
};
|
||||
|
||||
// Stop a running background server: SIGTERM, wait for it to go, then SIGKILL.
|
||||
// isStopped reports when it is gone; onStopped runs on success (pidfile cleanup).
|
||||
const terminate = async (suite, pid, isStopped, onStopped) => {
|
||||
killProcessTree(pid, "SIGTERM");
|
||||
const deadline = Date.now() + 10_000;
|
||||
/* eslint-disable no-await-in-loop -- poll until the server is gone */
|
||||
while (Date.now() < deadline) {
|
||||
await sleep(300);
|
||||
if (await isStopped()) {
|
||||
onStopped?.();
|
||||
process.stdout.write(`Stopped dev server (${suite}) (pid ${pid}).\n`);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
// Escalate if it is still up.
|
||||
killProcessTree(pid, "SIGKILL");
|
||||
await sleep(300);
|
||||
if (!(await isStopped())) {
|
||||
process.stderr.write(
|
||||
`Failed to stop dev server (${suite}) (pid ${pid}). Stop it manually.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
onStopped?.();
|
||||
process.stdout.write(`Stopped dev server (${suite}) (pid ${pid}).\n`);
|
||||
return 0;
|
||||
};
|
||||
|
||||
// --- health liveness (port + /__ha_dev_status) ------------------------------
|
||||
|
||||
/**
|
||||
* Probe the health endpoint. Dev servers bind IPv4 or IPv6 localhost depending
|
||||
* on the OS, so try each; the port is "free" only if every address refuses.
|
||||
* @returns {Promise<{state: "ours" | "foreign" | "free", suite?: string}>}
|
||||
*/
|
||||
const PROBE_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
|
||||
|
||||
const probe = async (port, timeoutMs = 1000) => {
|
||||
let sawResponse = false;
|
||||
/* eslint-disable no-await-in-loop -- probe localhost addresses in order, stopping at the first that answers */
|
||||
for (const host of PROBE_HOSTS) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const res = await fetch(`http://${host}:${port}/__ha_dev_status`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
sawResponse = true;
|
||||
if (res.ok) {
|
||||
const body = await res.json().catch(() => null);
|
||||
if (body && body.server === "ha-frontend-dev") {
|
||||
return { state: "ours", suite: body.suite };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Try the next address.
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
return sawResponse ? { state: "foreign" } : { state: "free" };
|
||||
};
|
||||
|
||||
// Find the pid listening on a port via the first available tool (no state file).
|
||||
const pidFromPort = (port) => {
|
||||
const attempts = [
|
||||
[
|
||||
"lsof",
|
||||
["-ti", `tcp:${port}`, "-sTCP:LISTEN"],
|
||||
(out) => out.trim().split("\n")[0],
|
||||
],
|
||||
[
|
||||
"ss",
|
||||
["-ltnpH", `sport = :${port}`],
|
||||
(out) => out.match(/pid=(\d+)/)?.[1],
|
||||
],
|
||||
["fuser", [`${port}/tcp`], (out) => out.trim().split(/\s+/)[0]],
|
||||
];
|
||||
for (const [cmd, cmdArgs, extract] of attempts) {
|
||||
try {
|
||||
const out = execFileSync(cmd, cmdArgs, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
const pid = Number(extract(out));
|
||||
if (Number.isInteger(pid) && pid > 0) {
|
||||
return pid;
|
||||
}
|
||||
} catch {
|
||||
// Try the next tool.
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const runForegroundHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const status = await probe(port);
|
||||
if (status.state === "ours" && status.suite === suite) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) is already running at http://localhost:${port}\n`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (status.state === "foreign") {
|
||||
process.stderr.write(
|
||||
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
return spawnInherit(cfg.spawn.cmd, cfg.spawn.args);
|
||||
};
|
||||
|
||||
const runBackgroundHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const preflight = await probe(port);
|
||||
if (preflight.state === "ours" && preflight.suite === suite) {
|
||||
const pid = pidFromPort(port);
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) already running at http://localhost:${port}` +
|
||||
`${pid ? ` (pid ${pid})` : ""}\n${hints(suite)}`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (preflight.state === "foreign") {
|
||||
process.stderr.write(
|
||||
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const { child, logFile } = spawnDetachedToLog(
|
||||
suite,
|
||||
cfg.spawn.cmd,
|
||||
cfg.spawn.args
|
||||
);
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: async () => {
|
||||
const status = await probe(port, 1000);
|
||||
return status.state === "ours" && status.suite === suite;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const runStatusHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const status = await probe(port);
|
||||
if (status.state === "ours" && status.suite === suite) {
|
||||
const pid = pidFromPort(port);
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) running at http://localhost:${port}` +
|
||||
`${pid ? ` (pid ${pid})` : ""}\n`
|
||||
);
|
||||
} else if (status.state === "ours") {
|
||||
process.stdout.write(
|
||||
`Port ${port} is serving a different Home Assistant frontend dev server (suite ${status.suite ?? "unknown"}); not ${suite}.\n`
|
||||
);
|
||||
} else if (status.state === "foreign") {
|
||||
process.stdout.write(
|
||||
`Port ${port} is in use by another process; not the ${suite} dev server.\n`
|
||||
);
|
||||
} else {
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runStopHealth = async (suite, cfg) => {
|
||||
const { port } = cfg;
|
||||
const status = await probe(port);
|
||||
if (!(status.state === "ours" && status.suite === suite)) {
|
||||
// Idempotent: stopping something that is not running is a success.
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
return 0;
|
||||
}
|
||||
const pid = pidFromPort(port);
|
||||
if (!pid) {
|
||||
process.stderr.write(
|
||||
`Dev server (${suite}) is running but its pid could not be found ` +
|
||||
`(no lsof/ss/fuser?). Stop it manually.\n`
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
return terminate(
|
||||
suite,
|
||||
pid,
|
||||
async () => (await probe(port, 800)).state === "free"
|
||||
);
|
||||
};
|
||||
|
||||
// --- process liveness (pidfile + log-readiness) -----------------------------
|
||||
|
||||
const isAlive = (pid) => {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// EPERM means the process exists but is owned by someone else.
|
||||
return err.code === "EPERM";
|
||||
}
|
||||
};
|
||||
|
||||
const readPidFile = (suite) => {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(pidFileFor(suite), "utf8"));
|
||||
if (data && Number.isInteger(data.pid)) {
|
||||
return data;
|
||||
}
|
||||
} catch {
|
||||
// Missing or corrupt.
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const writePidFile = (suite, data) => {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.writeFileSync(pidFileFor(suite), JSON.stringify(data));
|
||||
};
|
||||
|
||||
const removePidFile = (suite) => {
|
||||
try {
|
||||
fs.rmSync(pidFileFor(suite));
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
};
|
||||
|
||||
const logIsReady = (logFile, readyLog) => {
|
||||
try {
|
||||
return readyLog.test(fs.readFileSync(logFile, "utf8"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// app-serve serves on 8124 by default (8123 in a devcontainer), or whatever -p
|
||||
// the caller passed. Used only to show a URL; liveness comes from the pidfile.
|
||||
const resolveServePort = (passthrough) => {
|
||||
const i = passthrough.indexOf("-p");
|
||||
if (i !== -1) {
|
||||
const port = Number(passthrough[i + 1]);
|
||||
if (Number.isInteger(port) && port > 0) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
return process.env.DEVCONTAINER ? 8123 : 8124;
|
||||
};
|
||||
|
||||
const spawnArgs = (cfg, passthrough) => [
|
||||
...cfg.spawn.args,
|
||||
...(cfg.acceptsArgs ? passthrough : []),
|
||||
];
|
||||
|
||||
const runForegroundProcess = async (suite, cfg, passthrough) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) already running in the background ` +
|
||||
`(pid ${existing.pid}). Stop it with yarn ${cfg.alias} --stop.\n`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
}
|
||||
return spawnInherit(cfg.spawn.cmd, spawnArgs(cfg, passthrough));
|
||||
};
|
||||
|
||||
const runBackgroundProcess = async (suite, cfg, passthrough) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) already running${urlSuffix(existing.port)} ` +
|
||||
`(pid ${existing.pid})\n${hints(suite)}`
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
}
|
||||
|
||||
const { child, logFile } = spawnDetachedToLog(
|
||||
suite,
|
||||
cfg.spawn.cmd,
|
||||
spawnArgs(cfg, passthrough)
|
||||
);
|
||||
|
||||
const port = cfg.acceptsArgs ? resolveServePort(passthrough) : cfg.port;
|
||||
writePidFile(suite, { pid: child.pid, port });
|
||||
|
||||
return awaitReady({
|
||||
suite,
|
||||
child,
|
||||
logFile,
|
||||
port,
|
||||
isReady: () => logIsReady(logFile, cfg.readyLog),
|
||||
onExit: () => removePidFile(suite),
|
||||
});
|
||||
};
|
||||
|
||||
const runStatusProcess = async (suite) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (existing && isAlive(existing.pid)) {
|
||||
process.stdout.write(
|
||||
`Dev server (${suite}) running${urlSuffix(existing.port)} ` +
|
||||
`(pid ${existing.pid})\n`
|
||||
);
|
||||
} else {
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
}
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runStopProcess = async (suite) => {
|
||||
const existing = readPidFile(suite);
|
||||
if (!existing || !isAlive(existing.pid)) {
|
||||
// Idempotent: stopping something that is not running is a success.
|
||||
if (existing) {
|
||||
removePidFile(suite);
|
||||
}
|
||||
process.stdout.write(`Dev server (${suite}) not running.\n`);
|
||||
return 0;
|
||||
}
|
||||
const { pid } = existing;
|
||||
return terminate(
|
||||
suite,
|
||||
pid,
|
||||
() => !isAlive(pid),
|
||||
() => removePidFile(suite)
|
||||
);
|
||||
};
|
||||
|
||||
// --- shared -----------------------------------------------------------------
|
||||
|
||||
const runLogs = (suite, follow) => {
|
||||
const logFile = logFileFor(suite);
|
||||
if (!fs.existsSync(logFile)) {
|
||||
process.stdout.write(
|
||||
`No log for the ${suite} dev server yet (${logFile}).\n`
|
||||
);
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
if (!follow) {
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const tail = spawn("tail", ["-f", logFile], { stdio: "inherit" });
|
||||
tail.on("error", () => {
|
||||
// No tail available; fall back to a one-shot dump.
|
||||
process.stdout.write(fs.readFileSync(logFile, "utf8"));
|
||||
resolve(0);
|
||||
});
|
||||
tail.on("exit", (code) => resolve(code ?? 0));
|
||||
});
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cfg = SUITES[args.suite];
|
||||
if (!cfg) {
|
||||
usage();
|
||||
return 1;
|
||||
}
|
||||
if (args.passthrough.length && !cfg.acceptsArgs) {
|
||||
process.stderr.write(
|
||||
`Ignoring unexpected arguments: ${args.passthrough.join(" ")}\n`
|
||||
);
|
||||
}
|
||||
|
||||
// A plain dev:<suite> under a coding agent backgrounds itself; explicit modes
|
||||
// are untouched.
|
||||
let { mode } = args;
|
||||
if (
|
||||
mode === "foreground" &&
|
||||
!["0", "false"].includes(process.env.HA_DEV_BACKGROUND)
|
||||
) {
|
||||
const agent = detectAgent();
|
||||
if (agent) {
|
||||
process.stdout.write(
|
||||
`Detected coding agent (${agent}); starting in the background. ` +
|
||||
`Set HA_DEV_BACKGROUND=0 to force foreground.\n`
|
||||
);
|
||||
mode = "background";
|
||||
}
|
||||
}
|
||||
|
||||
const health = cfg.liveness === "health";
|
||||
switch (mode) {
|
||||
case "background":
|
||||
return health
|
||||
? runBackgroundHealth(args.suite, cfg)
|
||||
: runBackgroundProcess(args.suite, cfg, args.passthrough);
|
||||
case "status":
|
||||
return health
|
||||
? runStatusHealth(args.suite, cfg)
|
||||
: runStatusProcess(args.suite);
|
||||
case "stop":
|
||||
return health
|
||||
? runStopHealth(args.suite, cfg)
|
||||
: runStopProcess(args.suite);
|
||||
case "logs":
|
||||
return runLogs(args.suite, args.follow);
|
||||
default:
|
||||
return health
|
||||
? runForegroundHealth(args.suite, cfg)
|
||||
: runForegroundProcess(args.suite, cfg, args.passthrough);
|
||||
}
|
||||
};
|
||||
|
||||
main().then(
|
||||
(code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
(err) => {
|
||||
process.stderr.write(`${err?.stack || err}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "fs";
|
||||
import { glob } from "glob";
|
||||
import gulp from "gulp";
|
||||
import yaml from "js-yaml";
|
||||
import { load as loadYaml } from "js-yaml";
|
||||
import { marked } from "marked";
|
||||
import path from "path";
|
||||
import paths from "../paths.cjs";
|
||||
@@ -47,7 +47,7 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
|
||||
|
||||
if (descriptionContent.startsWith("---")) {
|
||||
const metadataEnd = descriptionContent.indexOf("---", 3);
|
||||
metadata = yaml.load(descriptionContent.substring(3, metadataEnd));
|
||||
metadata = loadYaml(descriptionContent.substring(3, metadataEnd));
|
||||
descriptionContent = descriptionContent
|
||||
.substring(metadataEnd + 3)
|
||||
.trim();
|
||||
|
||||
@@ -37,6 +37,7 @@ const isWsl =
|
||||
* listenHost?: string,
|
||||
* open?: boolean,
|
||||
* logUrlAfterFirstBuild?: boolean,
|
||||
* suite?: string,
|
||||
* }}
|
||||
*/
|
||||
const runDevServer = async ({
|
||||
@@ -47,6 +48,7 @@ const runDevServer = async ({
|
||||
open = true,
|
||||
logUrlAfterFirstBuild = false,
|
||||
proxy = undefined,
|
||||
suite = undefined,
|
||||
}) => {
|
||||
if (listenHost === undefined) {
|
||||
// For dev container, we need to listen on all hosts
|
||||
@@ -81,6 +83,19 @@ const runDevServer = async ({
|
||||
!error?.message?.includes("ResizeObserver loop"),
|
||||
},
|
||||
},
|
||||
setupMiddlewares: (middlewares) => {
|
||||
// Status endpoint so the dev-server manager can confirm this is our
|
||||
// server for the expected suite. Unshifted to beat the static handler.
|
||||
middlewares.unshift({
|
||||
name: "ha-dev-status",
|
||||
path: "/__ha_dev_status",
|
||||
middleware: (_req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ server: "ha-frontend-dev", suite, port }));
|
||||
},
|
||||
});
|
||||
return middlewares;
|
||||
},
|
||||
proxy,
|
||||
},
|
||||
compiler
|
||||
@@ -152,6 +167,8 @@ gulp.task("rspack-dev-server-demo", () =>
|
||||
),
|
||||
contentBase: paths.demo_output_root,
|
||||
port: 8090,
|
||||
open: false,
|
||||
suite: "demo",
|
||||
})
|
||||
);
|
||||
|
||||
@@ -173,6 +190,7 @@ gulp.task("rspack-dev-server-cast", () =>
|
||||
port: 8080,
|
||||
// Accessible from the network, because that's how Cast hits it.
|
||||
listenHost: "0.0.0.0",
|
||||
suite: "cast",
|
||||
})
|
||||
);
|
||||
|
||||
@@ -194,6 +212,7 @@ gulp.task("rspack-dev-server-gallery", () =>
|
||||
listenHost: "0.0.0.0",
|
||||
open: false,
|
||||
logUrlAfterFirstBuild: true,
|
||||
suite: "gallery",
|
||||
})
|
||||
);
|
||||
|
||||
@@ -240,6 +259,8 @@ gulp.task("rspack-dev-server-e2e-test-app", () =>
|
||||
),
|
||||
contentBase: paths.e2eTestApp_output_root,
|
||||
port: 8095,
|
||||
open: false,
|
||||
suite: "e2e-app",
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -24,11 +24,16 @@ const getMinifier = () => {
|
||||
// (html-minifier-next is option-compatible with html-minifier-terser). CSS in
|
||||
// css`` templates and inline <style> is handled by minify-literals' lightningcss
|
||||
// default.
|
||||
//
|
||||
// `keepClosingSlash` is required for `svg`` templates: SVG elements such as
|
||||
// `<path />` and `<circle />` are not void elements in HTML, so dropping the
|
||||
// trailing slash would break the markup. It is harmless for HTML.
|
||||
const htmlOptions = {
|
||||
caseSensitive: true,
|
||||
collapseWhitespace: true,
|
||||
conservativeCollapse: true,
|
||||
decodeEntities: true,
|
||||
keepClosingSlash: true,
|
||||
removeComments: true,
|
||||
removeRedundantAttributes: true,
|
||||
};
|
||||
|
||||
@@ -56,88 +56,100 @@ class HcCast extends LitElement {
|
||||
|
||||
return html`
|
||||
<hc-layout .auth=${this.auth} .connection=${this.connection}>
|
||||
${this.askWrite
|
||||
? html`
|
||||
<p class="question action-item">
|
||||
Stay logged in?
|
||||
<span>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
@click=${this._handleSaveTokens}
|
||||
>
|
||||
YES
|
||||
</ha-button>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
@click=${this._handleSkipSaveTokens}
|
||||
>
|
||||
NO
|
||||
</ha-button>
|
||||
</span>
|
||||
</p>
|
||||
`
|
||||
: ""}
|
||||
${error
|
||||
? html` <div class="card-content">${error}</div> `
|
||||
: !this.castManager.status
|
||||
${
|
||||
this.askWrite
|
||||
? html`
|
||||
<p class="center-item">
|
||||
<ha-button @click=${this._handleLaunch}>
|
||||
<ha-svg-icon slot="start" .path=${mdiCast}></ha-svg-icon>
|
||||
Start Casting
|
||||
</ha-button>
|
||||
<p class="question action-item">
|
||||
Stay logged in?
|
||||
<span>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
@click=${this._handleSaveTokens}
|
||||
>
|
||||
YES
|
||||
</ha-button>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
@click=${this._handleSkipSaveTokens}
|
||||
>
|
||||
NO
|
||||
</ha-button>
|
||||
</span>
|
||||
</p>
|
||||
`
|
||||
: html`
|
||||
<div class="section-header">PICK A VIEW</div>
|
||||
<ha-list @action=${this._handlePickView} activatable>
|
||||
${(
|
||||
this.lovelaceViews ?? [
|
||||
{
|
||||
title: "Home",
|
||||
},
|
||||
]
|
||||
).map(
|
||||
(view, idx) => html`
|
||||
<ha-list-item
|
||||
graphic="avatar"
|
||||
.activated=${this.castManager.status?.lovelacePath ===
|
||||
(view.path ?? idx)}
|
||||
.selected=${this.castManager.status?.lovelacePath ===
|
||||
(view.path ?? idx)}
|
||||
>
|
||||
${view.title || view.path || "Unnamed view"}
|
||||
${view.icon
|
||||
? html`
|
||||
<ha-icon
|
||||
.icon=${view.icon}
|
||||
slot="graphic"
|
||||
></ha-icon>
|
||||
`
|
||||
: html`<ha-svg-icon
|
||||
slot="item-icon"
|
||||
.path=${mdiViewDashboard}
|
||||
></ha-svg-icon>`}
|
||||
</ha-list-item>
|
||||
`
|
||||
)}</ha-list
|
||||
>
|
||||
`}
|
||||
: ""
|
||||
}
|
||||
${
|
||||
error
|
||||
? html` <div class="card-content">${error}</div> `
|
||||
: !this.castManager.status
|
||||
? html`
|
||||
<p class="center-item">
|
||||
<ha-button @click=${this._handleLaunch}>
|
||||
<ha-svg-icon slot="start" .path=${mdiCast}></ha-svg-icon>
|
||||
Start Casting
|
||||
</ha-button>
|
||||
</p>
|
||||
`
|
||||
: html`
|
||||
<div class="section-header">PICK A VIEW</div>
|
||||
<ha-list @action=${this._handlePickView} activatable>
|
||||
${(
|
||||
this.lovelaceViews ?? [
|
||||
{
|
||||
title: "Home",
|
||||
},
|
||||
]
|
||||
).map(
|
||||
(view, idx) => html`
|
||||
<ha-list-item
|
||||
graphic="avatar"
|
||||
.activated=${
|
||||
this.castManager.status?.lovelacePath ===
|
||||
(view.path ?? idx)
|
||||
}
|
||||
.selected=${
|
||||
this.castManager.status?.lovelacePath ===
|
||||
(view.path ?? idx)
|
||||
}
|
||||
>
|
||||
${view.title || view.path || "Unnamed view"}
|
||||
${
|
||||
view.icon
|
||||
? html`
|
||||
<ha-icon
|
||||
.icon=${view.icon}
|
||||
slot="graphic"
|
||||
></ha-icon>
|
||||
`
|
||||
: html`<ha-svg-icon
|
||||
slot="item-icon"
|
||||
.path=${mdiViewDashboard}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
</ha-list-item>
|
||||
`
|
||||
)}</ha-list
|
||||
>
|
||||
`
|
||||
}
|
||||
|
||||
<div class="card-actions">
|
||||
${this.castManager.status
|
||||
? html`
|
||||
<ha-button appearance="plain" @click=${this._handleLaunch}>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiCastConnected}
|
||||
></ha-svg-icon>
|
||||
Manage
|
||||
</ha-button>
|
||||
`
|
||||
: ""}
|
||||
${
|
||||
this.castManager.status
|
||||
? html`
|
||||
<ha-button appearance="plain" @click=${this._handleLaunch}>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiCastConnected}
|
||||
></ha-svg-icon>
|
||||
Manage
|
||||
</ha-button>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<div class="spacer"></div>
|
||||
<ha-button
|
||||
variant="danger"
|
||||
|
||||
@@ -135,9 +135,11 @@ export class HcConnect extends LitElement {
|
||||
Show Demo
|
||||
<ha-svg-icon
|
||||
slot="end"
|
||||
.path=${this.castManager.castState === "CONNECTED"
|
||||
? mdiCastConnected
|
||||
: mdiCast}
|
||||
.path=${
|
||||
this.castManager.castState === "CONNECTED"
|
||||
? mdiCastConnected
|
||||
: mdiCast
|
||||
}
|
||||
></ha-svg-icon>
|
||||
</ha-button>
|
||||
<div class="spacer"></div>
|
||||
|
||||
@@ -26,18 +26,20 @@ class HcLayout extends LitElement {
|
||||
/>
|
||||
<h1 class="card-header">
|
||||
Home Assistant Cast${this.subtitle ? ` – ${this.subtitle}` : ""}
|
||||
${this.auth
|
||||
? html`
|
||||
<div class="subtitle">
|
||||
<a href=${this.auth.data.hassUrl} target="_blank"
|
||||
>${this.auth.data.hassUrl.substr(
|
||||
this.auth.data.hassUrl.indexOf("//") + 2
|
||||
)}</a
|
||||
>
|
||||
${this.user ? html` – ${this.user.name} ` : ""}
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
${
|
||||
this.auth
|
||||
? html`
|
||||
<div class="subtitle">
|
||||
<a href=${this.auth.data.hassUrl} target="_blank"
|
||||
>${this.auth.data.hassUrl.substr(
|
||||
this.auth.data.hassUrl.indexOf("//") + 2
|
||||
)}</a
|
||||
>
|
||||
${this.user ? html` – ${this.user.name} ` : ""}
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</h1>
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Develop the demo
|
||||
# Develop the demo. Pass --background/--status/--stop/--logs to manage a
|
||||
# detached instance (see build-scripts/dev-server.mjs).
|
||||
|
||||
# Stop on errors
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
./node_modules/.bin/gulp develop-demo
|
||||
exec node build-scripts/dev-server.mjs --suite demo "$@"
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import { mdiClose, mdiFlaskOutline } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import { mainWindow } from "../../../src/common/dom/get_main_window";
|
||||
import { navigate } from "../../../src/common/navigate";
|
||||
import "../../../src/components/ha-button";
|
||||
import "../../../src/components/ha-card";
|
||||
import "../../../src/components/ha-icon-button";
|
||||
import "../../../src/components/ha-svg-icon";
|
||||
import "../../../src/components/ha-switch";
|
||||
import type { HaSwitch } from "../../../src/components/ha-switch";
|
||||
import type { CloudDemoScenario } from "../stubs/cloud-demo-state";
|
||||
import {
|
||||
getCloudDemoScenario,
|
||||
setCloudDemoScenario,
|
||||
subscribeCloudDemoScenario,
|
||||
} from "../stubs/cloud-demo-state";
|
||||
|
||||
// Walk the DOM, descending into shadow roots, to find the first matching
|
||||
// element. Used to reach <ha-panel-config> (which owns the cloud status) so we
|
||||
// can ask it to re-fetch after a scenario change.
|
||||
const deepQuery = (
|
||||
selector: string,
|
||||
root: Document | ShadowRoot = document
|
||||
): Element | null => {
|
||||
const direct = root.querySelector(selector);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const elements = root.querySelectorAll("*");
|
||||
for (const element of elements) {
|
||||
const shadow = element.shadowRoot;
|
||||
if (shadow) {
|
||||
const found = deepQuery(selector, shadow);
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Demo-only floating panel that flips the mocked Home Assistant Cloud state so
|
||||
* reviewers can preview every UI state of the cloud account page. It writes to
|
||||
* the shared {@link CloudDemoScenario} (which the cloud/backup mocks read) and
|
||||
* then nudges the page to re-read it. Lives entirely under demo/.
|
||||
*/
|
||||
@customElement("cloud-demo-controls")
|
||||
export class CloudDemoControls extends LitElement {
|
||||
@state() private _open = true;
|
||||
|
||||
@state() private _visible = false;
|
||||
|
||||
@state() private _scenario: CloudDemoScenario = getCloudDemoScenario();
|
||||
|
||||
private _unsub?: () => void;
|
||||
|
||||
// The demo uses hash-based routing (navigate() sets location.hash), so the
|
||||
// active route lives in the hash, not the pathname.
|
||||
private get _currentPath(): string {
|
||||
const hash = mainWindow.location.hash;
|
||||
return hash.startsWith("#/") ? hash.slice(1) : mainWindow.location.pathname;
|
||||
}
|
||||
|
||||
private _locationChanged = () => {
|
||||
this._visible = this._currentPath.startsWith("/config/cloud");
|
||||
};
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this._locationChanged();
|
||||
mainWindow.addEventListener("location-changed", this._locationChanged);
|
||||
mainWindow.addEventListener("popstate", this._locationChanged);
|
||||
mainWindow.addEventListener("hashchange", this._locationChanged);
|
||||
this._unsub = subscribeCloudDemoScenario((scenario) => {
|
||||
this._scenario = { ...scenario };
|
||||
});
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
mainWindow.removeEventListener("location-changed", this._locationChanged);
|
||||
mainWindow.removeEventListener("popstate", this._locationChanged);
|
||||
mainWindow.removeEventListener("hashchange", this._locationChanged);
|
||||
this._unsub?.();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._visible) {
|
||||
return nothing;
|
||||
}
|
||||
if (!this._open) {
|
||||
return html`
|
||||
<ha-icon-button
|
||||
class="fab"
|
||||
label="Cloud demo controls"
|
||||
.path=${mdiFlaskOutline}
|
||||
@click=${this._toggleOpen}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="header">
|
||||
<ha-svg-icon .path=${mdiFlaskOutline}></ha-svg-icon>
|
||||
<span class="title">Cloud demo controls</span>
|
||||
<ha-icon-button
|
||||
label="Close"
|
||||
.path=${mdiClose}
|
||||
@click=${this._toggleOpen}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
<p class="note">
|
||||
Demo only. Flips the mocked cloud state shown on this page.
|
||||
</p>
|
||||
<div class="controls">
|
||||
${this._segment("Subscription", "account", [
|
||||
["active", "Active"],
|
||||
["trialing", "Trialing"],
|
||||
["canceled", "Canceled"],
|
||||
["expired", "Expired"],
|
||||
["unknown", "Unknown"],
|
||||
])}
|
||||
${this._toggle("Onboarded", "onboarded")}
|
||||
${this._toggle("Onboarding postponed", "postponed")}
|
||||
${this._toggle("Remote access", "remote")}
|
||||
${this._segment("Remote status", "remoteStatus", [
|
||||
["ready", "Ready"],
|
||||
["generating", "Preparing"],
|
||||
["loading", "Loading"],
|
||||
["loaded", "Loaded"],
|
||||
["error", "Error"],
|
||||
])}
|
||||
${this._segment("Backups", "backup", [
|
||||
["fresh", "Recent"],
|
||||
["stale", "Old"],
|
||||
["failed", "Failed"],
|
||||
["local", "Local only"],
|
||||
["none", "None"],
|
||||
])}
|
||||
${this._toggle("Alexa linked", "alexa")}
|
||||
${this._toggle("Google linked", "google")}
|
||||
${this._toggle("Cameras (WebRTC)", "webrtc")}
|
||||
${this._toggle("Has webhooks", "webhooks")}
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _segment(
|
||||
label: string,
|
||||
field: keyof CloudDemoScenario,
|
||||
options: [string, string][]
|
||||
) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<span>${label}</span>
|
||||
<div class="segment">
|
||||
${options.map(
|
||||
([value, text]) => html`
|
||||
<ha-button
|
||||
size="s"
|
||||
appearance=${
|
||||
this._scenario[field] === value ? "filled" : "plain"
|
||||
}
|
||||
data-field=${field}
|
||||
data-value=${value}
|
||||
@click=${this._segmentClick}
|
||||
>
|
||||
${text}
|
||||
</ha-button>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggle(label: string, field: keyof CloudDemoScenario) {
|
||||
return html`
|
||||
<div class="row">
|
||||
<span>${label}</span>
|
||||
<ha-switch
|
||||
.checked=${this._scenario[field] as boolean}
|
||||
data-field=${field}
|
||||
@change=${this._toggleChange}
|
||||
></ha-switch>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _toggleOpen() {
|
||||
this._open = !this._open;
|
||||
}
|
||||
|
||||
private _segmentClick(ev: Event) {
|
||||
const target = ev.currentTarget as HTMLElement;
|
||||
this._set(
|
||||
target.dataset.field as keyof CloudDemoScenario,
|
||||
target.dataset.value!
|
||||
);
|
||||
}
|
||||
|
||||
private _toggleChange(ev: Event) {
|
||||
const target = ev.target as HaSwitch;
|
||||
this._set(target.dataset.field as keyof CloudDemoScenario, target.checked);
|
||||
}
|
||||
|
||||
private _set(field: keyof CloudDemoScenario, value: string | boolean) {
|
||||
setCloudDemoScenario({ [field]: value } as Partial<CloudDemoScenario>);
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
private _refresh() {
|
||||
// Refresh the shared cloud status so login-state changes (signed out) and
|
||||
// status-derived fields update.
|
||||
const panel = deepQuery("ha-panel-config");
|
||||
if (panel) {
|
||||
fireEvent(panel as HTMLElement, "ha-refresh-cloud-status");
|
||||
}
|
||||
// cloud-account fetches its subscription/backup/webhook data once on mount
|
||||
// and is not cached by the router, so bounce through a sibling cloud route
|
||||
// to force a clean remount that re-reads the updated mocks.
|
||||
const path = this._currentPath;
|
||||
if (path.startsWith("/config/cloud") && path !== "/config/cloud/login") {
|
||||
const sibling =
|
||||
path === "/config/cloud/remote"
|
||||
? "/config/cloud/account"
|
||||
: "/config/cloud/remote";
|
||||
navigate(sibling, { replace: true });
|
||||
window.setTimeout(() => navigate(path, { replace: true }), 0);
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
z-index: 9999;
|
||||
}
|
||||
.fab {
|
||||
--mdc-icon-button-size: 48px;
|
||||
--mdc-icon-size: 24px;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary-color, #fff);
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
ha-card {
|
||||
display: block;
|
||||
width: 320px;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 8px 8px 16px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
.header .title {
|
||||
flex: 1;
|
||||
font-weight: var(--ha-font-weight-medium, 500);
|
||||
}
|
||||
.header ha-svg-icon {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.note {
|
||||
margin: 8px 16px;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s, 0.875rem);
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 0 16px 16px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 36px;
|
||||
}
|
||||
.segment {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"cloud-demo-controls": CloudDemoControls;
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,7 @@ export interface DemoConfig {
|
||||
authorName: string;
|
||||
authorUrl: string;
|
||||
description?:
|
||||
| string
|
||||
| ((localize: LocalizeFunc) => string | TemplateResult<1>);
|
||||
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
|
||||
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
|
||||
entities: (localize: LocalizeFunc) => EntityInput[];
|
||||
theme: () => Record<string, string> | null;
|
||||
|
||||
@@ -43,28 +43,30 @@ export class HADemoCard extends LitElement implements LovelaceCard {
|
||||
<ha-card>
|
||||
<div class="picker">
|
||||
<div class="label">
|
||||
${this._switching
|
||||
? html`<ha-spinner></ha-spinner>`
|
||||
: until(
|
||||
selectedDemoConfig.then(
|
||||
(conf) => html`
|
||||
${conf.name}
|
||||
<small>
|
||||
${this.hass.localize(
|
||||
"ui.panel.page-demo.cards.demo.demo_by",
|
||||
{
|
||||
name: html`
|
||||
<a target="_blank" href=${conf.authorUrl}>
|
||||
${conf.authorName}
|
||||
</a>
|
||||
`,
|
||||
}
|
||||
)}
|
||||
</small>
|
||||
`
|
||||
),
|
||||
""
|
||||
)}
|
||||
${
|
||||
this._switching
|
||||
? html`<ha-spinner></ha-spinner>`
|
||||
: until(
|
||||
selectedDemoConfig.then(
|
||||
(conf) => html`
|
||||
${conf.name}
|
||||
<small>
|
||||
${this.hass.localize(
|
||||
"ui.panel.page-demo.cards.demo.demo_by",
|
||||
{
|
||||
name: html`
|
||||
<a target="_blank" href=${conf.authorUrl}>
|
||||
${conf.authorName}
|
||||
</a>
|
||||
`,
|
||||
}
|
||||
)}
|
||||
</small>
|
||||
`
|
||||
),
|
||||
""
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<ha-button @click=${this._nextConfig} .disabled=${this._switching}>
|
||||
|
||||
+22
-1
@@ -29,11 +29,13 @@ import { mockSystemLog } from "./stubs/system_log";
|
||||
import { mockTemplate } from "./stubs/template";
|
||||
import { mockTodo } from "./stubs/todo";
|
||||
import { mockTranslations } from "./stubs/translations";
|
||||
import "./cloud/cloud-demo-controls";
|
||||
|
||||
// WS command / REST path prefixes whose mocks live in the lazily imported
|
||||
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
|
||||
const CONFIG_PANEL_COMMANDS = [
|
||||
"cloud/",
|
||||
"webhook/list",
|
||||
"validate_config",
|
||||
"config_entries/",
|
||||
"device_automation/",
|
||||
@@ -53,6 +55,7 @@ const CONFIG_PANEL_COMMANDS = [
|
||||
"config/scene/config",
|
||||
"search/related",
|
||||
"tag/list",
|
||||
"assist_pipeline/",
|
||||
];
|
||||
|
||||
@customElement("ha-demo")
|
||||
@@ -65,7 +68,25 @@ 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);
|
||||
|
||||
// The cloud account page only fetches backup config and the webhook count
|
||||
// when those integrations are loaded. Enable them here (demo only) so the
|
||||
// mocked backup/config/info and webhook/list are queried.
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [...(hass.config?.components ?? []), "backup", "webhook"],
|
||||
},
|
||||
});
|
||||
|
||||
// Demo-only floating panel to flip the mocked cloud state. Mounted once at
|
||||
// the document level; it shows itself only on the cloud panel.
|
||||
if (!document.querySelector("cloud-demo-controls")) {
|
||||
document.body.appendChild(document.createElement("cloud-demo-controls"));
|
||||
}
|
||||
const localizePromise =
|
||||
// @ts-ignore
|
||||
this._loadFragmentTranslations(hass.language, "page-demo").then(
|
||||
|
||||
@@ -1,11 +1,43 @@
|
||||
import type { AssistPipeline } from "../../../src/data/assist_pipeline";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const pipelines: AssistPipeline[] = [
|
||||
{
|
||||
id: "01home_assistant_cloud",
|
||||
name: "Home Assistant Cloud",
|
||||
language: "en",
|
||||
conversation_engine: "conversation.home_assistant",
|
||||
conversation_language: "en",
|
||||
stt_engine: "cloud",
|
||||
stt_language: "en-US",
|
||||
tts_engine: "cloud",
|
||||
tts_language: "en-US",
|
||||
tts_voice: "JennyNeural",
|
||||
wake_word_entity: null,
|
||||
wake_word_id: null,
|
||||
},
|
||||
{
|
||||
id: "01local",
|
||||
name: "Local",
|
||||
language: "en",
|
||||
conversation_engine: "conversation.home_assistant",
|
||||
conversation_language: "en",
|
||||
stt_engine: "stt.faster_whisper",
|
||||
stt_language: "en",
|
||||
tts_engine: "tts.piper",
|
||||
tts_language: "en",
|
||||
tts_voice: null,
|
||||
wake_word_entity: null,
|
||||
wake_word_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockAssist = (hass: MockHomeAssistant) => {
|
||||
// Stub for assist pipeline list — returns empty so developer tools assist
|
||||
// tab loads without errors.
|
||||
// Stub for assist pipeline list — returns a cloud and a local pipeline so the
|
||||
// voice assistants config panel shows configured assistants.
|
||||
hass.mockWS("assist_pipeline/pipeline/list", () => ({
|
||||
pipelines: [],
|
||||
preferred_pipeline: null,
|
||||
pipelines,
|
||||
preferred_pipeline: "01home_assistant_cloud",
|
||||
}));
|
||||
|
||||
// Stub for assist pipeline run — immediately sends run-end event so
|
||||
|
||||
+139
-25
@@ -7,42 +7,34 @@ import type {
|
||||
import { BackupScheduleRecurrence } from "../../../src/data/backup";
|
||||
import type { ManagerStateEvent } from "../../../src/data/backup_manager";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import type { DemoCloudBackup } from "./cloud-demo-state";
|
||||
import {
|
||||
getCloudDemoScenario,
|
||||
setCloudDemoScenario,
|
||||
subscribeCloudDemoScenario,
|
||||
} from "./cloud-demo-state";
|
||||
|
||||
const lastBackupDate = new Date(Date.now() - 86400000).toISOString();
|
||||
const nextBackupDate = new Date(Date.now() + 86400000).toISOString();
|
||||
|
||||
const backups: BackupContent[] = [
|
||||
{
|
||||
backup_id: "demo-backup-1",
|
||||
name: "Automatic backup DEMO",
|
||||
date: lastBackupDate,
|
||||
with_automatic_settings: true,
|
||||
agents: {
|
||||
"backup.local": { size: 1024 * 1024 * 512, protected: true },
|
||||
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
|
||||
},
|
||||
},
|
||||
];
|
||||
const CLOUD_AGENT = "cloud.cloud";
|
||||
|
||||
const backupInfo: BackupInfo = {
|
||||
backups,
|
||||
backups: [],
|
||||
agent_errors: {},
|
||||
last_attempted_automatic_backup: lastBackupDate,
|
||||
last_completed_automatic_backup: lastBackupDate,
|
||||
last_attempted_automatic_backup: null,
|
||||
last_completed_automatic_backup: null,
|
||||
last_action_event: { manager_state: "idle" },
|
||||
next_automatic_backup: nextBackupDate,
|
||||
next_automatic_backup: null,
|
||||
next_automatic_backup_additional: false,
|
||||
state: "idle",
|
||||
};
|
||||
|
||||
const backupConfig: BackupConfig = {
|
||||
automatic_backups_configured: true,
|
||||
last_attempted_automatic_backup: lastBackupDate,
|
||||
last_completed_automatic_backup: lastBackupDate,
|
||||
next_automatic_backup: nextBackupDate,
|
||||
last_attempted_automatic_backup: null,
|
||||
last_completed_automatic_backup: null,
|
||||
next_automatic_backup: null,
|
||||
next_automatic_backup_additional: false,
|
||||
create_backup: {
|
||||
agent_ids: ["backup.local", "cloud.cloud"],
|
||||
agent_ids: ["backup.local", CLOUD_AGENT],
|
||||
include_addons: [],
|
||||
include_all_addons: true,
|
||||
include_database: true,
|
||||
@@ -69,10 +61,132 @@ const agentsInfo: BackupAgentsInfo = {
|
||||
],
|
||||
};
|
||||
|
||||
// Map the demo "Backups" scenario onto the mutable backup config/info, so the
|
||||
// cloud overview status line and the backup sub-page reflect the chosen state.
|
||||
const applyScenario = () => {
|
||||
const kind = getCloudDemoScenario().backup;
|
||||
const now = Date.now();
|
||||
const recent = new Date(now - 12 * 3600 * 1000).toISOString();
|
||||
const old = new Date(now - 5 * 86400000).toISOString();
|
||||
const future = new Date(now + 86400000).toISOString();
|
||||
// Comfortably past BACKUP_OVERDUE_MARGIN_HOURS (3h) so the "stale" scenario
|
||||
// actually reads as overdue rather than slipping under the margin.
|
||||
const overdue = new Date(now - 6 * 3600 * 1000).toISOString();
|
||||
|
||||
// The cloud agent is a backup target for the cloud-backed states only. For
|
||||
// "local" a backup exists but is stored locally (no cloud copy), and for
|
||||
// "none" there are no automatic backups at all.
|
||||
const cloudEnabled =
|
||||
kind === "fresh" || kind === "stale" || kind === "failed";
|
||||
backupConfig.create_backup.agent_ids = cloudEnabled
|
||||
? ["backup.local", CLOUD_AGENT]
|
||||
: ["backup.local"];
|
||||
|
||||
switch (kind) {
|
||||
case "fresh":
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = recent;
|
||||
backupConfig.last_attempted_automatic_backup = recent;
|
||||
backupConfig.next_automatic_backup = future;
|
||||
break;
|
||||
case "local":
|
||||
// Automatic backups run, but only to the local agent.
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = recent;
|
||||
backupConfig.last_attempted_automatic_backup = recent;
|
||||
backupConfig.next_automatic_backup = future;
|
||||
break;
|
||||
case "stale":
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = old;
|
||||
backupConfig.last_attempted_automatic_backup = old;
|
||||
// Next scheduled backup is in the past, so it reads as overdue.
|
||||
backupConfig.next_automatic_backup = overdue;
|
||||
break;
|
||||
case "failed":
|
||||
backupConfig.automatic_backups_configured = true;
|
||||
backupConfig.last_completed_automatic_backup = old;
|
||||
// Most recent attempt is newer than the last success, so it failed.
|
||||
backupConfig.last_attempted_automatic_backup = recent;
|
||||
backupConfig.next_automatic_backup = future;
|
||||
break;
|
||||
case "none":
|
||||
backupConfig.automatic_backups_configured = false;
|
||||
backupConfig.last_completed_automatic_backup = null;
|
||||
backupConfig.last_attempted_automatic_backup = null;
|
||||
backupConfig.next_automatic_backup = null;
|
||||
break;
|
||||
}
|
||||
|
||||
backupInfo.last_completed_automatic_backup =
|
||||
backupConfig.last_completed_automatic_backup;
|
||||
backupInfo.last_attempted_automatic_backup =
|
||||
backupConfig.last_attempted_automatic_backup;
|
||||
backupInfo.next_automatic_backup = backupConfig.next_automatic_backup;
|
||||
backupInfo.backups =
|
||||
cloudEnabled && backupConfig.last_completed_automatic_backup
|
||||
? [
|
||||
{
|
||||
backup_id: "demo-backup-1",
|
||||
name: "Automatic backup DEMO",
|
||||
date: backupConfig.last_completed_automatic_backup,
|
||||
with_automatic_settings: true,
|
||||
agents: {
|
||||
"backup.local": { size: 1024 * 1024 * 512, protected: true },
|
||||
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
|
||||
},
|
||||
} as BackupContent,
|
||||
]
|
||||
: [];
|
||||
};
|
||||
|
||||
applyScenario();
|
||||
subscribeCloudDemoScenario(applyScenario);
|
||||
|
||||
export const mockBackup = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("backup/info", () => backupInfo);
|
||||
hass.mockWS("backup/config/info", () => ({ config: backupConfig }));
|
||||
// Fresh objects each fetch so re-reading after a mutation actually re-renders
|
||||
// (Lit change detection is identity-based; the real WS API returns new
|
||||
// objects too).
|
||||
hass.mockWS("backup/info", () => ({ ...backupInfo }));
|
||||
hass.mockWS("backup/config/info", () => ({ config: { ...backupConfig } }));
|
||||
hass.mockWS("backup/agents/info", () => agentsInfo);
|
||||
hass.mockWS("backup/config/update", (msg) => {
|
||||
const { type, ...update } = msg;
|
||||
if (update.create_backup) {
|
||||
backupConfig.create_backup = {
|
||||
...backupConfig.create_backup,
|
||||
...update.create_backup,
|
||||
};
|
||||
}
|
||||
if (update.automatic_backups_configured !== undefined) {
|
||||
backupConfig.automatic_backups_configured =
|
||||
update.automatic_backups_configured;
|
||||
}
|
||||
if (update.schedule) {
|
||||
backupConfig.schedule = { ...backupConfig.schedule, ...update.schedule };
|
||||
}
|
||||
if (update.retention) {
|
||||
backupConfig.retention = update.retention;
|
||||
}
|
||||
if (update.agents) {
|
||||
backupConfig.agents = { ...backupConfig.agents, ...update.agents };
|
||||
}
|
||||
// Reflect the UI-driven backup change into the demo scenario so the demo
|
||||
// controls panel stays in sync with the mocked state.
|
||||
const cloudNow = backupConfig.create_backup.agent_ids.includes(CLOUD_AGENT);
|
||||
const current = getCloudDemoScenario().backup;
|
||||
const next: DemoCloudBackup = !backupConfig.automatic_backups_configured
|
||||
? "none"
|
||||
: cloudNow
|
||||
? current === "fresh" || current === "stale" || current === "failed"
|
||||
? current
|
||||
: "fresh"
|
||||
: "local";
|
||||
if (next !== current) {
|
||||
setCloudDemoScenario({ backup: next });
|
||||
}
|
||||
return null;
|
||||
});
|
||||
hass.mockWS(
|
||||
"backup/subscribe_events",
|
||||
(_msg, _hass, onChange?: (event: ManagerStateEvent) => void) => {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// Demo-only switchable Home Assistant Cloud scenario.
|
||||
//
|
||||
// The redesigned cloud account page (src/panels/config/cloud/account) renders
|
||||
// purely from real WS data. To let reviewers preview every UI state without a
|
||||
// real cloud account, this module holds a mutable "scenario" that the cloud and
|
||||
// backup mocks read from, plus the floating <cloud-demo-controls> panel writes
|
||||
// to. It is persisted to localStorage so the choice survives the data the page
|
||||
// fetches once per visit (subscription, backup config, webhooks).
|
||||
//
|
||||
// This lives entirely under demo/ — no production code imports it.
|
||||
|
||||
import type { RemoteCertificateStatus } from "../../../src/data/cloud";
|
||||
|
||||
// The five PaymentSubscriptionState values.
|
||||
export type DemoCloudAccount =
|
||||
"active" | "trialing" | "canceled" | "expired" | "unknown";
|
||||
|
||||
// "local": automatic backups are configured, but not to the cloud agent
|
||||
// (a backup exists, just no cloud copy). "none": no automatic backups at all.
|
||||
export type DemoCloudBackup = "fresh" | "stale" | "failed" | "local" | "none";
|
||||
|
||||
export interface CloudDemoScenario {
|
||||
account: DemoCloudAccount;
|
||||
onboarded: boolean;
|
||||
// Onboarding postponed server-side (maps to onboarding_postponed); hides
|
||||
// the onboarding UI without marking it completed.
|
||||
postponed: boolean;
|
||||
remote: boolean;
|
||||
remoteStatus: RemoteCertificateStatus;
|
||||
backup: DemoCloudBackup;
|
||||
alexa: boolean;
|
||||
google: boolean;
|
||||
webrtc: boolean;
|
||||
webhooks: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_CLOUD_DEMO_SCENARIO: CloudDemoScenario = {
|
||||
account: "active",
|
||||
onboarded: true,
|
||||
postponed: false,
|
||||
remote: true,
|
||||
remoteStatus: "ready",
|
||||
backup: "fresh",
|
||||
alexa: true,
|
||||
google: true,
|
||||
webrtc: true,
|
||||
webhooks: true,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "cloudDemoScenario";
|
||||
|
||||
const readScenario = (): CloudDemoScenario => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
return { ...DEFAULT_CLOUD_DEMO_SCENARIO, ...JSON.parse(raw) };
|
||||
}
|
||||
} catch (_err) {
|
||||
// Ignore malformed or unavailable storage and fall back to the default.
|
||||
}
|
||||
return { ...DEFAULT_CLOUD_DEMO_SCENARIO };
|
||||
};
|
||||
|
||||
let scenario: CloudDemoScenario = readScenario();
|
||||
|
||||
const listeners = new Set<(scenario: CloudDemoScenario) => void>();
|
||||
|
||||
export const getCloudDemoScenario = (): CloudDemoScenario => scenario;
|
||||
|
||||
export const subscribeCloudDemoScenario = (
|
||||
listener: (scenario: CloudDemoScenario) => void
|
||||
): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const setCloudDemoScenario = (
|
||||
partial: Partial<CloudDemoScenario>
|
||||
): void => {
|
||||
scenario = { ...scenario, ...partial };
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(scenario));
|
||||
} catch (_err) {
|
||||
// Ignore storage failures (e.g. private mode); state still applies in-memory.
|
||||
}
|
||||
listeners.forEach((listener) => listener(scenario));
|
||||
};
|
||||
+157
-2
@@ -2,8 +2,15 @@ import type {
|
||||
CloudStatusLoggedIn,
|
||||
SubscriptionInfo,
|
||||
} from "../../../src/data/cloud";
|
||||
import { ONBOARDING_ITEMS } from "../../../src/data/cloud";
|
||||
import type { CloudTTSInfo } from "../../../src/data/cloud/tts";
|
||||
import type { Webhook } from "../../../src/data/webhook";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import {
|
||||
getCloudDemoScenario,
|
||||
setCloudDemoScenario,
|
||||
subscribeCloudDemoScenario,
|
||||
} from "./cloud-demo-state";
|
||||
|
||||
const emptyFilter = () => ({
|
||||
include_domains: [],
|
||||
@@ -12,8 +19,23 @@ const emptyFilter = () => ({
|
||||
exclude_entities: [],
|
||||
});
|
||||
|
||||
const demoWebhooks: Webhook[] = [
|
||||
{
|
||||
webhook_id: "demo_front_door",
|
||||
domain: "automation",
|
||||
name: "Front door motion",
|
||||
local_only: false,
|
||||
},
|
||||
{
|
||||
webhook_id: "demo_companion_app",
|
||||
domain: "mobile_app",
|
||||
name: "Companion app",
|
||||
local_only: false,
|
||||
},
|
||||
];
|
||||
|
||||
// A single mutable status object so that preference changes made in the demo
|
||||
// are reflected back in the UI.
|
||||
// (both via the real UI and the demo scenario controls) are reflected back.
|
||||
const cloudStatus: CloudStatusLoggedIn = {
|
||||
logged_in: true,
|
||||
cloud: "connected",
|
||||
@@ -35,6 +57,8 @@ const cloudStatus: CloudStatusLoggedIn = {
|
||||
remote_certificate_status: "ready",
|
||||
http_use_ssl: false,
|
||||
active_subscription: true,
|
||||
onboarding_postponed: false,
|
||||
onboarding_completed: true,
|
||||
prefs: {
|
||||
google_enabled: true,
|
||||
alexa_enabled: true,
|
||||
@@ -47,6 +71,8 @@ const cloudStatus: CloudStatusLoggedIn = {
|
||||
google_report_state: true,
|
||||
tts_default_voice: ["en-US", "JennyNeural"],
|
||||
cloud_ice_servers_enabled: true,
|
||||
onboarded_items: [...ONBOARDING_ITEMS],
|
||||
onboarding_postponed_until: null,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -54,6 +80,7 @@ const subscription: SubscriptionInfo = {
|
||||
human_description: "Demo subscription, renews automatically",
|
||||
provider: "Nabu Casa, Inc.",
|
||||
plan_renewal_date: 4102444800,
|
||||
subscription: { status: "active" },
|
||||
};
|
||||
|
||||
const ttsInfo: CloudTTSInfo = {
|
||||
@@ -66,17 +93,140 @@ const ttsInfo: CloudTTSInfo = {
|
||||
],
|
||||
};
|
||||
|
||||
// Map the high-level demo scenario onto the mutable cloud status / subscription.
|
||||
const applyScenario = () => {
|
||||
const scenario = getCloudDemoScenario();
|
||||
|
||||
switch (scenario.account) {
|
||||
case "trialing":
|
||||
cloudStatus.active_subscription = true;
|
||||
subscription.subscription = { status: "trialing" };
|
||||
break;
|
||||
case "canceled":
|
||||
cloudStatus.active_subscription = false;
|
||||
subscription.subscription = { status: "canceled" };
|
||||
break;
|
||||
case "expired":
|
||||
cloudStatus.active_subscription = false;
|
||||
subscription.subscription = { status: "expired" };
|
||||
break;
|
||||
case "unknown":
|
||||
cloudStatus.active_subscription = true;
|
||||
subscription.subscription = { status: "unknown" };
|
||||
break;
|
||||
default:
|
||||
// "active"
|
||||
cloudStatus.active_subscription = true;
|
||||
subscription.subscription = { status: "active" };
|
||||
}
|
||||
|
||||
cloudStatus.prefs.onboarded_items = scenario.onboarded
|
||||
? [...ONBOARDING_ITEMS]
|
||||
: [];
|
||||
cloudStatus.onboarding_completed = scenario.onboarded;
|
||||
cloudStatus.onboarding_postponed = scenario.postponed;
|
||||
cloudStatus.prefs.onboarding_postponed_until = scenario.postponed
|
||||
? new Date(Date.now() + 24 * 3600 * 1000).toISOString()
|
||||
: null;
|
||||
cloudStatus.prefs.remote_enabled = scenario.remote;
|
||||
cloudStatus.remote_connected = scenario.remote;
|
||||
cloudStatus.remote_certificate_status = scenario.remoteStatus;
|
||||
cloudStatus.alexa_registered = scenario.alexa;
|
||||
cloudStatus.google_registered = scenario.google;
|
||||
cloudStatus.prefs.cloud_ice_servers_enabled = scenario.webrtc;
|
||||
|
||||
const hasCloudhooks = Object.keys(cloudStatus.prefs.cloudhooks).length > 0;
|
||||
if (scenario.webhooks && !hasCloudhooks) {
|
||||
cloudStatus.prefs.cloudhooks = Object.fromEntries(
|
||||
demoWebhooks.map((webhook) => [
|
||||
webhook.webhook_id,
|
||||
{
|
||||
webhook_id: webhook.webhook_id,
|
||||
cloudhook_id: `demo-${webhook.webhook_id}`,
|
||||
cloudhook_url: `https://hooks.nabu.casa/demo-${webhook.webhook_id}`,
|
||||
managed: false,
|
||||
},
|
||||
])
|
||||
);
|
||||
} else if (!scenario.webhooks && hasCloudhooks) {
|
||||
cloudStatus.prefs.cloudhooks = {};
|
||||
}
|
||||
};
|
||||
|
||||
applyScenario();
|
||||
subscribeCloudDemoScenario(applyScenario);
|
||||
|
||||
// Reflect UI-driven changes (onboarding toggles, remote connect/disconnect)
|
||||
// back into the demo scenario so the demo controls panel stays in sync with the
|
||||
// mocked state. Only writes when a value actually changed, to avoid needless
|
||||
// re-projection. `applyScenario` re-applies the (now matching) scenario, so
|
||||
// this stays idempotent and does not fight the direct mutation above.
|
||||
const syncScenarioFromStatus = () => {
|
||||
const scenario = getCloudDemoScenario();
|
||||
const next = {
|
||||
onboarded: cloudStatus.onboarding_completed,
|
||||
postponed: cloudStatus.onboarding_postponed,
|
||||
remote: cloudStatus.prefs.remote_enabled,
|
||||
webrtc: cloudStatus.prefs.cloud_ice_servers_enabled,
|
||||
webhooks: Object.keys(cloudStatus.prefs.cloudhooks).length > 0,
|
||||
};
|
||||
if (
|
||||
scenario.onboarded !== next.onboarded ||
|
||||
scenario.postponed !== next.postponed ||
|
||||
scenario.remote !== next.remote ||
|
||||
scenario.webrtc !== next.webrtc ||
|
||||
scenario.webhooks !== next.webhooks
|
||||
) {
|
||||
setCloudDemoScenario(next);
|
||||
}
|
||||
};
|
||||
|
||||
export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("cloud/status", () => cloudStatus);
|
||||
hass.mockWS("cloud/status", () => ({
|
||||
...cloudStatus,
|
||||
prefs: { ...cloudStatus.prefs },
|
||||
}));
|
||||
hass.mockWS("cloud/subscription", () => subscription);
|
||||
hass.mockWS("cloud/tts/info", () => ttsInfo);
|
||||
hass.mockWS("webhook/list", () => demoWebhooks);
|
||||
|
||||
hass.mockWS("cloud/update_prefs", (msg) => {
|
||||
const { type, ...prefs } = msg;
|
||||
cloudStatus.prefs = { ...cloudStatus.prefs, ...prefs };
|
||||
syncScenarioFromStatus();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/onboarding/postpone", () => {
|
||||
cloudStatus.prefs.onboarding_postponed_until = new Date(
|
||||
Date.now() + 24 * 3600 * 1000
|
||||
).toISOString();
|
||||
cloudStatus.onboarding_postponed = true;
|
||||
syncScenarioFromStatus();
|
||||
// Backend returns the full logged-in status object.
|
||||
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/onboarding/complete", (msg) => {
|
||||
const items: string[] = msg.items ?? [];
|
||||
const missing = items.filter(
|
||||
(item) => !cloudStatus.prefs.onboarded_items.includes(item)
|
||||
);
|
||||
if (missing.length) {
|
||||
cloudStatus.prefs.onboarded_items = [
|
||||
...cloudStatus.prefs.onboarded_items,
|
||||
...missing,
|
||||
];
|
||||
}
|
||||
cloudStatus.onboarding_completed = ONBOARDING_ITEMS.every(
|
||||
(onboardingItem) =>
|
||||
cloudStatus.prefs.onboarded_items.includes(onboardingItem)
|
||||
);
|
||||
syncScenarioFromStatus();
|
||||
// Backend returns the full logged-in status object.
|
||||
return { ...cloudStatus, prefs: { ...cloudStatus.prefs } };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/cloudhook/create", (msg) => {
|
||||
const webhook = {
|
||||
webhook_id: msg.webhook_id,
|
||||
@@ -95,15 +245,20 @@ export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
const cloudhooks = { ...cloudStatus.prefs.cloudhooks };
|
||||
delete cloudhooks[msg.webhook_id];
|
||||
cloudStatus.prefs.cloudhooks = cloudhooks;
|
||||
syncScenarioFromStatus();
|
||||
return null;
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/remote/connect", () => {
|
||||
cloudStatus.remote_connected = true;
|
||||
cloudStatus.prefs.remote_enabled = true;
|
||||
syncScenarioFromStatus();
|
||||
return null;
|
||||
});
|
||||
hass.mockWS("cloud/remote/disconnect", () => {
|
||||
cloudStatus.remote_connected = false;
|
||||
cloudStatus.prefs.remote_enabled = false;
|
||||
syncScenarioFromStatus();
|
||||
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);
|
||||
};
|
||||
|
||||
+137
-143
@@ -8,103 +8,100 @@ import type {
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"energy/get_prefs",
|
||||
(): EnergyPreferences => ({
|
||||
energy_sources: [
|
||||
{
|
||||
type: "grid",
|
||||
stat_energy_from: "sensor.energy_consumption_tarif_1",
|
||||
stat_energy_to: "sensor.energy_production_tarif_1",
|
||||
stat_cost: "sensor.energy_consumption_tarif_1_cost",
|
||||
stat_compensation: "sensor.energy_production_tarif_1_compensation",
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
entity_energy_price_export: null,
|
||||
number_energy_price_export: null,
|
||||
stat_rate: "sensor.power_grid",
|
||||
cost_adjustment_day: 0,
|
||||
},
|
||||
{
|
||||
type: "grid",
|
||||
stat_energy_from: "sensor.energy_consumption_tarif_2",
|
||||
stat_energy_to: "sensor.energy_production_tarif_2",
|
||||
stat_cost: "sensor.energy_consumption_tarif_2_cost",
|
||||
stat_compensation: "sensor.energy_production_tarif_2_compensation",
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
entity_energy_price_export: null,
|
||||
number_energy_price_export: null,
|
||||
stat_rate: "sensor.power_grid_return",
|
||||
cost_adjustment_day: 0,
|
||||
},
|
||||
{
|
||||
type: "solar",
|
||||
stat_energy_from: "sensor.solar_production",
|
||||
stat_rate: "sensor.power_solar",
|
||||
config_entry_solar_forecast: ["solar_forecast"],
|
||||
},
|
||||
{
|
||||
type: "battery",
|
||||
stat_energy_from: "sensor.battery_output",
|
||||
stat_energy_to: "sensor.battery_input",
|
||||
stat_rate: "sensor.power_battery",
|
||||
},
|
||||
{
|
||||
type: "gas",
|
||||
stat_energy_from: "sensor.energy_gas",
|
||||
stat_cost: "sensor.energy_gas_cost",
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
},
|
||||
{
|
||||
type: "water",
|
||||
stat_energy_from: "sensor.energy_water",
|
||||
stat_cost: "sensor.energy_water_cost",
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
},
|
||||
],
|
||||
device_consumption: [
|
||||
{
|
||||
stat_consumption: "sensor.energy_car",
|
||||
stat_rate: "sensor.power_car",
|
||||
},
|
||||
{
|
||||
stat_consumption: "sensor.energy_ac",
|
||||
stat_rate: "sensor.power_ac",
|
||||
},
|
||||
{
|
||||
stat_consumption: "sensor.energy_washing_machine",
|
||||
stat_rate: "sensor.power_washing_machine",
|
||||
},
|
||||
{
|
||||
stat_consumption: "sensor.energy_dryer",
|
||||
stat_rate: "sensor.power_dryer",
|
||||
},
|
||||
{
|
||||
stat_consumption: "sensor.energy_heat_pump",
|
||||
stat_rate: "sensor.power_heat_pump",
|
||||
},
|
||||
{
|
||||
stat_consumption: "sensor.energy_boiler",
|
||||
stat_rate: "sensor.power_boiler",
|
||||
},
|
||||
],
|
||||
device_consumption_water: [
|
||||
{
|
||||
stat_consumption: "sensor.water_kitchen",
|
||||
},
|
||||
{
|
||||
stat_consumption: "sensor.water_garden",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
hass.mockWS(
|
||||
"energy/info",
|
||||
(): EnergyInfo => ({ cost_sensors: {}, solar_forecast_domains: [] })
|
||||
);
|
||||
hass.mockWS("energy/get_prefs", (): EnergyPreferences => ({
|
||||
energy_sources: [
|
||||
{
|
||||
type: "grid",
|
||||
stat_energy_from: "sensor.energy_consumption_tarif_1",
|
||||
stat_energy_to: "sensor.energy_production_tarif_1",
|
||||
stat_cost: "sensor.energy_consumption_tarif_1_cost",
|
||||
stat_compensation: "sensor.energy_production_tarif_1_compensation",
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
entity_energy_price_export: null,
|
||||
number_energy_price_export: null,
|
||||
stat_rate: "sensor.power_grid",
|
||||
cost_adjustment_day: 0,
|
||||
},
|
||||
{
|
||||
type: "grid",
|
||||
stat_energy_from: "sensor.energy_consumption_tarif_2",
|
||||
stat_energy_to: "sensor.energy_production_tarif_2",
|
||||
stat_cost: "sensor.energy_consumption_tarif_2_cost",
|
||||
stat_compensation: "sensor.energy_production_tarif_2_compensation",
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
entity_energy_price_export: null,
|
||||
number_energy_price_export: null,
|
||||
stat_rate: "sensor.power_grid_return",
|
||||
cost_adjustment_day: 0,
|
||||
},
|
||||
{
|
||||
type: "solar",
|
||||
stat_energy_from: "sensor.solar_production",
|
||||
stat_rate: "sensor.power_solar",
|
||||
config_entry_solar_forecast: ["solar_forecast"],
|
||||
},
|
||||
{
|
||||
type: "battery",
|
||||
stat_energy_from: "sensor.battery_output",
|
||||
stat_energy_to: "sensor.battery_input",
|
||||
stat_rate: "sensor.power_battery",
|
||||
},
|
||||
{
|
||||
type: "gas",
|
||||
stat_energy_from: "sensor.energy_gas",
|
||||
stat_cost: "sensor.energy_gas_cost",
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
},
|
||||
{
|
||||
type: "water",
|
||||
stat_energy_from: "sensor.energy_water",
|
||||
stat_cost: "sensor.energy_water_cost",
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
},
|
||||
],
|
||||
device_consumption: [
|
||||
{
|
||||
stat_consumption: "sensor.energy_car",
|
||||
stat_rate: "sensor.power_car",
|
||||
},
|
||||
{
|
||||
stat_consumption: "sensor.energy_ac",
|
||||
stat_rate: "sensor.power_ac",
|
||||
},
|
||||
{
|
||||
stat_consumption: "sensor.energy_washing_machine",
|
||||
stat_rate: "sensor.power_washing_machine",
|
||||
},
|
||||
{
|
||||
stat_consumption: "sensor.energy_dryer",
|
||||
stat_rate: "sensor.power_dryer",
|
||||
},
|
||||
{
|
||||
stat_consumption: "sensor.energy_heat_pump",
|
||||
stat_rate: "sensor.power_heat_pump",
|
||||
},
|
||||
{
|
||||
stat_consumption: "sensor.energy_boiler",
|
||||
stat_rate: "sensor.power_boiler",
|
||||
},
|
||||
],
|
||||
device_consumption_water: [
|
||||
{
|
||||
stat_consumption: "sensor.water_kitchen",
|
||||
},
|
||||
{
|
||||
stat_consumption: "sensor.water_garden",
|
||||
},
|
||||
],
|
||||
}));
|
||||
hass.mockWS("energy/info", (): EnergyInfo => ({
|
||||
cost_sensors: {},
|
||||
solar_forecast_domains: [],
|
||||
}));
|
||||
hass.mockWS(
|
||||
"energy/fossil_energy_consumption",
|
||||
({ period }): FossilEnergyConsumption => ({
|
||||
@@ -113,51 +110,48 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
);
|
||||
const todayString = format(startOfToday(), "yyyy-MM-dd");
|
||||
const tomorrowString = format(startOfTomorrow(), "yyyy-MM-dd");
|
||||
hass.mockWS(
|
||||
"energy/solar_forecast",
|
||||
(): EnergySolarForecasts => ({
|
||||
solar_forecast: {
|
||||
wh_hours: {
|
||||
[`${todayString}T06:00:00`]: 0,
|
||||
[`${todayString}T06:23:00`]: 6,
|
||||
[`${todayString}T06:45:00`]: 39,
|
||||
[`${todayString}T07:00:00`]: 28,
|
||||
[`${todayString}T08:00:00`]: 208,
|
||||
[`${todayString}T09:00:00`]: 352,
|
||||
[`${todayString}T10:00:00`]: 544,
|
||||
[`${todayString}T11:00:00`]: 748,
|
||||
[`${todayString}T12:00:00`]: 1259,
|
||||
[`${todayString}T13:00:00`]: 1361,
|
||||
[`${todayString}T14:00:00`]: 1373,
|
||||
[`${todayString}T15:00:00`]: 1370,
|
||||
[`${todayString}T16:00:00`]: 1186,
|
||||
[`${todayString}T17:00:00`]: 937,
|
||||
[`${todayString}T18:00:00`]: 652,
|
||||
[`${todayString}T19:00:00`]: 370,
|
||||
[`${todayString}T20:00:00`]: 155,
|
||||
[`${todayString}T21:48:00`]: 24,
|
||||
[`${todayString}T22:36:00`]: 0,
|
||||
[`${tomorrowString}T06:01:00`]: 0,
|
||||
[`${tomorrowString}T06:23:00`]: 9,
|
||||
[`${tomorrowString}T06:45:00`]: 47,
|
||||
[`${tomorrowString}T07:00:00`]: 48,
|
||||
[`${tomorrowString}T08:00:00`]: 473,
|
||||
[`${tomorrowString}T09:00:00`]: 827,
|
||||
[`${tomorrowString}T10:00:00`]: 1153,
|
||||
[`${tomorrowString}T11:00:00`]: 1413,
|
||||
[`${tomorrowString}T12:00:00`]: 1590,
|
||||
[`${tomorrowString}T13:00:00`]: 1652,
|
||||
[`${tomorrowString}T14:00:00`]: 1612,
|
||||
[`${tomorrowString}T15:00:00`]: 1438,
|
||||
[`${tomorrowString}T16:00:00`]: 1149,
|
||||
[`${tomorrowString}T17:00:00`]: 830,
|
||||
[`${tomorrowString}T18:00:00`]: 542,
|
||||
[`${tomorrowString}T19:00:00`]: 311,
|
||||
[`${tomorrowString}T20:00:00`]: 140,
|
||||
[`${tomorrowString}T21:47:00`]: 22,
|
||||
[`${tomorrowString}T22:34:00`]: 0,
|
||||
},
|
||||
hass.mockWS("energy/solar_forecast", (): EnergySolarForecasts => ({
|
||||
solar_forecast: {
|
||||
wh_hours: {
|
||||
[`${todayString}T06:00:00`]: 0,
|
||||
[`${todayString}T06:23:00`]: 6,
|
||||
[`${todayString}T06:45:00`]: 39,
|
||||
[`${todayString}T07:00:00`]: 28,
|
||||
[`${todayString}T08:00:00`]: 208,
|
||||
[`${todayString}T09:00:00`]: 352,
|
||||
[`${todayString}T10:00:00`]: 544,
|
||||
[`${todayString}T11:00:00`]: 748,
|
||||
[`${todayString}T12:00:00`]: 1259,
|
||||
[`${todayString}T13:00:00`]: 1361,
|
||||
[`${todayString}T14:00:00`]: 1373,
|
||||
[`${todayString}T15:00:00`]: 1370,
|
||||
[`${todayString}T16:00:00`]: 1186,
|
||||
[`${todayString}T17:00:00`]: 937,
|
||||
[`${todayString}T18:00:00`]: 652,
|
||||
[`${todayString}T19:00:00`]: 370,
|
||||
[`${todayString}T20:00:00`]: 155,
|
||||
[`${todayString}T21:48:00`]: 24,
|
||||
[`${todayString}T22:36:00`]: 0,
|
||||
[`${tomorrowString}T06:01:00`]: 0,
|
||||
[`${tomorrowString}T06:23:00`]: 9,
|
||||
[`${tomorrowString}T06:45:00`]: 47,
|
||||
[`${tomorrowString}T07:00:00`]: 48,
|
||||
[`${tomorrowString}T08:00:00`]: 473,
|
||||
[`${tomorrowString}T09:00:00`]: 827,
|
||||
[`${tomorrowString}T10:00:00`]: 1153,
|
||||
[`${tomorrowString}T11:00:00`]: 1413,
|
||||
[`${tomorrowString}T12:00:00`]: 1590,
|
||||
[`${tomorrowString}T13:00:00`]: 1652,
|
||||
[`${tomorrowString}T14:00:00`]: 1612,
|
||||
[`${tomorrowString}T15:00:00`]: 1438,
|
||||
[`${tomorrowString}T16:00:00`]: 1149,
|
||||
[`${tomorrowString}T17:00:00`]: 830,
|
||||
[`${tomorrowString}T18:00:00`]: 542,
|
||||
[`${tomorrowString}T19:00:00`]: 311,
|
||||
[`${tomorrowString}T20:00:00`]: 140,
|
||||
[`${tomorrowString}T21:47:00`]: 22,
|
||||
[`${tomorrowString}T22:34:00`]: 0,
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -2,11 +2,8 @@ import type { EntitySources } from "../../../src/data/entity/entity_sources";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockEntitySources = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"entity/source",
|
||||
(): EntitySources => ({
|
||||
"sensor.co2_intensity": { domain: "co2signal" },
|
||||
"sensor.grid_fossil_fuel_percentage": { domain: "co2signal" },
|
||||
})
|
||||
);
|
||||
hass.mockWS("entity/source", (): EntitySources => ({
|
||||
"sensor.co2_intensity": { domain: "co2signal" },
|
||||
"sensor.grid_fossil_fuel_percentage": { domain: "co2signal" },
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,12 +2,9 @@ import type { NetworkUrls } from "../../../src/data/network";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockNetwork = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"network/url",
|
||||
(): NetworkUrls => ({
|
||||
internal: "http://homeassistant.local:8123",
|
||||
external: "https://demo-instance.ui.nabu.casa",
|
||||
cloud: "https://demo-instance.ui.nabu.casa",
|
||||
})
|
||||
);
|
||||
hass.mockWS("network/url", (): NetworkUrls => ({
|
||||
internal: "http://homeassistant.local:8123",
|
||||
external: "https://demo-instance.ui.nabu.casa",
|
||||
cloud: "https://demo-instance.ui.nabu.casa",
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockTranslations = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"frontend/get_translations",
|
||||
(/* msg: {language: string, category: string} */) => ({ resources: {} })
|
||||
);
|
||||
hass.mockWS("frontend/get_translations", (
|
||||
/* msg: {language: string, category: string} */
|
||||
) => ({ resources: {} }));
|
||||
};
|
||||
|
||||
@@ -240,6 +240,12 @@ export default tseslint.config(
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [".github/scripts/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Run the gallery
|
||||
# Run the gallery. Pass --background/--status/--stop/--logs to manage a
|
||||
# detached instance (see build-scripts/dev-server.mjs).
|
||||
|
||||
# Stop on errors
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
./node_modules/.bin/gulp develop-gallery
|
||||
exec node build-scripts/dev-server.mjs --suite gallery "$@"
|
||||
|
||||
@@ -101,9 +101,11 @@ class DemoBlackWhiteRow extends LitElement {
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>
|
||||
${this.value
|
||||
? html`<pre>${JSON.stringify(this.value, undefined, 2)}</pre>`
|
||||
: nothing}
|
||||
${
|
||||
this.value
|
||||
? html`<pre>${JSON.stringify(this.value, undefined, 2)}</pre>`
|
||||
: nothing
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -34,9 +34,11 @@ class DemoCard extends LitElement {
|
||||
return html`
|
||||
<h2>
|
||||
${this.config.heading}
|
||||
${this._size !== undefined
|
||||
? html`<small>(size ${this._size})</small>`
|
||||
: ""}
|
||||
${
|
||||
this._size !== undefined
|
||||
? html`<small>(size ${this._size})</small>`
|
||||
: ""
|
||||
}
|
||||
</h2>
|
||||
<div class="root">
|
||||
<hui-card
|
||||
@@ -44,9 +46,11 @@ class DemoCard extends LitElement {
|
||||
.hass=${this.hass}
|
||||
@card-updated=${this._cardUpdated}
|
||||
></hui-card>
|
||||
${this.showConfig
|
||||
? html`<pre>${this.config.config.trim()}</pre>`
|
||||
: nothing}
|
||||
${
|
||||
this.showConfig
|
||||
? html`<pre>${this.config.config.trim()}</pre>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -22,13 +22,15 @@ class DemoMoreInfo extends LitElement {
|
||||
<div class="root">
|
||||
<div id="card">
|
||||
<ha-card>
|
||||
${!computeShowNewMoreInfo(state)
|
||||
? html`<state-card-content
|
||||
.stateObj=${state}
|
||||
.hass=${this.hass}
|
||||
in-dialog
|
||||
></state-card-content>`
|
||||
: nothing}
|
||||
${
|
||||
!computeShowNewMoreInfo(state)
|
||||
? html`<state-card-content
|
||||
.stateObj=${state}
|
||||
.hass=${this.hass}
|
||||
in-dialog
|
||||
></state-card-content>`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<more-info-content
|
||||
.hass=${this.hass}
|
||||
|
||||
+165
-55
@@ -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];
|
||||
@@ -135,38 +211,46 @@ class HaGallery extends LitElement {
|
||||
</ha-sidebar>
|
||||
<div slot="appContent" class="app-content">
|
||||
<ha-top-app-bar-fixed .narrow=${this._narrow}>
|
||||
${this._narrow || !this._drawerOpen
|
||||
? html`<ha-icon-button
|
||||
slot="navigationIcon"
|
||||
@click=${this._toggleDrawer}
|
||||
.path=${mdiMenu}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
${
|
||||
this._narrow || !this._drawerOpen
|
||||
? html`<ha-icon-button
|
||||
slot="navigationIcon"
|
||||
@click=${this._toggleDrawer}
|
||||
.path=${mdiMenu}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<div slot="title">
|
||||
${isSettingsPage
|
||||
? "Settings"
|
||||
: page?.metadata.title || this._page.split("/")[1]}
|
||||
${
|
||||
isSettingsPage
|
||||
? "Settings"
|
||||
: page?.metadata.title || this._page.split("/")[1]
|
||||
}
|
||||
</div>
|
||||
<div class="content">
|
||||
${isSettingsPage
|
||||
? html`<gallery-settings
|
||||
.hass=${this._galleryHass}
|
||||
.themeSettings=${this._themeSettings}
|
||||
.narrow=${this._narrow}
|
||||
.rtl=${this._rtl}
|
||||
@theme-settings-changed=${this._themeSettingsChanged}
|
||||
@gallery-rtl-changed=${this._rtlChanged}
|
||||
></gallery-settings>`
|
||||
: html`
|
||||
${page?.description
|
||||
? html`
|
||||
<page-description .page=${this._page}>
|
||||
</page-description>
|
||||
`
|
||||
: nothing}
|
||||
${dynamicElement(`demo-${this._page.replace("/", "-")}`)}
|
||||
`}
|
||||
${
|
||||
isSettingsPage
|
||||
? html`<gallery-settings
|
||||
.hass=${this._galleryHass}
|
||||
.themeSettings=${this._themeSettings}
|
||||
.narrow=${this._narrow}
|
||||
.rtl=${this._rtl}
|
||||
@theme-settings-changed=${this._themeSettingsChanged}
|
||||
@gallery-rtl-changed=${this._rtlChanged}
|
||||
></gallery-settings>`
|
||||
: html`
|
||||
${
|
||||
page?.description
|
||||
? html`
|
||||
<page-description .page=${this._page}>
|
||||
</page-description>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${dynamicElement(`demo-${this._page.replace("/", "-")}`)}
|
||||
`
|
||||
}
|
||||
</div>
|
||||
${isSettingsPage || !page ? nothing : this._renderPageFooter(page)}
|
||||
</ha-top-app-bar-fixed>
|
||||
@@ -314,13 +398,15 @@ class HaGallery extends LitElement {
|
||||
.header=${group.header}
|
||||
?expanded=${expanded}
|
||||
>
|
||||
${group.icon
|
||||
? html`<ha-svg-icon
|
||||
slot="leading-icon"
|
||||
class="gallery-sidebar-icon"
|
||||
.path=${group.icon}
|
||||
></ha-svg-icon>`
|
||||
: nothing}
|
||||
${
|
||||
group.icon
|
||||
? html`<ha-svg-icon
|
||||
slot="leading-icon"
|
||||
class="gallery-sidebar-icon"
|
||||
.path=${group.icon}
|
||||
></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
${content}
|
||||
</ha-expansion-panel>
|
||||
`
|
||||
@@ -378,9 +464,11 @@ class HaGallery extends LitElement {
|
||||
?selected=${this._page === page}
|
||||
href=${`#${page}`}
|
||||
>
|
||||
${iconPath
|
||||
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
|
||||
: nothing}
|
||||
${
|
||||
iconPath
|
||||
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
<span slot="headline">${title}</span>
|
||||
</ha-list-item-button>
|
||||
`;
|
||||
@@ -411,23 +499,30 @@ class HaGallery extends LitElement {
|
||||
Suggest an edit to this page, or provide/view feedback for this page.
|
||||
</div>
|
||||
<div>
|
||||
${page.description || Object.keys(page.metadata).length > 0
|
||||
? html`
|
||||
<a
|
||||
href=${`${GITHUB_DEMO_URL}${this._page}.markdown`}
|
||||
target="_blank"
|
||||
>
|
||||
Edit text
|
||||
</a>
|
||||
`
|
||||
: nothing}
|
||||
${page.demo
|
||||
? html`
|
||||
<a href=${`${GITHUB_DEMO_URL}${this._page}.ts`} target="_blank">
|
||||
Edit demo
|
||||
</a>
|
||||
`
|
||||
: nothing}
|
||||
${
|
||||
page.description || Object.keys(page.metadata).length > 0
|
||||
? html`
|
||||
<a
|
||||
href=${`${GITHUB_DEMO_URL}${this._page}.markdown`}
|
||||
target="_blank"
|
||||
>
|
||||
Edit text
|
||||
</a>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
page.demo
|
||||
? html`
|
||||
<a
|
||||
href=${`${GITHUB_DEMO_URL}${this._page}.ts`}
|
||||
target="_blank"
|
||||
>
|
||||
Edit demo
|
||||
</a>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -576,6 +671,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -149,9 +149,11 @@ export class DemoAutomationDescribeAction extends LitElement {
|
||||
<ha-card header="Actions">
|
||||
<div class="action">
|
||||
<span>
|
||||
${this._action
|
||||
? describeAction(this.hass, [], this._action)
|
||||
: "<invalid YAML>"}
|
||||
${
|
||||
this._action
|
||||
? describeAction(this.hass, [], this._action)
|
||||
: "<invalid YAML>"
|
||||
}
|
||||
</span>
|
||||
<ha-yaml-editor
|
||||
label="Action Config"
|
||||
|
||||
@@ -74,9 +74,11 @@ export class DemoAutomationDescribeCondition extends LitElement {
|
||||
<ha-card header="Conditions">
|
||||
<div class="condition">
|
||||
<span>
|
||||
${this._condition
|
||||
? describeCondition(this._condition, this.hass, [])
|
||||
: "<invalid YAML>"}
|
||||
${
|
||||
this._condition
|
||||
? describeCondition(this._condition, this.hass, [])
|
||||
: "<invalid YAML>"
|
||||
}
|
||||
</span>
|
||||
<ha-yaml-editor
|
||||
label="Condition Config"
|
||||
|
||||
@@ -98,9 +98,11 @@ export class DemoAutomationDescribeTrigger extends LitElement {
|
||||
<ha-card header="Triggers">
|
||||
<div class="trigger">
|
||||
<span>
|
||||
${this._trigger
|
||||
? describeTrigger(this._trigger, this.hass, [])
|
||||
: "<invalid YAML>"}
|
||||
${
|
||||
this._trigger
|
||||
? describeTrigger(this._trigger, this.hass, [])
|
||||
: "<invalid YAML>"
|
||||
}
|
||||
</span>
|
||||
<ha-yaml-editor
|
||||
label="Trigger Config"
|
||||
|
||||
@@ -54,16 +54,18 @@ export class DemoAutomationTrace extends LitElement {
|
||||
@value-changed=${this._handleTimelineValueChanged}
|
||||
.sampleIdx=${idx}
|
||||
></hat-trace-timeline>
|
||||
${selectedNode && graph
|
||||
? html`<ha-trace-path-details
|
||||
.hass=${this.hass}
|
||||
.trace=${trace.trace}
|
||||
.selected=${selectedNode}
|
||||
.logbookEntries=${trace.logbookEntries}
|
||||
.trackedNodes=${graph.trackedNodes}
|
||||
.renderedNodes=${graph.renderedNodes}
|
||||
></ha-trace-path-details>`
|
||||
: nothing}
|
||||
${
|
||||
selectedNode && graph
|
||||
? html`<ha-trace-path-details
|
||||
.hass=${this.hass}
|
||||
.trace=${trace.trace}
|
||||
.selected=${selectedNode}
|
||||
.logbookEntries=${trace.logbookEntries}
|
||||
.trackedNodes=${graph.trackedNodes}
|
||||
.renderedNodes=${graph.renderedNodes}
|
||||
></ha-trace-path-details>`
|
||||
: nothing
|
||||
}
|
||||
<button @click=${() => console.log(trace)}>Log trace</button>
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
@@ -33,20 +33,24 @@ export class DemoHaChips extends LitElement {
|
||||
${chips.map(
|
||||
(chip) => html`
|
||||
<ha-assist-chip .label=${chip.content}>
|
||||
${chip.icon
|
||||
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
|
||||
</ha-svg-icon>`
|
||||
: nothing}
|
||||
${
|
||||
chip.icon
|
||||
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
|
||||
</ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
</ha-assist-chip>
|
||||
`
|
||||
)}
|
||||
${chips.map(
|
||||
(chip) => html`
|
||||
<ha-assist-chip .label=${chip.content} selected>
|
||||
${chip.icon
|
||||
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
|
||||
</ha-svg-icon>`
|
||||
: nothing}
|
||||
${
|
||||
chip.icon
|
||||
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
|
||||
</ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
</ha-assist-chip>
|
||||
`
|
||||
)}
|
||||
@@ -56,20 +60,24 @@ export class DemoHaChips extends LitElement {
|
||||
${chips.map(
|
||||
(chip) => html`
|
||||
<ha-filter-chip .label=${chip.content}>
|
||||
${chip.icon
|
||||
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
|
||||
</ha-svg-icon>`
|
||||
: nothing}
|
||||
${
|
||||
chip.icon
|
||||
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
|
||||
</ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
</ha-filter-chip>
|
||||
`
|
||||
)}
|
||||
${chips.map(
|
||||
(chip) => html`
|
||||
<ha-filter-chip .label=${chip.content} selected>
|
||||
${chip.icon
|
||||
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
|
||||
</ha-svg-icon>`
|
||||
: nothing}
|
||||
${
|
||||
chip.icon
|
||||
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
|
||||
</ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
</ha-filter-chip>
|
||||
`
|
||||
)}
|
||||
@@ -79,10 +87,12 @@ export class DemoHaChips extends LitElement {
|
||||
${chips.map(
|
||||
(chip) => html`
|
||||
<ha-input-chip .label=${chip.content}>
|
||||
${chip.icon
|
||||
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
|
||||
</ha-svg-icon>`
|
||||
: nothing}
|
||||
${
|
||||
chip.icon
|
||||
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
|
||||
</ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
${chip.content}
|
||||
</ha-input-chip>
|
||||
`
|
||||
@@ -90,10 +100,12 @@ export class DemoHaChips extends LitElement {
|
||||
${chips.map(
|
||||
(chip) => html`
|
||||
<ha-input-chip .label=${chip.content} selected>
|
||||
${chip.icon
|
||||
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
|
||||
</ha-svg-icon>`
|
||||
: nothing}
|
||||
${
|
||||
chip.icon
|
||||
? html`<ha-svg-icon slot="icon" .path=${chip.icon}>
|
||||
</ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
</ha-input-chip>
|
||||
`
|
||||
)}
|
||||
|
||||
@@ -92,14 +92,16 @@ export class DemoHaControlSelectMenu extends LitElement {
|
||||
.value=${option.value}
|
||||
.graphic=${option.icon ? "icon" : undefined}
|
||||
>
|
||||
${option.icon
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
slot="graphic"
|
||||
.path=${option.icon}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: nothing}
|
||||
${
|
||||
option.icon
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
slot="graphic"
|
||||
.path=${option.icon}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${option.label ?? option.value}
|
||||
</ha-list-item>
|
||||
`
|
||||
|
||||
@@ -60,9 +60,9 @@ export class DemoHaLabelBadge extends LitElement {
|
||||
${badges.map(
|
||||
(badge) => html`
|
||||
<ha-label-badge
|
||||
style="--ha-label-badge-color: ${colors[
|
||||
Math.floor(Math.random() * colors.length)
|
||||
]}"
|
||||
style="--ha-label-badge-color: ${
|
||||
colors[Math.floor(Math.random() * colors.length)]
|
||||
}"
|
||||
.label=${badge.label}
|
||||
.description=${badge.description}
|
||||
.image=${badge.image}
|
||||
@@ -78,9 +78,9 @@ export class DemoHaLabelBadge extends LitElement {
|
||||
(badge) => html`
|
||||
<div class="badge">
|
||||
<ha-label-badge
|
||||
style="--ha-label-badge-color: ${colors[
|
||||
Math.floor(Math.random() * colors.length)
|
||||
]}"
|
||||
style="--ha-label-badge-color: ${
|
||||
colors[Math.floor(Math.random() * colors.length)]
|
||||
}"
|
||||
.label=${badge.label}
|
||||
.description=${badge.description}
|
||||
.image=${badge.image}
|
||||
|
||||
@@ -244,8 +244,7 @@ export class DemoHaList extends LitElement {
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
<pre>
|
||||
selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre
|
||||
>
|
||||
selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre>
|
||||
</ha-card>
|
||||
|
||||
<ha-card
|
||||
@@ -272,8 +271,7 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
<pre>
|
||||
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre
|
||||
>
|
||||
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Option: all combinations">
|
||||
|
||||
@@ -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);
|
||||
@@ -723,11 +696,13 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
([key, value]) => html`
|
||||
<ha-settings-row narrow slot=${slot}>
|
||||
<span slot="heading">${value?.name || key}</span>
|
||||
${value?.description
|
||||
? html`<span slot="description"
|
||||
>${value?.description}</span
|
||||
>`
|
||||
: nothing}
|
||||
${
|
||||
value?.description
|
||||
? html`<span slot="description"
|
||||
>${value?.description}</span
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${value!.selector}
|
||||
|
||||
@@ -248,7 +248,7 @@ class DemoThermostatEntity extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProperties);
|
||||
const hass = provideHass(this._demoRoot, {}, false, true);
|
||||
const hass = provideHass(this._demoRoot);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.updateTranslations("lovelace", "en");
|
||||
hass.addEntities(ENTITIES);
|
||||
|
||||
@@ -151,7 +151,7 @@ class DemoMoreInfoClimate extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProperties);
|
||||
const hass = provideHass(this._demoRoot, {}, false, true);
|
||||
const hass = provideHass(this._demoRoot);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.addEntities(ENTITIES);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ class DemoMoreInfoHumidifier extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProperties);
|
||||
const hass = provideHass(this._demoRoot, {}, false, true);
|
||||
const hass = provideHass(this._demoRoot);
|
||||
hass.updateTranslations(null, "en");
|
||||
hass.addEntities(ENTITIES);
|
||||
}
|
||||
|
||||
@@ -65,30 +65,34 @@ class LandingPageLogs extends LitElement {
|
||||
<ha-button appearance="plain" @click=${this._toggleLogDetails}>
|
||||
${this.localize(this._show ? "hide_details" : "show_details")}
|
||||
</ha-button>
|
||||
${this._show
|
||||
? html`<ha-icon-button
|
||||
.label=${this.localize("logs.download_logs")}
|
||||
.path=${mdiDownload}
|
||||
@click=${this._downloadLogs}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
${
|
||||
this._show
|
||||
? html`<ha-icon-button
|
||||
.label=${this.localize("logs.download_logs")}
|
||||
.path=${mdiDownload}
|
||||
@click=${this._downloadLogs}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${this._error
|
||||
? html`
|
||||
<ha-alert
|
||||
alert-type="error"
|
||||
.title=${this.localize("logs.fetch_error")}
|
||||
>
|
||||
<ha-button
|
||||
size="small"
|
||||
variant="danger"
|
||||
@click=${this._startLogStream}
|
||||
${
|
||||
this._error
|
||||
? html`
|
||||
<ha-alert
|
||||
alert-type="error"
|
||||
.title=${this.localize("logs.fetch_error")}
|
||||
>
|
||||
${this.localize("logs.retry")}
|
||||
</ha-button>
|
||||
</ha-alert>
|
||||
`
|
||||
: nothing}
|
||||
<ha-button
|
||||
size="small"
|
||||
variant="danger"
|
||||
@click=${this._startLogStream}
|
||||
>
|
||||
${this.localize("logs.retry")}
|
||||
</ha-button>
|
||||
</ha-alert>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div
|
||||
class=${classMap({
|
||||
logs: true,
|
||||
|
||||
@@ -55,13 +55,15 @@ class LandingPageNetwork extends LitElement {
|
||||
})}
|
||||
</p>
|
||||
<p>${this.localize("network_issue.resolve_different")}</p>
|
||||
${!dnsPrimaryInterfaceNameservers
|
||||
? html`
|
||||
<p>
|
||||
<b>${this.localize("network_issue.no_primary_interface")} </b>
|
||||
</p>
|
||||
`
|
||||
: nothing}
|
||||
${
|
||||
!dnsPrimaryInterfaceNameservers
|
||||
? html`
|
||||
<p>
|
||||
<b>${this.localize("network_issue.no_primary_interface")} </b>
|
||||
</p>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="actions">
|
||||
${ALTERNATIVE_DNS_SERVERS.map(
|
||||
({ translationKey }, key) =>
|
||||
|
||||
@@ -61,39 +61,47 @@ class HaLandingPage extends LandingPageBaseElement {
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<h1>${this.localize("header")}</h1>
|
||||
${!networkIssue && !this._supervisorError
|
||||
? html`
|
||||
<p>${this.localize("subheader")}</p>
|
||||
<ha-progress-bar
|
||||
.indeterminate=${this._progress <= 0}
|
||||
.value=${this._progress > 0 ? this._progress : undefined}
|
||||
.loading=${this._progress >= 0}
|
||||
>${this._progress > 0
|
||||
? `${Math.round(this._progress)}%`
|
||||
: nothing}</ha-progress-bar
|
||||
>
|
||||
`
|
||||
: nothing}
|
||||
${networkIssue || this._networkInfoError
|
||||
? html`
|
||||
<landing-page-network
|
||||
.localize=${this.localize}
|
||||
.networkInfo=${this._networkInfo}
|
||||
.error=${this._networkInfoError}
|
||||
@dns-set=${this._fetchSupervisorInfo}
|
||||
></landing-page-network>
|
||||
`
|
||||
: nothing}
|
||||
${this._supervisorError
|
||||
? html`
|
||||
<ha-alert
|
||||
alert-type="error"
|
||||
.title=${this.localize("error_title")}
|
||||
>
|
||||
${this.localize("error_description")}
|
||||
</ha-alert>
|
||||
`
|
||||
: nothing}
|
||||
${
|
||||
!networkIssue && !this._supervisorError
|
||||
? html`
|
||||
<p>${this.localize("subheader")}</p>
|
||||
<ha-progress-bar
|
||||
.indeterminate=${this._progress <= 0}
|
||||
.value=${this._progress > 0 ? this._progress : undefined}
|
||||
.loading=${this._progress >= 0}
|
||||
>${
|
||||
this._progress > 0
|
||||
? `${Math.round(this._progress)}%`
|
||||
: nothing
|
||||
}</ha-progress-bar
|
||||
>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
networkIssue || this._networkInfoError
|
||||
? html`
|
||||
<landing-page-network
|
||||
.localize=${this.localize}
|
||||
.networkInfo=${this._networkInfo}
|
||||
.error=${this._networkInfoError}
|
||||
@dns-set=${this._fetchSupervisorInfo}
|
||||
></landing-page-network>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
this._supervisorError
|
||||
? html`
|
||||
<ha-alert
|
||||
alert-type="error"
|
||||
.title=${this.localize("error_title")}
|
||||
>
|
||||
${this.localize("error_description")}
|
||||
</ha-alert>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<landing-page-logs
|
||||
.localize=${this.localize}
|
||||
@landing-page-error=${this._showError}
|
||||
|
||||
+30
-25
@@ -23,10 +23,16 @@
|
||||
"test": "vitest run --config test/vitest.config.ts",
|
||||
"test:bench": "vitest bench --run --config test/vitest.bench.config.ts",
|
||||
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
|
||||
"check-bundlesize": "node build-scripts/check-bundle-size.cjs",
|
||||
"dev": "node build-scripts/dev-server.mjs --suite app",
|
||||
"dev:serve": "node build-scripts/dev-server.mjs --suite app-serve",
|
||||
"dev:demo": "demo/script/develop_demo",
|
||||
"dev:gallery": "gallery/script/develop_gallery",
|
||||
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
|
||||
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
|
||||
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
|
||||
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
|
||||
"test:e2e:app:dev": "test/e2e/app/script/develop_app",
|
||||
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts"
|
||||
},
|
||||
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
|
||||
@@ -36,14 +42,14 @@
|
||||
"@babel/runtime": "8.0.0",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.10.3",
|
||||
"@codemirror/commands": "6.10.4",
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/language": "6.12.4",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.1",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.43.1",
|
||||
"@codemirror/state": "6.7.0",
|
||||
"@codemirror/view": "6.43.5",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.4.9",
|
||||
@@ -77,9 +83,10 @@
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "4.2.1",
|
||||
"@tsparticles/preset-links": "4.2.1",
|
||||
"@tsparticles/engine": "4.3.1",
|
||||
"@tsparticles/preset-links": "4.3.1",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.198.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.2.0",
|
||||
@@ -96,13 +103,12 @@
|
||||
"echarts": "6.1.0",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.4.2",
|
||||
"google-timezones-json": "1.2.0",
|
||||
"gulp-zopfli-green": "7.0.0",
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.5",
|
||||
"intl-messageformat": "11.2.8",
|
||||
"js-yaml": "4.2.0",
|
||||
"idb-keyval": "6.2.6",
|
||||
"intl-messageformat": "11.2.9",
|
||||
"js-yaml": "5.2.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
@@ -139,22 +145,21 @@
|
||||
"@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",
|
||||
"@html-eslint/eslint-plugin": "0.63.0",
|
||||
"@lokalise/node-api": "16.0.0",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.60.0",
|
||||
"@rsdoctor/rspack-plugin": "1.5.15",
|
||||
"@rspack/core": "2.0.8",
|
||||
"@rspack/dev-server": "2.0.3",
|
||||
"@playwright/test": "1.61.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.17",
|
||||
"@rspack/core": "2.1.2",
|
||||
"@rspack/dev-server": "2.1.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
"@types/color-name": "2.0.0",
|
||||
"@types/culori": "4.0.1",
|
||||
"@types/html-minifier-terser": "7.0.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/leaflet": "1.9.21",
|
||||
"@types/leaflet-draw": "1.0.13",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
@@ -168,19 +173,19 @@
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.5.0",
|
||||
"eslint": "10.6.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.16.2",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
"eslint-plugin-lit": "2.3.1",
|
||||
"eslint-plugin-lit-a11y": "5.1.1",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.3.5",
|
||||
"fs-extra": "11.3.6",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.6.0",
|
||||
"globals": "17.7.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-brotli": "3.0.0",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
@@ -195,17 +200,17 @@
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
"map-stream": "0.0.7",
|
||||
"minify-literals": "2.0.2",
|
||||
"minify-literals": "2.1.0",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.8.4",
|
||||
"prettier": "3.9.4",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "22.0.0",
|
||||
"tar": "7.5.16",
|
||||
"tar": "7.5.19",
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.61.1",
|
||||
"typescript-eslint": "8.62.1",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.9",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -218,7 +223,7 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.6.0",
|
||||
"globals": "17.7.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"glob@^10.2.2": "^10.5.0"
|
||||
|
||||
+10
-1
@@ -11,7 +11,7 @@
|
||||
"group:recommended",
|
||||
"security:minimumReleaseAgeNpm"
|
||||
],
|
||||
"enabledManagers": ["npm", "nvm"],
|
||||
"enabledManagers": ["npm", "nvm", "custom.regex"],
|
||||
"postUpdateOptions": ["yarnDedupeHighest"],
|
||||
"lockFileMaintenance": {
|
||||
"description": ["Run after patch releases but before next beta"],
|
||||
@@ -49,6 +49,15 @@
|
||||
"datasourceTemplate": "custom.ha-core-python",
|
||||
"versioningTemplate": "python",
|
||||
"extractVersionTemplate": "^(?<version>\\d+\\.\\d+)"
|
||||
},
|
||||
{
|
||||
"description": "Keep actionlint used in CI up to date",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/workflows/actionlint\\.yaml$/"],
|
||||
"matchStrings": ["ACTIONLINT_VERSION: (?<currentValue>\\S+)"],
|
||||
"depNameTemplate": "rhysd/actionlint",
|
||||
"datasourceTemplate": "github-releases",
|
||||
"extractVersionTemplate": "^v(?<version>.+)$"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
|
||||
+23
-17
@@ -182,9 +182,11 @@ export class HaAuthFlow extends LitElement {
|
||||
@click=${this._handleSubmit}
|
||||
.loading=${this._submitting}
|
||||
>
|
||||
${this.step.type === "form"
|
||||
? this.localize("ui.panel.page-authorize.form.next")
|
||||
: this.localize("ui.panel.page-authorize.form.start_over")}
|
||||
${
|
||||
this.step.type === "form"
|
||||
? this.localize("ui.panel.page-authorize.form.next")
|
||||
: this.localize("ui.panel.page-authorize.form.start_over")
|
||||
}
|
||||
</ha-button>
|
||||
</div>
|
||||
`;
|
||||
@@ -224,9 +226,11 @@ export class HaAuthFlow extends LitElement {
|
||||
case "form":
|
||||
return html`
|
||||
<h1>
|
||||
${!["select_mfa_module", "mfa"].includes(step.step_id)
|
||||
? this.localize("ui.panel.page-authorize.welcome_home")
|
||||
: this.localize("ui.panel.page-authorize.just_checking")}
|
||||
${
|
||||
!["select_mfa_module", "mfa"].includes(step.step_id)
|
||||
? this.localize("ui.panel.page-authorize.welcome_home")
|
||||
: this.localize("ui.panel.page-authorize.just_checking")
|
||||
}
|
||||
</h1>
|
||||
${this._computeStepDescription(step)}
|
||||
${keyed(
|
||||
@@ -244,17 +248,19 @@ export class HaAuthFlow extends LitElement {
|
||||
)}
|
||||
|
||||
<div class="space-between">
|
||||
${this.clientId === genClientId() &&
|
||||
!["select_mfa_module", "mfa"].includes(step.step_id)
|
||||
? html`
|
||||
<ha-checkbox
|
||||
.checked=${this._storeToken}
|
||||
@change=${this._storeTokenChanged}
|
||||
>
|
||||
${this.localize("ui.panel.page-authorize.store_token")}
|
||||
</ha-checkbox>
|
||||
`
|
||||
: ""}
|
||||
${
|
||||
this.clientId === genClientId() &&
|
||||
!["select_mfa_module", "mfa"].includes(step.step_id)
|
||||
? html`
|
||||
<ha-checkbox
|
||||
.checked=${this._storeToken}
|
||||
@change=${this._storeTokenChanged}
|
||||
>
|
||||
${this.localize("ui.panel.page-authorize.store_token")}
|
||||
</ha-checkbox>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<a
|
||||
class="forgot-password"
|
||||
href="https://www.home-assistant.io/docs/locked_out/#forgot-password"
|
||||
|
||||
+50
-37
@@ -147,45 +147,58 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
|
||||
}
|
||||
</style>
|
||||
|
||||
${!this._ownInstance
|
||||
? html`<ha-alert .alertType=${app ? "info" : "warning"}>
|
||||
${app
|
||||
? this.localize("ui.panel.page-authorize.authorizing_app", {
|
||||
app: appNames[this.clientId!],
|
||||
})
|
||||
: this.localize("ui.panel.page-authorize.authorizing_client", {
|
||||
clientId: html`<b
|
||||
>${this.clientId
|
||||
? punycode.toASCII(this.clientId)
|
||||
: this.clientId}</b
|
||||
>`,
|
||||
})}
|
||||
</ha-alert>`
|
||||
: nothing}
|
||||
${
|
||||
!this._ownInstance
|
||||
? html`<ha-alert .alertType=${app ? "info" : "warning"}>
|
||||
${
|
||||
app
|
||||
? this.localize("ui.panel.page-authorize.authorizing_app", {
|
||||
app: appNames[this.clientId!],
|
||||
})
|
||||
: this.localize(
|
||||
"ui.panel.page-authorize.authorizing_client",
|
||||
{
|
||||
clientId: html`<b
|
||||
>${
|
||||
this.clientId
|
||||
? punycode.toASCII(this.clientId)
|
||||
: this.clientId
|
||||
}</b
|
||||
>`,
|
||||
}
|
||||
)
|
||||
}
|
||||
</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<div class="card-content">
|
||||
${!this._authProvider
|
||||
? html`<p>
|
||||
${this.localize("ui.panel.page-authorize.initializing")}
|
||||
</p> `
|
||||
: html`<ha-auth-flow
|
||||
.clientId=${this.clientId}
|
||||
.redirectUri=${this.redirectUri}
|
||||
.oauth2State=${this.oauth2State}
|
||||
.authProvider=${this._authProvider}
|
||||
.localize=${this.localize}
|
||||
.initStoreToken=${this._preselectStoreToken}
|
||||
></ha-auth-flow>
|
||||
${inactiveProviders!.length > 0
|
||||
? html`
|
||||
<ha-pick-auth-provider
|
||||
.localize=${this.localize}
|
||||
.clientId=${this.clientId}
|
||||
.authProviders=${inactiveProviders!}
|
||||
@pick-auth-provider=${this._handleAuthProviderPick}
|
||||
></ha-pick-auth-provider>
|
||||
`
|
||||
: ""}`}
|
||||
${
|
||||
!this._authProvider
|
||||
? html`<p>
|
||||
${this.localize("ui.panel.page-authorize.initializing")}
|
||||
</p> `
|
||||
: html`<ha-auth-flow
|
||||
.clientId=${this.clientId}
|
||||
.redirectUri=${this.redirectUri}
|
||||
.oauth2State=${this.oauth2State}
|
||||
.authProvider=${this._authProvider}
|
||||
.localize=${this.localize}
|
||||
.initStoreToken=${this._preselectStoreToken}
|
||||
></ha-auth-flow>
|
||||
${
|
||||
inactiveProviders!.length > 0
|
||||
? html`
|
||||
<ha-pick-auth-provider
|
||||
.localize=${this.localize}
|
||||
.clientId=${this.clientId}
|
||||
.authProviders=${inactiveProviders!}
|
||||
@pick-auth-provider=${this._handleAuthProviderPick}
|
||||
></ha-pick-auth-provider>
|
||||
`
|
||||
: ""
|
||||
}`
|
||||
}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<ha-language-picker
|
||||
|
||||
@@ -33,10 +33,7 @@ export interface ShowDemoMessage extends BaseCastMessage {
|
||||
}
|
||||
|
||||
export type HassMessage =
|
||||
| ShowDemoMessage
|
||||
| GetStatusMessage
|
||||
| ConnectMessage
|
||||
| ShowLovelaceViewMessage;
|
||||
ShowDemoMessage | GetStatusMessage | ConnectMessage | ShowLovelaceViewMessage;
|
||||
|
||||
export const castSendAuth = (cast: CastManager, auth: Auth) =>
|
||||
cast.sendMessage({
|
||||
|
||||
+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;
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
export type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
export type WeekdayShort =
|
||||
| "sun"
|
||||
| "mon"
|
||||
| "tue"
|
||||
| "wed"
|
||||
| "thu"
|
||||
| "fri"
|
||||
| "sat";
|
||||
"sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat";
|
||||
|
||||
export type WeekdayLong =
|
||||
| "sunday"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { ContextEvent } from "@lit/context";
|
||||
import type { Context, ContextCallback } from "@lit/context";
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import type { ReactiveController, ReactiveElement } from "lit";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantInternationalization,
|
||||
@@ -49,8 +51,86 @@ export const preserveUnchangedEntityStatesRecord = <
|
||||
return previous;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reactive controller that subscribes to a Lit context and assigns each
|
||||
* delivered value to a host property — WITHOUT forcing a host update on every
|
||||
* delivery.
|
||||
*
|
||||
* `@lit/context`'s built-in `ContextConsumer` calls `host.requestUpdate()`
|
||||
* unconditionally on every provider notification. For a hot context such as
|
||||
* `statesContext` (replaced on every entity state change) that means every
|
||||
* consumer runs an (often empty) update/render cycle on every unrelated state
|
||||
* change, even when the value it actually reads is unchanged.
|
||||
*
|
||||
* This controller instead leaves update scheduling to the property's own
|
||||
* setter. Combined with {@link transform}, that setter only requests an update
|
||||
* when the *selected* value changes (Lit gates `requestUpdate(key, oldValue)`
|
||||
* with `hasChanged`), so unrelated context churn no longer triggers renders.
|
||||
*/
|
||||
class ContextSubscriptionController<ValueType> implements ReactiveController {
|
||||
private _unsubscribe?: () => void;
|
||||
|
||||
constructor(
|
||||
private readonly _host: ReactiveElement,
|
||||
private readonly _context: Context<unknown, ValueType>,
|
||||
private readonly _assign: (value: ValueType) => void
|
||||
) {
|
||||
this._host.addController(this);
|
||||
}
|
||||
|
||||
public hostConnected(): void {
|
||||
this._host.dispatchEvent(
|
||||
new ContextEvent(this._context, this._host, this._callback, true)
|
||||
);
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this._unsubscribe?.();
|
||||
this._unsubscribe = undefined;
|
||||
}
|
||||
|
||||
// Class field arrow function so the identity is stable per instance, which the
|
||||
// provider's subscription bookkeeping and `ContextRoot` deduping rely on.
|
||||
private readonly _callback: ContextCallback<ValueType> = (
|
||||
value,
|
||||
unsubscribe
|
||||
) => {
|
||||
// A different provider answered (e.g. re-parenting); drop the stale one.
|
||||
if (this._unsubscribe && this._unsubscribe !== unsubscribe) {
|
||||
this._unsubscribe();
|
||||
}
|
||||
this._unsubscribe = unsubscribe;
|
||||
// Assign through the property setter, which decides — via `hasChanged` —
|
||||
// whether an update is actually needed. We intentionally never call
|
||||
// `host.requestUpdate()` here.
|
||||
this._assign(value);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `@consume({ subscribe: true })` from `@lit/context`, but does not force a
|
||||
* host update on every provider notification — see
|
||||
* {@link ContextSubscriptionController}. Pair with {@link transform} so an
|
||||
* update is scheduled only when the derived value actually changes.
|
||||
*/
|
||||
const subscribeContext =
|
||||
<ValueType>(context: Context<unknown, ValueType>) =>
|
||||
(proto: object, propertyKey: string): void => {
|
||||
(proto.constructor as unknown as typeof ReactiveElement).addInitializer(
|
||||
(host) => {
|
||||
new ContextSubscriptionController<ValueType>(
|
||||
host as ReactiveElement,
|
||||
context,
|
||||
(value) => {
|
||||
(host as unknown as Record<string, unknown>)[propertyKey] = value;
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const composeDecorator = <T, V>(
|
||||
context: Parameters<typeof consume>[0]["context"],
|
||||
context: Context<unknown, T>,
|
||||
watchKey: string | undefined,
|
||||
select: (this: unknown, value: T) => V | undefined
|
||||
) => {
|
||||
@@ -60,7 +140,7 @@ const composeDecorator = <T, V>(
|
||||
},
|
||||
watch: watchKey ? [watchKey] : [],
|
||||
});
|
||||
const consumeDec = consume<any>({ context, subscribe: true });
|
||||
const consumeDec = subscribeContext<T>(context);
|
||||
return (proto: any, propertyKey: string) => {
|
||||
transformDec(proto, propertyKey);
|
||||
consumeDec(proto, propertyKey);
|
||||
@@ -124,10 +204,7 @@ export const consumeEntityStates = (config: ConsumeEntryConfig) => {
|
||||
},
|
||||
watch: watchKey ? [watchKey] : [],
|
||||
});
|
||||
const consumeDec = consume<any>({
|
||||
context: statesContext,
|
||||
subscribe: true,
|
||||
});
|
||||
const consumeDec = subscribeContext<HassEntities>(statesContext);
|
||||
transformDec(proto as never, propertyKey);
|
||||
consumeDec(proto as never, propertyKey);
|
||||
};
|
||||
@@ -151,7 +228,7 @@ export const consumeEntityRegistryEntry = (config: ConsumeEntryConfig) =>
|
||||
/**
|
||||
* Consumes `internationalizationContext` and narrows it to the `localize`
|
||||
* function. No host watching is needed — the decorated property updates
|
||||
* whenever the i18n context changes.
|
||||
* whenever `localize` changes.
|
||||
*/
|
||||
export const consumeLocalize = () =>
|
||||
composeDecorator<HomeAssistantInternationalization, LocalizeFunc>(
|
||||
|
||||
@@ -13,8 +13,7 @@ export const isNavigationClick = (e: MouseEvent, preventDefault = true) => {
|
||||
const anchor = e
|
||||
.composedPath()
|
||||
.find((n) => (n as HTMLElement).tagName === "A") as
|
||||
| HTMLAnchorElement
|
||||
| undefined;
|
||||
HTMLAnchorElement | undefined;
|
||||
if (
|
||||
!anchor ||
|
||||
anchor.target ||
|
||||
|
||||
@@ -82,8 +82,7 @@ export const computeAttributeValueToParts = (
|
||||
: formatNumber(attributeValue, locale);
|
||||
|
||||
let unit = DOMAIN_ATTRIBUTES_UNITS[domain]?.[attribute] as
|
||||
| string
|
||||
| undefined;
|
||||
string | undefined;
|
||||
|
||||
if (domain === "weather") {
|
||||
unit = getWeatherUnit(config, stateObj as WeatherEntity, attribute);
|
||||
@@ -156,8 +155,7 @@ export const computeAttributeValueToParts = (
|
||||
const domain = computeDomain(entityId);
|
||||
const deviceClass = stateObj.attributes.device_class;
|
||||
const registryEntry = entities[entityId] as
|
||||
| EntityRegistryDisplayEntry
|
||||
| undefined;
|
||||
EntityRegistryDisplayEntry | undefined;
|
||||
const translationKey = registryEntry?.translation_key;
|
||||
|
||||
const formattedValue =
|
||||
|
||||
@@ -14,8 +14,7 @@ export const computeEntityName = (
|
||||
devices: HomeAssistant["devices"]
|
||||
): string | undefined => {
|
||||
const entry = entities[stateObj.entity_id] as
|
||||
| EntityRegistryDisplayEntry
|
||||
| undefined;
|
||||
EntityRegistryDisplayEntry | undefined;
|
||||
|
||||
if (!entry) {
|
||||
// Fall back to state name if not in the entity registry (friendly name)
|
||||
|
||||
@@ -49,8 +49,7 @@ export const computeStateDisplay = (
|
||||
state?: string
|
||||
): string => {
|
||||
const entity = entities?.[stateObj.entity_id] as
|
||||
| EntityRegistryDisplayEntry
|
||||
| undefined;
|
||||
EntityRegistryDisplayEntry | undefined;
|
||||
return computeStateDisplayFromEntityAttributes(
|
||||
localize,
|
||||
locale,
|
||||
@@ -302,8 +301,7 @@ export const computeStateToParts = (
|
||||
state?: string
|
||||
): ValuePart[] => {
|
||||
const entity = entities?.[stateObj.entity_id] as
|
||||
| EntityRegistryDisplayEntry
|
||||
| undefined;
|
||||
EntityRegistryDisplayEntry | undefined;
|
||||
return computeStateToPartsFromEntityAttributes(
|
||||
localize,
|
||||
locale,
|
||||
|
||||
@@ -24,8 +24,7 @@ export const getEntityContext = (
|
||||
floors: HomeAssistant["floors"]
|
||||
): EntityContext => {
|
||||
const entry = entities[stateObj.entity_id] as
|
||||
| EntityRegistryDisplayEntry
|
||||
| undefined;
|
||||
EntityRegistryDisplayEntry | undefined;
|
||||
|
||||
if (!entry) {
|
||||
return {
|
||||
@@ -52,9 +51,7 @@ export const getEntityAreaId = (
|
||||
|
||||
export const getEntityEntryContext = (
|
||||
entry:
|
||||
| EntityRegistryDisplayEntry
|
||||
| EntityRegistryEntry
|
||||
| ExtEntityRegistryEntry,
|
||||
EntityRegistryDisplayEntry | EntityRegistryEntry | ExtEntityRegistryEntry,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
|
||||
@@ -114,8 +114,7 @@ export const computeLocalize = async <Keys extends string = LocalizeKeys>(
|
||||
|
||||
const messageKey = key + translatedValue;
|
||||
let translatedMessage = cache._localizationCache![messageKey] as
|
||||
| IntlMessageFormat
|
||||
| undefined;
|
||||
IntlMessageFormat | undefined;
|
||||
|
||||
if (!translatedMessage) {
|
||||
try {
|
||||
|
||||
@@ -10,11 +10,7 @@ import {
|
||||
} from "./query-params";
|
||||
|
||||
export type HistoryLogbookTargetParamKey =
|
||||
| "entity_id"
|
||||
| "label_id"
|
||||
| "floor_id"
|
||||
| "area_id"
|
||||
| "device_id";
|
||||
"entity_id" | "label_id" | "floor_id" | "area_id" | "device_id";
|
||||
|
||||
export const historyLogbookTargetParamKeys: readonly HistoryLogbookTargetParamKey[] =
|
||||
["entity_id", "label_id", "floor_id", "area_id", "device_id"];
|
||||
|
||||
@@ -2,9 +2,7 @@ import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { ensureArray } from "../array/ensure-array";
|
||||
|
||||
export type SearchParamsSource =
|
||||
| URLSearchParams
|
||||
| Record<string, string>
|
||||
| string;
|
||||
URLSearchParams | Record<string, string> | string;
|
||||
|
||||
export interface QueryParamConfig {
|
||||
list?: readonly string[];
|
||||
|
||||
@@ -27,7 +27,7 @@ export const deepEqual = (
|
||||
if (length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (i = length; i-- !== 0; ) {
|
||||
for (i = length; i-- !== 0;) {
|
||||
if (!deepEqual(a[i], b[i], options)) {
|
||||
return false;
|
||||
}
|
||||
@@ -71,7 +71,7 @@ export const deepEqual = (
|
||||
if (length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (i = length; i-- !== 0; ) {
|
||||
for (i = length; i-- !== 0;) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
@@ -94,13 +94,13 @@ export const deepEqual = (
|
||||
if (length !== Object.keys(b).length) {
|
||||
return false;
|
||||
}
|
||||
for (i = length; i-- !== 0; ) {
|
||||
for (i = length; i-- !== 0;) {
|
||||
if (!Object.prototype.hasOwnProperty.call(b, keys[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (i = length; i-- !== 0; ) {
|
||||
for (i = length; i-- !== 0;) {
|
||||
const key = keys[i];
|
||||
|
||||
if (!deepEqual(a[key], b[key], options)) {
|
||||
|
||||
@@ -3,14 +3,7 @@ import type { FrontendLocaleData } from "../../data/translation";
|
||||
import { firstWeekdayIndex } from "../datetime/first_weekday";
|
||||
|
||||
export type Unit =
|
||||
| "second"
|
||||
| "minute"
|
||||
| "hour"
|
||||
| "day"
|
||||
| "week"
|
||||
| "month"
|
||||
| "quarter"
|
||||
| "year";
|
||||
"second" | "minute" | "hour" | "day" | "week" | "month" | "quarter" | "year";
|
||||
|
||||
const MS_PER_SECOND = 1e3;
|
||||
const SECS_PER_MIN = 60;
|
||||
|
||||
@@ -18,7 +18,7 @@ export const shallowEqual = (a: any, b: any): boolean => {
|
||||
if (length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (i = length; i-- !== 0; ) {
|
||||
for (i = length; i-- !== 0;) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
@@ -62,7 +62,7 @@ export const shallowEqual = (a: any, b: any): boolean => {
|
||||
if (length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
for (i = length; i-- !== 0; ) {
|
||||
for (i = length; i-- !== 0;) {
|
||||
if (a[i] !== b[i]) {
|
||||
return false;
|
||||
}
|
||||
@@ -85,13 +85,13 @@ export const shallowEqual = (a: any, b: any): boolean => {
|
||||
if (length !== Object.keys(b).length) {
|
||||
return false;
|
||||
}
|
||||
for (i = length; i-- !== 0; ) {
|
||||
for (i = length; i-- !== 0;) {
|
||||
if (!Object.prototype.hasOwnProperty.call(b, keys[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (i = length; i-- !== 0; ) {
|
||||
for (i = length; i-- !== 0;) {
|
||||
const key = keys[i];
|
||||
|
||||
if (a[key] !== b[key]) {
|
||||
|
||||
@@ -56,11 +56,13 @@ export class HaAutomationConditionLiveTest extends LitElement {
|
||||
)}
|
||||
></ha-automation-row-live-test>
|
||||
</div>
|
||||
${this._liveTestResult.message
|
||||
? html`<ha-tooltip for="indicator"
|
||||
>${this._liveTestResult.message}</ha-tooltip
|
||||
>`
|
||||
: nothing}
|
||||
${
|
||||
this._liveTestResult.message
|
||||
? html`<ha-tooltip for="indicator"
|
||||
>${this._liveTestResult.message}</ha-tooltip
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,16 +41,18 @@ export class HaAutomationRow extends LitElement {
|
||||
role="button"
|
||||
@keydown=${this._handleKeydown}
|
||||
>
|
||||
${this.leftChevron
|
||||
? html`
|
||||
<ha-icon-button
|
||||
class="expand-button"
|
||||
.path=${mdiChevronUp}
|
||||
@click=${this._handleExpand}
|
||||
@keydown=${this._handleExpand}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: nothing}
|
||||
${
|
||||
this.leftChevron
|
||||
? html`
|
||||
<ha-icon-button
|
||||
class="expand-button"
|
||||
.path=${mdiChevronUp}
|
||||
@click=${this._handleExpand}
|
||||
@keydown=${this._handleExpand}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
<div class="leading-icon-wrapper">
|
||||
<slot name="leading-icon"></slot>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user