Compare commits

..
755 changed files with 9639 additions and 35494 deletions

No files matched your search

@@ -37,7 +37,6 @@ Container components may keep `hass` when they own it and feed providers. Leaf c
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callApiRaw`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
| `uiContext` | themes, selected theme, panels, sidebar, and UI state |
| `narrowViewportContext` | narrow-layout boolean |
| `reducedMotionContext` | `prefers-reduced-motion` media query |
Lazy contexts subscribe on first consumer and tear down after the last consumer: `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`, `triggerDescriptionsContext`, and `conditionDescriptionsContext`.
-4
View File
@@ -54,10 +54,6 @@ http://localhost:8090/?demo=<second slug>#/energy/water
- `demo/src/stubs/`: Mocked WebSocket and REST APIs.
- `demo/script/develop_demo`, `demo/script/build_demo`: Development server and static build wrappers.
## Core Data
The service descriptions in `src/fake_data/demo_services.ts` and the trigger and condition descriptions and icons in `demo/src/stubs/automation_platforms_*.ts` are generated from Home Assistant Core. Do not edit them by hand. Run `script/gen_demo_core_data` to regenerate them from the core `dev` branch, and add a domain to the lists in `build-scripts/gulp/gen-demo-core-data.js` when the demo needs its services, triggers, or conditions. Their translations come from the backend translations merged into the demo build.
## Shared Gallery Stubs
`demo/src/stubs/` is shared, not demo-private: gallery pages import from it directly. Before changing or removing anything a stub does, search for its callers across `demo/src/` and `gallery/src/`, then check the affected gallery pages as well as the demo.
+1 -1
View File
@@ -58,7 +58,7 @@ Readiness and invalidation:
Repository-owned contracts:
- Consult the public [frontend contributor documentation](https://contributors.home-assistant.io/frontend/) for documented architecture, data flow, design, and development workflows.
- Consult the public [frontend developer documentation](https://developers.home-assistant.io/docs/frontend/) for documented architecture, data flow, design, and development workflows.
- For new leaf components, load `ha-frontend-contexts` and verify they consume narrow contexts instead of introducing a broad `hass` property; containers and external APIs may still require `hass`.
- Verify backend assumptions against the owning Core, Supervisor, or WebSocket implementation, and component assumptions against the exported component contract.
- Prefer canonical repository helpers and test setup over duplicate local implementations.
+1 -1
View File
@@ -105,4 +105,4 @@ To help with the load of incoming pull requests:
Below, some useful links you could explore:
-->
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr
-226
View File
@@ -1,23 +1,3 @@
# Path rules for .github/workflows/labeler.yaml, which runs actions/labeler on
# every pull request. A label is added when a pull request changes a matching
# file, and removed again when it no longer does, including one added by hand.
#
# Do not add these labels here. .github/scripts/label-content.mts applies and
# removes them from paths and code. A rule here would remove them whenever its
# paths don't match, even when the script's code check does:
#
# - Companion App
# - Home Assistant Link
# - Labs
# - Supervisor
Activity:
- changed-files:
- any-glob-to-any-file:
- src/panels/logbook/**
- src/panels/lovelace/**/*logbook*
- src/data/logbook.ts
Agents:
- changed-files:
- any-glob-to-any-file:
@@ -36,36 +16,6 @@ Agents:
- .github/skills/**
- .github/workflows/copilot-setup-steps.yml
AI:
- changed-files:
- any-glob-to-any-file:
- "src/panels/config/core/{ha-config-section-ai,ai-task-pref,mcp-pref}.ts"
- src/components/ha-suggest-with-ai-button.ts
- "src/data/{ai_task,llm}.ts"
Areas:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/areas/**
- src/data/area/**
- "src/data/{area_floor_picker,floor_registry,ws-area_registry,ws-floor_registry}.ts"
Automations:
- changed-files:
- any-glob-to-any-file:
- "src/panels/config/{automation,script,scene,blueprint}/**"
- src/components/automation/**
- src/components/trace/**
- src/data/trace*.ts
- "src/data/{automation,automation_i18n,script,script_i18n,scene,blueprint,trigger,condition,action}.ts"
Backups:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/backup/**
- src/data/backup*.ts
- src/onboarding/onboarding-restore-backup.ts
Build:
- changed-files:
- any-glob-to-any-file:
@@ -73,37 +23,12 @@ Build:
- .browserslistrc
- gulpfile.js
Calendar:
- changed-files:
- any-glob-to-any-file:
- src/panels/calendar/**
- src/panels/lovelace/**/*calendar*
- src/data/calendar.ts
Cast:
- changed-files:
- any-glob-to-any-file:
- cast/src/**
- src/cast/**
Connectivity:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/connectivity/**
- src/panels/config/tags/**
- "src/panels/config/integrations/integration-panels/{bluetooth,infrared,matter,modbus,mqtt,radio_frequency,serial,thread,zha,zwave_js}/**"
- "src/data/{bluetooth,infrared,matter,matter-lock,modbus,mqtt,otbr,radio_frequency,tag,thread,usb,zha,zwave_js,zwave_js-credentials}.ts"
Dashboards:
- changed-files:
- any-glob-to-any-file:
- src/panels/lovelace/**
- "src/panels/{home,climate,light,security,maintenance,map}/**"
- src/panels/config/lovelace/**
- "src/panels/config/dashboard/{dashboard-card,dialog-new-dashboard,show-dialog-new-dashboard}.ts"
- src/data/lovelace/**
- src/data/lovelace*.ts
Demo:
- changed-files:
- any-glob-to-any-file:
@@ -132,29 +57,6 @@ Dependencies:
- "^renovate/"
- "^dependabot/"
Devices:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/devices/**
- src/data/device/device_registry.ts
- src/data/ws-device_registry.ts
Energy:
- changed-files:
- any-glob-to-any-file:
- src/panels/energy/**
- src/panels/config/energy/**
- src/panels/lovelace/cards/energy/**
- src/panels/lovelace/**/*energy*
- src/data/energy*.ts
Entities:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/entities/**
- src/data/entity/entity_registry*.ts
- src/data/ws-entity_registry_display.ts
GitHub Actions:
- changed-files:
- any-glob-to-any-file:
@@ -162,100 +64,6 @@ GitHub Actions:
- .github/workflows/**
- .github/*.yml
Helpers:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/helpers/**
- src/data/helpers_crud.ts
History:
- changed-files:
- any-glob-to-any-file:
- src/panels/history/**
- src/panels/lovelace/**/*history*
- src/data/history.ts
Integrations:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/integrations/**
- src/panels/config/application_credentials/**
- src/dialogs/config-flow/**
- src/data/application_credential.ts
- src/data/config_entries.ts
- "src/data/{config_flow,data_entry_flow,options_flow,sub_config_flow}.ts"
Labels:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/labels/**
- src/data/label/**
Landing Page:
- changed-files:
- any-glob-to-any-file:
- landing-page/**
Marketplace:
- changed-files:
- any-glob-to-any-file:
- src/panels/marketplace/**
- src/data/marketplace/**
- src/panels/config/integrations/offer-marketplace-uninstall.ts
Media:
- changed-files:
- any-glob-to-any-file:
- src/panels/media-browser/**
- src/components/media-player/**
- src/data/media_source.ts
Notifications:
- changed-files:
- any-glob-to-any-file:
- src/dialogs/notifications/**
- src/data/persistent_notification.ts
Onboarding:
- changed-files:
- any-glob-to-any-file:
- src/onboarding/**
- src/auth/**
- src/data/onboarding.ts
- src/data/auth.ts
People:
- changed-files:
- any-glob-to-any-file:
- "src/panels/config/{person,users}/**"
- "src/data/{person,user,ws-user}.ts"
Profile:
- changed-files:
- any-glob-to-any-file:
- src/panels/profile/**
Search:
- changed-files:
- any-glob-to-any-file:
- src/dialogs/quick-bar/**
- src/state/quick-bar-mixin.ts
- "src/data/{quick_bar,search}.ts"
Sidebar:
- changed-files:
- any-glob-to-any-file:
- src/components/ha-sidebar.ts
- src/dialogs/sidebar/**
System:
- changed-files:
- any-glob-to-any-file:
- "src/panels/config/{core,hardware,info,logs,network,repairs,storage}/**"
- src/panels/config/dashboard/ha-config-updates.ts
- "src/dialogs/{repairs-flow,restart}/**"
- "src/data/{analytics,entity_id_format,error_log,hardware,network,repairs,system_health,system_log,system_log_report,update}.ts"
"Tests: E2E":
- changed-files:
- any-glob-to-any-file:
@@ -285,37 +93,3 @@ System:
- any-glob-to-any-file:
- test/*.ts
- "test/!(e2e)/**"
Theming:
- changed-files:
- any-glob-to-any-file:
- src/resources/theme/**
- src/state/themes-mixin.ts
- src/common/dom/apply_themes_on_element.ts
- "src/data/{theme,ws-themes}.ts"
To-do:
- changed-files:
- any-glob-to-any-file:
- src/panels/todo/**
- src/panels/lovelace/**/*todo*
- src/data/todo.ts
Tools:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/tools/**
Voice:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/voice-assistants/**
- "src/dialogs/{voice-assistant-setup,voice-command-dialog}/**"
- src/components/ha-assist-chat.ts
- "src/data/{assist_pipeline,assist_satellite,conversation,stt,tts,wake_word}.ts"
Zones:
- changed-files:
- any-glob-to-any-file:
- src/panels/config/zone/**
- src/data/zone.ts
@@ -192,8 +192,8 @@ export default async function checkPullRequestStandards({
`\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 [contributor docs](https://contributors.home-assistant.io/start/pull-requests/#fill-out-the-template) ` +
`for more on creating a great pull request.`;
`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 withRetry("comment update", () =>
-56
View File
@@ -1,56 +0,0 @@
/**
* Builds a section of a workflow run's summary: a heading, a table of rows and
* the warnings raised along the way.
*
* Warnings are also logged as annotations, so a script can report problems
* without failing its step.
*/
import type { AnnotationProperties, Core } from "./github-script.d.ts";
interface SummaryOptions {
heading: string;
columns: string[];
// Shown instead of the table when no rows were added
empty: string;
}
export const createSummary = (
core: Core,
{ heading, columns, empty }: SummaryOptions
) => {
const rows: string[][] = [];
const warnings: string[] = [];
return {
addRow: (...cells: string[]) => {
rows.push(cells);
},
warn: (message: string, properties?: AnnotationProperties) => {
core.warning(message, properties);
warnings.push(message);
},
write: async () => {
core.summary.addHeading(heading, 2);
if (rows.length > 0) {
core.summary.addTable([
columns.map((data) => ({ data, header: true })),
...rows,
]);
} else {
core.summary.addRaw(`${empty}\n`);
}
if (warnings.length > 0) {
core.summary
.addHeading("Warnings", 3)
.addRaw(`${warnings.map((warning) => `- ${warning}`).join("\n")}\n`);
}
await core.summary.write();
},
};
};
-324
View File
@@ -1,324 +0,0 @@
#!/usr/bin/env node
/**
* Applies the Companion App, Home Assistant Link, Labs and Supervisor labels
* to pull requests.
*
* These areas are often changed from code outside their folders, which
* actions/labeler can't see, so this script owns them and .github/labeler.yml
* leaves them out.
*
* A label matches when the pull request:
* - changes a file in one of the area's paths, or
* - adds or removes a line that uses the area's API, such as gating a feature
* behind Labs or only showing it on Supervisor installs.
*
* Like actions/labeler, a label is removed once the pull request no longer
* matches, including one added by hand.
*
* Invoked from the `triage` job in .github/workflows/labeler.yaml.
*/
import type {
GitHubScriptArgs,
PullRequestPayload,
} from "./github-script.d.ts";
import { withRetry } from "./github-retry.mts";
import { createSummary } from "./github-summary.mts";
const RULES: { label: string; paths: RegExp[]; pattern: RegExp }[] = [
{
label: "Companion App",
paths: [
/^src\/external_app\//,
/^src\/data\/external\.ts$/,
/^src\/util\/is_ios\.ts$/,
],
// Reads or messages the companion app the frontend is running in, or
// imports its modules. Matches imports of data/external rather than
// isExternal, which is also a common local name.
pattern:
/\bauth\??\.external\b(?:[!?]*\.\w+)*(?:\([^)]*)?|\bfireExternalBusMessage\([^)]*|(?:\bimport\s[^;]*?from\s*|\bimport\(\s*)["'][^"']*\/(?:external_app\/[^"']*|data\/external)["']/g,
},
{
label: "Home Assistant Link",
paths: [
/^src\/panels\/config\/cloud\//,
/^src\/data\/cloud\//,
/^src\/data\/cloud\.ts$/,
],
// Only runs when Home Assistant Link is set up
pattern: /isComponentLoaded\([^)]*["']cloud["']/g,
},
{
label: "Labs",
paths: [/^src\/panels\/config\/labs\//, /^src\/data\/labs\.ts$/],
// Gates a feature behind, or toggles, a Labs preview feature
pattern:
/\b(?:subscribeLabFeatures?|fetchLabFeatures|labsUpdatePreviewFeature)\([^)]*/g,
},
{
label: "Supervisor",
paths: [
/^src\/panels\/config\/apps\//,
/^src\/panels\/app\//,
/^src\/data\/hassio\//,
/^src\/data\/supervisor\//,
],
// Only runs on installs with the Supervisor
pattern: /isComponentLoaded\([^)]*["']hassio["']/g,
},
];
interface HunkSide {
lines: string[];
changed: boolean[];
}
interface CodeChange {
code: string;
filename: string;
}
const parseHunks = (patch: string) => {
const hunks: { before: HunkSide; after: HunkSide }[] = [];
for (const line of patch.split("\n")) {
if (line.startsWith("@@")) {
hunks.push({
before: { lines: [], changed: [] },
after: { lines: [], changed: [] },
});
continue;
}
const hunk = hunks.at(-1);
if (!hunk || line.startsWith("\\")) {
continue;
}
const text = line.slice(1);
if (!line.startsWith("+")) {
hunk.before.lines.push(text);
hunk.before.changed.push(line.startsWith("-"));
}
if (!line.startsWith("-")) {
hunk.after.lines.push(text);
hunk.after.changed.push(line.startsWith("+"));
}
}
return hunks;
};
// Ignores whitespace and trailing commas outside strings, as Prettier changes
// them when wrapping
const normaliseCode = (code: string) => {
const parts = code.split(
/("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`)/
);
return parts
.map((part, i) => {
if (i % 2 === 1) {
return part;
}
const stripped = part.replace(/\s+/g, "").replace(/,(?=[}\])])/g, "");
return i === parts.length - 1 ? stripped.replace(/,$/, "") : stripped;
})
.join("");
};
// Matches whole calls, which can span lines, that touch a changed line
const changedMatches = (side: HunkSide, pattern: RegExp) => {
const text = side.lines.join("\n");
const matches: string[] = [];
for (const match of text.matchAll(pattern)) {
const first = text.slice(0, match.index).split("\n").length - 1;
const last = first + match[0].split("\n").length - 1;
if (side.changed.slice(first, last + 1).some(Boolean)) {
matches.push(normaliseCode(match[0]));
}
}
return matches;
};
// Matches that were only moved, reindented or reformatted cancel out in pairs,
// returning a file with a match that doesn't
const unmatchedChange = (added: CodeChange[], removed: CodeChange[]) => {
const remaining = [...removed];
for (const change of added) {
const index = remaining.findIndex(({ code }) => code === change.code);
if (index === -1) {
return change.filename;
}
remaining.splice(index, 1);
}
return remaining[0]?.filename;
};
const describeError = (cause: unknown) =>
cause instanceof Error ? cause.message : String(cause);
const isNotFound = (cause: unknown) =>
cause instanceof Error && "status" in cause && cause.status === 404;
export default async function labelContent({
github,
context,
core,
}: GitHubScriptArgs<PullRequestPayload>) {
const pr = context.payload.pull_request;
const summary = createSummary(core, {
heading: "Content labels",
columns: ["Label", "Change", "Reason"],
empty: "No content labels changed.",
});
if (process.env.LABELER_OUTCOME === "failure") {
summary.warn(
"Applying labels from .github/labeler.yml failed, see its step"
);
}
// Bot pull requests, such as Prettier bumps, rewrite code they don't change
const checkCode = pr.user.type !== "Bot";
const { owner, repo } = context.repo;
let files;
let existing: Set<string>;
try {
// Read the current labels, as the event's snapshot can be stale by now
const [fileList, labels] = await Promise.all([
withRetry("pull request files", () =>
github.paginate(github.rest.pulls.listFiles, {
owner,
repo,
pull_number: pr.number,
per_page: 100,
})
),
withRetry("pull request labels", () =>
github.paginate(github.rest.issues.listLabelsOnIssue, {
owner,
repo,
issue_number: pr.number,
per_page: 100,
})
),
]);
files = fileList;
existing = new Set(labels.map((l) => l.name));
} catch (error) {
summary.warn(
`Could not read the pull request's files and labels: ${describeError(error)}`
);
await summary.write();
return;
}
const add: { label: string; reason: string }[] = [];
const remove: string[] = [];
for (const rule of RULES) {
const added: CodeChange[] = [];
const removed: CodeChange[] = [];
if (checkCode) {
for (const { filename, patch } of files) {
for (const { before, after } of parseHunks(patch ?? "")) {
for (const code of changedMatches(after, rule.pattern)) {
added.push({ code, filename });
}
for (const code of changedMatches(before, rule.pattern)) {
removed.push({ code, filename });
}
}
}
}
// Renames count for both locations, as moving a file out of an area changes it
const match =
files.find(({ filename, previous_filename }) =>
rule.paths.some(
(path) =>
path.test(filename) ||
(previous_filename !== undefined && path.test(previous_filename))
)
)?.filename ?? unmatchedChange(added, removed);
if (match && !existing.has(rule.label)) {
core.info(`Adding ${rule.label} for ${match}`);
add.push({ label: rule.label, reason: match });
} else if (!match && existing.has(rule.label)) {
core.info(`Removing ${rule.label}, no longer matched`);
remove.push(rule.label);
}
}
const removals = await Promise.allSettled(
remove.map((name) =>
withRetry("label removal", () =>
github.rest.issues.removeLabel({
owner,
repo,
issue_number: pr.number,
name,
})
)
)
);
removals.forEach((removal, i) => {
const name = remove[i];
// Already removed, such as by hand or a run that raced this one
if (removal.status === "fulfilled" || isNotFound(removal.reason)) {
summary.addRow(name, "Removed", "No longer matched");
} else {
summary.warn(
`Could not remove ${name}: ${describeError(removal.reason)}`
);
}
});
if (add.length > 0) {
try {
await withRetry("label addition", () =>
github.rest.issues.addLabels({
owner,
repo,
issue_number: pr.number,
labels: add.map(({ label }) => label),
})
);
for (const { label, reason } of add) {
summary.addRow(label, "Added", reason);
}
} catch (error) {
summary.warn(
`Could not add ${add.map(({ label }) => label).join(", ")}: ${describeError(error)}`
);
}
}
await summary.write();
}
+2 -2
View File
@@ -134,7 +134,7 @@ jobs:
is-test: true
rspack-cache: readonly
- name: Upload bundle stats
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-bundle-stats
path: build/stats/*.json
@@ -142,7 +142,7 @@ jobs:
- name: Check entrypoint bundle size budget
run: pnpm run check-bundlesize
- name: Upload frontend build
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-build
path: hass_frontend/
+17 -17
View File
@@ -38,7 +38,7 @@ jobs:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.63.0-noble
options: --user 1001
defaults:
run:
@@ -78,7 +78,7 @@ jobs:
is-test: true
- name: Upload demo build
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: demo-dist
path: demo/dist/
@@ -109,7 +109,7 @@ jobs:
is-test: true
- name: Upload e2e test app build
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
@@ -140,7 +140,7 @@ jobs:
is-test: true
- name: Upload gallery build
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gallery-dist
path: gallery/dist/
@@ -155,7 +155,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.63.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -182,7 +182,7 @@ jobs:
node-modules-cache-key: node-modules-container-v1
- name: Download demo build
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: demo-dist
path: demo/dist/
@@ -192,7 +192,7 @@ jobs:
timeout-minutes: 15
- name: Upload demo blob report
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-demo-${{ matrix.shardIndex }}
@@ -207,7 +207,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.63.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -236,7 +236,7 @@ jobs:
node-modules-cache-key: node-modules-container-v1
- name: Download e2e test app build
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
@@ -246,7 +246,7 @@ jobs:
timeout-minutes: 15
- name: Upload app blob report
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-app-${{ matrix.shardIndex }}
@@ -261,7 +261,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.63.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -290,7 +290,7 @@ jobs:
node-modules-cache-key: node-modules-container-v1
- name: Download gallery build
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: gallery-dist
path: gallery/dist/
@@ -300,7 +300,7 @@ jobs:
timeout-minutes: 15
- name: Upload gallery blob report
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-gallery-${{ matrix.shardIndex }}
@@ -331,21 +331,21 @@ jobs:
node-modules-cache: true
- name: Download demo blob reports
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
pattern: blob-report-demo-*
path: test/e2e/reports/demo/
- name: Download app blob reports
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
pattern: blob-report-app-*
path: test/e2e/reports/app/
- name: Download gallery blob reports
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
pattern: blob-report-gallery-*
@@ -359,7 +359,7 @@ jobs:
- name: Upload merged HTML report
id: upload-report
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: test/e2e/reports/combined/
+1 -24
View File
@@ -1,11 +1,6 @@
name: "Pull Request Labeler"
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: checks out base repo scripts only and reads PR files through the API, never PR head code
# Runs one at a time per pull request, so an older run can't undo a newer one
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: only runs actions/labeler, no PR code checkout
jobs:
triage:
@@ -15,24 +10,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Apply labels
id: labeler
continue-on-error: true
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
with:
sync-labels: true
- name: Check out workflow scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Apply content labels
continue-on-error: true
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
LABELER_OUTCOME: ${{ steps.labeler.outcome }}
with:
script: |
const { default: labelContent } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/label-content.mts`
);
await labelContent({ github, context, core });
+2 -2
View File
@@ -104,7 +104,7 @@ jobs:
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Upload build artifacts
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: wheels
path: dist/home_assistant_frontend*.whl
@@ -137,7 +137,7 @@ jobs:
run: tar -czvf translations.tar.gz translations
- name: Upload translations
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: translations
path: translations.tar.gz
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60 # v3.2.4
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
with:
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
token: ${{ github.token }}
@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60 # v3.2.4
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
with:
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
token: ${{ github.token }}
+1 -1
View File
@@ -19,6 +19,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@72967cdc98ddd3160f1fc3dff619de365e137dd0 # v7.9.0
- uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@cd6f3635a740c84d8a14f6121a90068b8ece6bf6 # master
uses: home-assistant/actions/helpers/verify-version@06749dd8c0b54f350bc69c8752456cee498808a3 # master
- name: Setup Node and install
uses: ./.github/actions/setup
+2 -3
View File
@@ -15,12 +15,11 @@ jobs:
if: github.repository == 'home-assistant/frontend'
runs-on: ubuntu-latest
steps:
- name: Stale policy
- name: 90 days stale policy
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-issue-stale: 90
days-before-pr-stale: 60
days-before-stale: 90
days-before-close: 7
operations-per-run: 25
remove-stale-when-updated: true
+1 -1
View File
@@ -1 +1 @@
24.22.0
24.21.0
+1 -1
View File
@@ -41,5 +41,5 @@ Contributions that do not follow this policy will be closed. Repeated violations
---
The canonical version of this policy is published at
<https://contributors.home-assistant.io/start/ai-policy/>. In case of differences,
<https://developers.home-assistant.io/docs/ai_policy>. In case of differences,
the published version applies.
+2 -2
View File
@@ -6,12 +6,12 @@ This is the repository for the official [Home Assistant](https://home-assistant.
- [View demo of Home Assistant](https://demo.home-assistant.io/)
- [More information about Home Assistant](https://home-assistant.io)
- [Frontend development instructions](https://contributors.home-assistant.io/frontend/development/)
- [Frontend development instructions](https://developers.home-assistant.io/docs/frontend/development/)
## Development
- Initial setup: `script/setup`
- Development: [Instructions](https://contributors.home-assistant.io/frontend/development/)
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
- Production build: `pnpm build`
- Gallery: `cd gallery && script/develop_gallery`
+8 -29
View File
@@ -5,7 +5,6 @@ import mapStream from "map-stream";
import transform from "gulp-json-transform";
import { LokaliseApi } from "@lokalise/node-api";
import JSZip from "jszip";
import { SERVICE_DOMAINS } from "./gen-demo-core-data.js";
const inDir = "translations";
const inDirFrontend = `${inDir}/frontend`;
@@ -52,34 +51,15 @@ function convertBackendTranslations(data, _file) {
if (!data.component) {
return output;
}
// Keep the entity domains and the domains with automation triggers or
// conditions, so the demo can show states and describe automations. Only
// keep the services of the domains whose services the demo mocks.
Object.keys(data.component).forEach((domain) => {
const {
entity_component,
services,
triggers,
conditions,
selector,
title,
} = data.component[domain];
if (
!entity_component &&
!triggers &&
!conditions &&
!SERVICE_DOMAINS.includes(domain)
) {
if (!("entity_component" in data.component[domain])) {
return;
}
output.component[domain] = {
entity_component,
services: SERVICE_DOMAINS.includes(domain) ? services : undefined,
triggers,
conditions,
selector,
title,
};
output.component[domain] = { entity_component: {} };
Object.keys(data.component[domain].entity_component).forEach((key) => {
output.component[domain].entity_component[key] =
data.component[domain].entity_component[key];
});
});
return output;
}
@@ -175,9 +155,8 @@ gulp.task("fetch-lokalise", async function () {
fs.mkdir(inDirBackend, { recursive: true }),
]);
// The backend project only provides the translations the mocked backend
// needs (see convertBackendTranslations), which are merged into the demo,
// gallery, cast and e2e builds. The shipped app fetches
// The backend project only provides entity_component translations, which are
// merged into the demo, gallery, cast and e2e builds. The shipped app fetches
// them live from core, so builds that only produce the app (release, release
// landing-page) can skip this second, whole-project export to save time.
const projects = Object.entries(lokaliseProjects).filter(
+4
View File
@@ -77,6 +77,10 @@ async function copyMapPanel(staticDir) {
// Style, glyphs and sprites for the vector base map
await ensureMapAssets();
fs.copySync(mapAssetsDir, staticPath("map/"));
copyFileDir(
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
staticPath("map/")
);
// Controls and popups of the native MapLibre engine
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
}
-422
View File
@@ -1,422 +0,0 @@
// Generates the service, trigger and condition descriptions the demo and
// gallery serve instead of a backend, from the files in Home Assistant Core.
import gulp from "gulp";
import { CORE_SCHEMA, load, mergeTag } from "js-yaml";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import process from "node:process";
import prettier from "prettier";
import paths from "../paths.cjs";
const CORE_URL =
process.env.CORE_COMPONENTS_URL ||
"https://raw.githubusercontent.com/home-assistant/core/refs/heads/dev/homeassistant/components";
// Integrations whose services the mocked backend provides
export const SERVICE_DOMAINS = [
"alarm_control_panel",
"automation",
"button",
"calendar",
"camera",
"climate",
"conversation",
"counter",
"cover",
"device_tracker",
"fan",
"frontend",
"group",
"homeassistant",
"humidifier",
"image_processing",
"input_boolean",
"input_button",
"input_datetime",
"input_number",
"input_select",
"input_text",
"lawn_mower",
"light",
"lock",
"logger",
"media_player",
"notify",
"number",
"persistent_notification",
"person",
"recorder",
"remote",
"scene",
"schedule",
"script",
"select",
"siren",
"switch",
"system_log",
"text",
"timer",
"todo",
"tts",
"update",
"vacuum",
"valve",
"water_heater",
"weather",
"zone",
];
// Services that return data. Core sets this when it registers a service, it is
// not part of services.yaml. The value is whether the response is optional.
const RESPONSE_SERVICES = {
"calendar.get_events": false,
"conversation.process": true,
"media_player.browse_media": false,
"media_player.search_media": false,
"recorder.get_statistics": false,
"schedule.get_schedule": false,
"todo.get_items": false,
"weather.get_forecasts": false,
};
// Services that are still in services.yaml, but that core no longer registers
const UNREGISTERED_SERVICES = ["weather.get_forecast"];
// Integrations that provide the new style triggers and conditions
const AUTOMATION_PLATFORM_DOMAINS = [
"air_quality",
"alarm_control_panel",
"assist_satellite",
"battery",
"button",
"calendar",
"climate",
"counter",
"cover",
"door",
"doorbell",
"event",
"fan",
"garage_door",
"gate",
"humidifier",
"humidity",
"illuminance",
"lawn_mower",
"light",
"lock",
"media_player",
"moisture",
"moon",
"motion",
"occupancy",
"power",
"remote",
"scene",
"schedule",
"select",
"siren",
"sun",
"switch",
"temperature",
"text",
"timer",
"todo",
"update",
"vacuum",
"valve",
"vibration",
"water_heater",
"window",
"zone",
];
// YAML 1.2 with merge keys, which matches how core reads these files
const YAML_SCHEMA = CORE_SCHEMA.withTags([mergeTag]);
const HEADER = `// This file is auto-generated from Home Assistant Core. Do not edit by hand.
// Regenerate with \`script/gen_demo_core_data\`.`;
const fetchCoreFile = async (domain, file) => {
const url = `${CORE_URL}/${domain}/${file}`;
const response = await fetch(url);
if (response.status === 404) {
return undefined;
}
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: ${response.status}`);
}
return response.text();
};
// Fetches a file of every domain, keyed by domain
const fetchCoreFiles = async (domains, file) =>
Object.fromEntries(
await Promise.all(
domains.map(async (domain) => [domain, await fetchCoreFile(domain, file)])
)
);
const parseYaml = (content) => load(content, { schema: YAML_SCHEMA }) ?? {};
// Enum references look like "cover.CoverEntityFeature.OPEN"
const ENUM_REFERENCE = /^([a-z_]+)\.[A-Z]\w*\.[A-Z0-9_]+$/;
const enumDomains = (value, domains = new Set()) => {
if (typeof value === "string") {
const match = value.match(ENUM_REFERENCE);
if (match) {
domains.add(match[1]);
}
} else if (value && typeof value === "object") {
Object.values(value).forEach((val) => enumDomains(val, domains));
}
return domains;
};
// Resolves "<domain>.<Enum>.<MEMBER>" to the value of the member, like core
const enumResolver = async (yamls) => {
const domains = [...enumDomains(yamls)];
const [consts, inits] = await Promise.all([
fetchCoreFiles(domains, "const.py"),
fetchCoreFiles(domains, "__init__.py"),
]);
return (reference) => {
const [domain, enumName, member] = reference.split(".");
for (const source of [consts[domain], inits[domain]]) {
const match = source
?.split(`class ${enumName}(`)[1]
?.match(new RegExp(`\\n\\s+${member} = ("[^"]*"|\\d+)`));
if (match) {
return JSON.parse(match[1]);
}
}
throw new Error(`Unknown enum ${reference}`);
};
};
const createResolvers = (enumValue) => {
// In entity filters, every item of the supported features, a feature or a
// list of features, becomes one bit mask
const featureMask = (features) =>
[].concat(features).reduce(
// eslint-disable-next-line no-bitwise
(mask, feature) => mask | enumValue(feature),
0
);
const resolveSelector = (value) => {
if (Array.isArray(value)) {
return value.map(resolveSelector);
}
if (!value || typeof value !== "object") {
return value;
}
return Object.fromEntries(
Object.entries(value).map(([key, val]) => [
key,
key === "supported_features"
? [].concat(val).map(featureMask)
: resolveSelector(val),
])
);
};
// Like core, an empty target allows any target
const resolveTarget = (target) => resolveSelector(target ?? {});
// Field filters keep their lists, with every enum resolved on its own
const resolveField = (field) => {
const result = { ...field };
if (field.selector) {
result.selector = resolveSelector(field.selector);
}
if (field.filter?.supported_features) {
result.filter = {
...result.filter,
supported_features: field.filter.supported_features.map(enumValue),
};
}
if (field.filter?.attribute) {
result.filter = {
...result.filter,
attribute: Object.fromEntries(
Object.entries(field.filter.attribute).map(([name, options]) => [
name,
options.map(enumValue),
])
),
};
}
return result;
};
// Fields can be grouped in sections, which hold fields themselves
const resolveFields = (fields) =>
Object.fromEntries(
Object.entries(fields ?? {}).map(([key, field]) => [
key,
field && "fields" in field && !("selector" in field)
? { ...field, fields: resolveFields(field.fields) }
: resolveField(field ?? {}),
])
);
return { resolveFields, resolveTarget };
};
// Mirrors how core builds the response of get_services
const serviceDescriptions = (yamls, { resolveFields, resolveTarget }) =>
Object.fromEntries(
SERVICE_DOMAINS.map((domain) => {
if (!yamls[domain]) {
throw new Error(`No services.yaml found for ${domain}`);
}
const services = Object.entries(yamls[domain])
.filter(
([service]) =>
!service.startsWith(".") &&
!UNREGISTERED_SERVICES.includes(`${domain}.${service}`)
)
.map(([service, value]) => {
const description = { fields: resolveFields(value?.fields) };
for (const item of ["description", "name"]) {
if (value && item in value) {
description[item] = value[item];
}
}
if (value && "target" in value) {
description.target = resolveTarget(value.target);
}
const key = `${domain}.${service}`;
if (key in RESPONSE_SERVICES) {
description.response = { optional: RESPONSE_SERVICES[key] };
}
return [service, description];
});
return [domain, Object.fromEntries(services)];
})
);
// Mirrors how core builds the trigger and condition platform descriptions
const platformDescriptions = (yamls, { resolveFields, resolveTarget }) =>
Object.fromEntries(
AUTOMATION_PLATFORM_DOMAINS.flatMap((domain) =>
Object.entries(yamls[domain] ?? {})
// Keys starting with a dot only hold YAML anchors
.filter(([key, value]) => !key.startsWith(".") && value)
.map(([key, value]) => {
const absoluteKey = !key.startsWith("_")
? `${domain}.${key}`
: key.slice(1) || domain;
const description = { fields: resolveFields(value.fields) };
if ("target" in value) {
description.target = resolveTarget(value.target);
}
return [absoluteKey, description];
})
)
);
// The trigger or condition icons of every domain that has them
const platformIcons = (icons, category) =>
Object.fromEntries(
Object.entries(icons)
.map(([domain, content]) => [
domain,
content ? JSON.parse(content)[category] : undefined,
])
.filter(([, categoryIcons]) => categoryIcons)
);
const parseYamls = (files) =>
Object.fromEntries(
Object.entries(files)
.filter(([, content]) => content !== undefined)
.map(([domain, content]) => [domain, parseYaml(content)])
);
const writeTs = async (path, content) => {
const file = join(paths.root_dir, path);
const options = await prettier.resolveConfig(file);
await writeFile(
file,
await prettier.format(`${HEADER}\n${content}`, {
...options,
filepath: file,
})
);
};
gulp.task("gen-demo-core-data", async () => {
const [servicesFiles, triggersFiles, conditionsFiles, iconsFiles] =
await Promise.all([
fetchCoreFiles(SERVICE_DOMAINS, "services.yaml"),
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "triggers.yaml"),
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "conditions.yaml"),
fetchCoreFiles(AUTOMATION_PLATFORM_DOMAINS, "icons.json"),
]);
const servicesYamls = parseYamls(servicesFiles);
const triggersYamls = parseYamls(triggersFiles);
const conditionsYamls = parseYamls(conditionsFiles);
const resolvers = createResolvers(
await enumResolver([servicesYamls, triggersYamls, conditionsYamls])
);
for (const key of UNREGISTERED_SERVICES) {
const [domain, service] = key.split(".");
if (!(service in (servicesYamls[domain] ?? {}))) {
throw new Error(`Remove ${key} from UNREGISTERED_SERVICES, it is gone`);
}
}
const services = serviceDescriptions(servicesYamls, resolvers);
for (const key of Object.keys(RESPONSE_SERVICES)) {
const [domain, service] = key.split(".");
if (!services[domain]?.[service]) {
throw new Error(`Service ${key} that returns a response does not exist`);
}
}
const triggers = platformDescriptions(triggersYamls, resolvers);
const conditions = platformDescriptions(conditionsYamls, resolvers);
const icons = {
triggers: platformIcons(iconsFiles, "triggers"),
conditions: platformIcons(iconsFiles, "conditions"),
};
await Promise.all([
writeTs(
"src/fake_data/demo_services.ts",
`import type { HassServices } from "home-assistant-js-websocket";
// Core sends more than the HassServices type covers, like sections and list
// examples.
export const demoServices = ${JSON.stringify(services)} as unknown as HassServices;
`
),
writeTs(
"demo/src/stubs/automation_platform_domains.ts",
`
// Integrations that provide the new style triggers and conditions. Their
// translations are part of the demo build.
export const automationPlatformDomains = ${JSON.stringify(AUTOMATION_PLATFORM_DOMAINS)};
`
),
writeTs(
"demo/src/stubs/automation_platforms_data.ts",
`import type { ConditionDescriptions } from "../../../src/data/condition";
import type { TriggerDescriptions } from "../../../src/data/trigger";
export const triggerDescriptions: TriggerDescriptions = ${JSON.stringify(triggers)};
export const conditionDescriptions: ConditionDescriptions = ${JSON.stringify(conditions)};
`
),
writeTs(
"demo/src/stubs/automation_platforms_icons.ts",
`
export const triggerIcons = ${JSON.stringify(icons.triggers)};
export const conditionIcons = ${JSON.stringify(icons.conditions)};
`
),
]);
});
-1
View File
@@ -9,7 +9,6 @@ import "./entry-html.js";
import "./fetch-nightly-translations.mts";
import "./gallery.js";
import "./gather-static.js";
import "./gen-demo-core-data.js";
import "./gen-device-classes.js";
import "./gen-icons-json.js";
import "./gen-sensor-entity-constants.js";
+1 -4
View File
@@ -115,10 +115,7 @@ const createRspackConfig = ({
dontHash = new Set();
}
const ignorePackages = bundle.ignorePackages({ latestBuild });
// pnpm links node_modules/lit-html into its store; rspack compares real paths
const litHtmlRoot = fs.realpathSync(
path.resolve(__dirname, "../node_modules/lit-html")
);
const litHtmlRoot = path.resolve(__dirname, "../node_modules/lit-html");
const litHtmlDevelopmentRoot = path.join(litHtmlRoot, "development");
const litDisableDevModeLoader = path.join(
__dirname,
+1 -1
View File
@@ -4,7 +4,7 @@ import { castContext } from "../cast_context";
export const castDemoLovelace: () => LovelaceConfig = () => {
const touchSupported =
castContext.getDeviceCapabilities()?.touch_input_supported;
castContext.getDeviceCapabilities().touch_input_supported;
return {
views: [
{
+2 -2
View File
@@ -19,7 +19,7 @@ const playerStylesAdded = false;
let controls: HTMLElement | null;
const setTouchControlsVisibility = (visible: boolean) => {
if (!castContext.getDeviceCapabilities()?.touch_input_supported) {
if (!castContext.getDeviceCapabilities().touch_input_supported) {
return;
}
controls =
@@ -51,7 +51,7 @@ const playDummyMedia = (viewTitle?: string) => {
clearTimeout(timeOut);
timeOut = undefined;
}
if (castContext.getDeviceCapabilities()?.touch_input_supported) {
if (castContext.getDeviceCapabilities().touch_input_supported) {
timeOut = window.setTimeout(() => playDummyMedia(viewTitle), 540000); // repeat every 9 minutes to keep it active (gets deactivated after 10 minutes)
}
};
+2 -2
View File
@@ -27,7 +27,6 @@ import type {
LovelaceConfig,
LovelaceDashboardStrategyConfig,
} from "../../../../src/data/lovelace/config/types";
import { normalizeLovelaceConfig } from "../../../../src/data/lovelace/config/normalize";
import { isStrategyDashboard } from "../../../../src/data/lovelace/config/types";
import { fetchResources } from "../../../../src/data/lovelace/resource";
import { loadLovelaceResources } from "../../../../src/panels/lovelace/common/load-resources";
@@ -35,6 +34,7 @@ import { HassElement } from "../../../../src/state/hass-element";
import { castContext } from "../cast_context";
import "./hc-launch-screen";
import { getPanelTitleFromUrlPath } from "../../../../src/data/panel";
import { checkLovelaceConfig } from "../../../../src/panels/lovelace/common/check-lovelace-config";
const DEFAULT_CONFIG: LovelaceDashboardStrategyConfig = {
strategy: {
@@ -359,7 +359,7 @@ export class HcMain extends HassElement {
this._urlPath || "lovelace"
);
castContext.setApplicationState(title || "");
this._lovelaceConfig = normalizeLovelaceConfig(
this._lovelaceConfig = checkLovelaceConfig(
lovelaceConfig
) as LovelaceConfig;
}
+2 -96
View File
@@ -1,16 +1,12 @@
import { navigate } from "../../../src/common/navigate";
import { slugify } from "../../../src/common/string/slugify";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import type { Lovelace } from "../../../src/panels/lovelace/types";
import { setDemoAreas } from "../stubs/area_registry";
import { setPlatformEntityRegistryEntries } from "../stubs/entity_registry";
import { energyEntities } from "../stubs/entities";
import { connectivityEntities } from "../stubs/connectivity/fixtures";
import { setDemoFloors } from "../stubs/floor_registry";
import { getDemoTheme } from "../stubs/frontend";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { DemoAutomation, DemoConfig, DemoTheme } from "./types";
import type { DemoConfig, DemoTheme } from "./types";
export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
if (typeof theme === "function") {
@@ -20,95 +16,6 @@ export const applyDemoTheme = (hass: MockHomeAssistant, theme: DemoTheme) => {
hass.mockTheme(null, getDemoTheme(theme));
};
export const automationEntity = ({
config: automation,
entityId,
state = "on",
icon,
lastTriggered,
}: DemoAutomation): EntityInput => ({
entity_id: entityId ?? `automation.${slugify(automation.alias)}`,
platform: "automation",
state,
attributes: {
id: automation.id,
friendly_name: automation.alias,
last_triggered:
lastTriggered === undefined
? null
: new Date(Date.now() - lastTriggered * 60000).toISOString(),
mode: automation.mode ?? "single",
current: 0,
// Like core, only the modes that can run more than once have a maximum
...((automation.mode === "queued" || automation.mode === "parallel") && {
max: automation.max ?? 10,
}),
icon,
},
});
// Automations saved in the editor during this session, per demo
// Automations saved in the editor during this session, per demo. A deleted
// automation is null.
const savedAutomations: Record<
string,
Record<string, DemoAutomation | null>
> = {};
export const saveDemoAutomation = (
id: string,
automation: DemoAutomation | null
) => {
savedAutomations[selectedDemo] = {
...savedAutomations[selectedDemo],
[id]: automation,
};
};
// The automations of a demo config, with the ones saved in the editor
export const demoAutomations = (
demo: string,
config: DemoConfig
): DemoAutomation[] => {
const saved = savedAutomations[demo] ?? {};
const configured = config.automations ?? [];
const ids = new Set(configured.map((automation) => automation.config.id));
return [
...configured.map((automation) =>
automation.config.id in saved ? saved[automation.config.id] : automation
),
...Object.values(saved).filter(
(automation) => automation && !ids.has(automation.config.id)
),
].filter((automation): automation is DemoAutomation => automation !== null);
};
// The automation entities are derived from the configs the editor shows, so
// their names and IDs always match.
export const demoConfigEntities = (
demo: string,
config: DemoConfig,
localize: LocalizeFunc
): EntityInput[] => [
...config.entities(localize),
...demoAutomations(demo, config).map(automationEntity),
];
// Like core, the automations of a demo config are in the entity registry
export const registerDemoAutomations = (
hass: MockHomeAssistant,
demo: string,
config: DemoConfig
) =>
setPlatformEntityRegistryEntries(
hass,
"automation",
demoAutomations(demo, config).map((automation) => ({
entity_id: automationEntity(automation).entity_id,
unique_id: automation.config.id,
}))
);
export const demoConfigs: Record<string, () => Promise<DemoConfig>> = {
sections: () => import("./sections").then((mod) => mod.demoSections),
home: () => import("./home").then((mod) => mod.demoHome),
@@ -145,8 +52,7 @@ export const setDemoConfig = async (
setDemoFloors(hass, config.floors);
setDemoAreas(hass, config.areas);
hass.addEntities(demoConfigEntities(demo, config, hass.localize), true);
registerDemoAutomations(hass, demo, config);
hass.addEntities(config.entities(hass.localize), true);
hass.addEntities(energyEntities());
// Replaced the whole state map above, so the entities that do not belong to a
// demo config have to be added back.
-361
View File
@@ -1,361 +0,0 @@
import type { DemoAutomation } from "../types";
export const demoAutomationsSections: DemoAutomation[] = [
{
lastTriggered: 60 * 9,
state: "off",
icon: "mdi:auto-mode",
config: {
id: "1700669321947",
alias: "Home Assistant Auto-update",
description:
"Installs Home Assistant updates at night, so nobody is bothered by the restart.",
triggers: [{ trigger: "time", at: "03:00:00" }],
conditions: [
{
condition: "update.is_available",
target: { entity_id: "update.home_assistant_core_update" },
options: { for: "00:00:00" },
},
],
actions: [
{
action: "update.install",
target: { entity_id: "update.home_assistant_core_update" },
data: { backup: true },
},
],
mode: "queued",
},
},
{
lastTriggered: 25,
config: {
id: "1700669322001",
alias: "Kitchen lights on motion",
description:
"Turns on the kitchen lights when motion is detected and turns them off again once the kitchen has been empty for 2 minutes.",
triggers: [
{
trigger: "motion.detected",
target: { entity_id: "binary_sensor.kitchen_motion" },
options: { for: "00:00:00" },
},
],
conditions: [],
actions: [
{
action: "light.turn_on",
target: {
entity_id: ["light.kitchen_spotlights", "light.worktop_spotlights"],
},
data: { brightness_pct: 80 },
},
{
wait_for_trigger: [
{
trigger: "motion.cleared",
target: { entity_id: "binary_sensor.kitchen_motion" },
options: { for: "00:02:00" },
},
],
},
{
action: "light.turn_off",
target: {
entity_id: ["light.kitchen_spotlights", "light.worktop_spotlights"],
},
},
],
mode: "restart",
},
},
{
lastTriggered: 60 * 3,
config: {
id: "1700669322002",
alias: "Outdoor light from sunset to sunrise",
description:
"Uses trigger IDs to handle both sunset and sunrise in one automation.",
triggers: [
{
trigger: "sun.sunset",
id: "sunset",
options: {
offset: { days: 0, hours: 0, minutes: 15, seconds: 0 },
offset_type: "before",
},
},
{
trigger: "sun.sunrise",
id: "sunrise",
options: {
offset: { days: 0, hours: 0, minutes: 0, seconds: 0 },
offset_type: "before",
},
},
],
conditions: [],
actions: [
{
choose: [
{
conditions: [{ condition: "trigger", id: "sunset" }],
sequence: [
{
action: "light.turn_on",
target: { entity_id: "light.outdoor_light" },
},
],
},
{
conditions: [{ condition: "trigger", id: "sunrise" }],
sequence: [
{
action: "light.turn_off",
target: { entity_id: "light.outdoor_light" },
},
],
},
],
},
],
mode: "single",
},
},
{
lastTriggered: 60 * 14,
config: {
id: "1700669322003",
alias: "Flood light on motion when dark",
description:
"Turns on the flood light for 5 minutes when someone walks by in the dark.",
triggers: [
{
trigger: "motion.detected",
target: { entity_id: "binary_sensor.outdoor_motion_sensor_motion" },
options: { for: "00:00:00" },
},
],
conditions: [
{
condition: "illuminance.is_value",
target: { entity_id: "sensor.outdoor_motion_sensor_illuminance" },
options: {
threshold: {
type: "below",
value: { number: 50, unit_of_measurement: "lx" },
},
for: "00:00:00",
},
},
],
actions: [
{
action: "light.turn_on",
target: { entity_id: "light.flood_light" },
},
{ delay: { hours: 0, minutes: 5, seconds: 0, milliseconds: 0 } },
{
action: "light.turn_off",
target: { entity_id: "light.flood_light" },
},
],
mode: "restart",
},
},
{
lastTriggered: 60 * 26,
config: {
id: "1700669322004",
alias: "Close the shutters when it gets hot",
description:
"Keeps the living room cool by closing the shutters on hot, sunny days.",
triggers: [
{
trigger: "temperature.crossed_threshold",
target: { entity_id: "sensor.outdoor_temperature" },
options: {
threshold: {
type: "above",
value: { number: 25, unit_of_measurement: "°C" },
},
for: "00:10:00",
},
},
],
conditions: [{ condition: "sun.is_up" }],
actions: [
{
action: "cover.close_cover",
target: {
entity_id: [
"cover.living_room_garden_shutter",
"cover.living_room_graveyard_shutter",
"cover.living_room_left_shutter",
"cover.living_room_right_shutter",
],
},
},
],
mode: "single",
},
},
{
lastTriggered: 60 * 50,
config: {
id: "1700669322005",
alias: "Fridge door left open",
description:
"Sends a reminder when the fridge door has been open for 2 minutes.",
triggers: [
{
trigger: "door.opened",
target: { entity_id: "binary_sensor.fridge_door" },
options: { for: "00:02:00" },
},
],
conditions: [],
actions: [
{
action: "notify.notify",
data: {
title: "Fridge door",
message: "The fridge door has been open for 2 minutes.",
},
},
],
mode: "single",
},
},
{
lastTriggered: 90,
config: {
id: "1700669322006",
alias: "Meeting mode",
description:
"Pauses the music in the study and turns on the lights when a meeting starts.",
triggers: [
{
trigger: "switch.turned_on",
target: { entity_id: "switch.in_meeting" },
options: { for: "00:00:00" },
},
],
conditions: [],
actions: [
{
action: "media_player.media_pause",
target: { entity_id: "media_player.study_nest_hub" },
},
{
action: "light.turn_on",
target: { entity_id: "light.study_spotlights" },
data: { brightness_pct: 100, color_temp_kelvin: 4000 },
},
],
mode: "single",
},
},
{
lastTriggered: 60 * 20,
config: {
id: "1700669322009",
alias: "Lights up when the music pauses",
description:
"Turns on the living room spotlights when the music is paused in the evening.",
triggers: [
{
trigger: "media_player.paused_playing",
target: { entity_id: "media_player.living_room_nest_mini" },
options: { for: "00:00:00" },
},
],
conditions: [{ condition: "sun.is_set" }],
actions: [
{
action: "light.turn_on",
target: { entity_id: "light.living_room_spotlights" },
data: { brightness_pct: 40 },
},
],
mode: "single",
},
},
{
lastTriggered: 60 * 7,
config: {
id: "1700669322007",
alias: "Lower heating when the car leaves",
triggers: [
{
trigger: "zone.left",
target: { entity_id: "device_tracker.car" },
options: { zone: "zone.home", for: "00:00:00" },
},
],
conditions: [
{
condition: "time",
weekday: ["mon", "tue", "wed", "thu", "fri"],
},
],
actions: [
{
action: "climate.set_temperature",
target: {
entity_id: ["climate.ground_floor", "climate.first_floor"],
},
data: { temperature: 17 },
},
],
mode: "single",
},
},
{
lastTriggered: 60 * 11,
config: {
id: "1700669322008",
alias: "Good night",
description:
"Turns off all lights and closes the shutters at 23:00 on weeknights.",
triggers: [
{
trigger: "time",
at: "23:00:00",
weekday: ["sun", "mon", "tue", "wed", "thu"],
},
],
conditions: [],
actions: [
{
action: "light.turn_off",
target: {
entity_id: [
"light.floor_lamp",
"light.living_room_spotlights",
"light.bar_lamp",
"light.kitchen_spotlights",
"light.worktop_spotlights",
"light.study_spotlights",
],
},
},
{
action: "cover.close_cover",
target: {
entity_id: [
"cover.living_room_garden_shutter",
"cover.living_room_graveyard_shutter",
"cover.living_room_left_shutter",
"cover.living_room_right_shutter",
"cover.kitchen_shutter",
"cover.study_shutter",
],
},
},
],
mode: "single",
},
},
];
+13 -12
View File
@@ -140,18 +140,6 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
friendly_name: "Outdoor humidity",
},
},
// Used by the automation for when the car leaves home
"zone.home": {
entity_id: "zone.home",
state: "0",
attributes: {
latitude: 52.3731339,
longitude: 4.8903147,
radius: 100,
friendly_name: "Home",
icon: "mdi:home",
},
},
"device_tracker.car": {
entity_id: "device_tracker.car",
state: "not_home",
@@ -526,6 +514,19 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
friendly_name: "Outdoor motion sensor Illuminance",
},
},
"automation.home_assistant_auto_update": {
entity_id: "automation.home_assistant_auto_update",
state: "off",
attributes: {
id: "1700669321947",
last_triggered: "2024-02-29T18:02:05.343139+00:00",
mode: "queued",
current: 0,
max: 50,
icon: "mdi:auto-mode",
friendly_name: "Home Assistant Auto-update",
},
},
"update.home_assistant_operating_system_update": {
entity_id: "update.home_assistant_operating_system_update",
state: "off",
-2
View File
@@ -1,5 +1,4 @@
import type { DemoConfig } from "../types";
import { demoAutomationsSections } from "./automations";
import { demoEntitiesSections } from "./entities";
import { demoLovelaceSections } from "./lovelace";
@@ -9,6 +8,5 @@ export const demoSections: DemoConfig = {
name: "Home Demo",
lovelace: demoLovelaceSections,
entities: demoEntitiesSections,
automations: demoAutomationsSections,
theme: { theme: "default", dark: false },
};
-12
View File
@@ -1,6 +1,5 @@
import type { TemplateResult } from "lit";
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { ManualAutomationConfig } from "../../../src/data/automation";
import type { LovelaceRawConfig } from "../../../src/data/lovelace/config/types";
import type { EntityInput } from "../../../src/fake_data/entities/types";
import type { ThemeSettings } from "../../../src/types";
@@ -9,16 +8,6 @@ import type { DemoFloor } from "../stubs/floor_registry";
export type DemoTheme = ThemeSettings | (() => Record<string, string> | null);
export interface DemoAutomation {
config: ManualAutomationConfig & { id: string; alias: string };
/** Defaults to the alias as slug */
entityId?: string;
state?: "on" | "off";
icon?: string;
/** Minutes ago */
lastTriggered?: number;
}
export interface DemoConfig {
index?: number;
name: string;
@@ -28,7 +17,6 @@ export interface DemoConfig {
string | ((localize: LocalizeFunc) => string | TemplateResult<1>);
lovelace: (localize: LocalizeFunc) => LovelaceRawConfig;
entities: (localize: LocalizeFunc) => EntityInput[];
automations?: DemoAutomation[];
floors?: DemoFloor[];
areas?: DemoArea[];
theme: DemoTheme;
+15 -106
View File
@@ -1,18 +1,11 @@
import { customElement } from "lit/decorators";
import type { LocalizeFunc } from "../../src/common/translations/localize";
import { isNavigationClick } from "../../src/common/dom/is-navigation-click";
import { navigate } from "../../src/common/navigate";
import type { MockHomeAssistant } from "../../src/fake_data/provide_hass";
import { provideHass } from "../../src/fake_data/provide_hass";
import { HomeAssistantAppEl } from "../../src/layouts/home-assistant";
import type { HomeAssistant } from "../../src/types";
import {
applyDemoTheme,
demoConfigEntities,
registerDemoAutomations,
selectedDemo,
selectedDemoConfig,
} from "./configs/demo-configs";
import { applyDemoTheme, selectedDemoConfig } from "./configs/demo-configs";
import { mockAreaRegistry, setDemoAreas } from "./stubs/area_registry";
import {
connectivityCommands,
@@ -25,10 +18,7 @@ import { demoDevices } from "./stubs/devices";
import { mockDeviceRegistry } from "./stubs/device_registry";
import { mockEnergy } from "./stubs/energy";
import { energyEntities } from "./stubs/entities";
import {
mockEntityRegistry,
mockEntityRegistryDisplay,
} from "./stubs/entity_registry";
import { mockEntityRegistry } from "./stubs/entity_registry";
import { mockEvents } from "./stubs/events";
import { mockFloorRegistry, setDemoFloors } from "./stubs/floor_registry";
import { mockFrontend } from "./stubs/frontend";
@@ -36,7 +26,6 @@ import { mockHardware } from "./stubs/hardware";
import { mockHassioSupervisor } from "./stubs/hassio_supervisor";
import { mockIntegration } from "./stubs/integration";
import { mockLabelRegistry } from "./stubs/label_registry";
import { automationPlatformDomains } from "./stubs/automation_platform_domains";
import { mockIcons } from "./stubs/icons";
import { mockHistory } from "./stubs/history";
import { mockLovelace } from "./stubs/lovelace";
@@ -69,12 +58,6 @@ const CONFIG_PANEL_COMMANDS = [
"system_health/",
"backup/",
"automation/config",
"trigger_platforms/subscribe",
"condition_platforms/subscribe",
"get_triggers_for_target",
"get_conditions_for_target",
"get_services_for_target",
"extract_from_target",
"script/config",
"config/automation/config",
"config/script/config",
@@ -106,19 +89,15 @@ export class HaDemo extends HomeAssistantAppEl {
config: {
...hass.config,
components: [
// Some automation platforms are loaded already
...new Set([
...(hass.config?.components ?? []),
"backup",
"webhook",
"usage_prediction",
"assist_pipeline",
"hassio",
"hardware",
"marketplace",
...connectivityComponents,
...automationPlatformDomains,
]),
...(hass.config?.components ?? []),
"backup",
"webhook",
"usage_prediction",
"assist_pipeline",
"hassio",
"hardware",
"marketplace",
...connectivityComponents,
],
},
panels: {
@@ -137,50 +116,10 @@ export class HaDemo extends HomeAssistantAppEl {
const localizePromise =
// @ts-ignore
this._loadFragmentTranslations(hass.language, "page-demo").then(
// hass is not updated yet when another fragment started loading
// meanwhile, so use the localize with these translations directly
(localize?: LocalizeFunc) => localize ?? this.hass!.localize
() => this.hass!.localize
);
mockLovelace(hass, localizePromise, [
{
id: "music",
url_path: "music",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:music",
title: "Music",
mode: "storage",
},
{
id: "cameras",
url_path: "cameras",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:cctv",
title: "Cameras",
mode: "storage",
},
{
id: "garden",
url_path: "garden",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:flower",
title: "Garden",
mode: "storage",
},
{
id: "lovelace-advanced",
url_path: "lovelace-advanced",
require_admin: false,
show_in_sidebar: false,
icon: "mdi:cog",
title: "Advanced",
mode: "yaml",
filename: "lovelace-advanced.yaml",
},
]);
mockLovelace(hass, localizePromise);
mockAuth(hass);
mockTranslations(hass, localizePromise);
mockHistory(hass);
@@ -210,37 +149,8 @@ export class HaDemo extends HomeAssistantAppEl {
mockAreaRegistry(hass);
mockDeviceRegistry(hass, demoDevices);
mockFloorRegistry(hass);
mockLabelRegistry(hass, [
{
label_id: "kitchen",
name: "Kitchen",
icon: null,
color: "red",
description: "Everything in the kitchen",
created_at: 1735689600,
modified_at: 1735689600,
},
{
label_id: "garden",
name: "Garden",
icon: "mdi:flower",
color: "green",
description: null,
created_at: 1735689600,
modified_at: 1735689600,
},
{
label_id: "battery-powered",
name: "Battery powered",
icon: null,
color: "orange",
description: "Runs on batteries",
created_at: 1735689600,
modified_at: 1735689600,
},
]);
mockLabelRegistry(hass);
mockUsagePrediction(hass);
mockEntityRegistryDisplay(hass);
mockEntityRegistry(hass, [
{
config_entry_id: "co2signal",
@@ -296,8 +206,7 @@ export class HaDemo extends HomeAssistantAppEl {
([conf, localize]) => {
setDemoFloors(hass, conf.floors);
setDemoAreas(hass, conf.areas);
hass.addEntities(demoConfigEntities(selectedDemo, conf, localize));
registerDemoAutomations(hass, selectedDemo, conf);
hass.addEntities(conf.entities(localize));
applyDemoTheme(hass, conf.theme);
}
);
-3
View File
@@ -39,9 +39,6 @@
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
/* Prevent iOS Safari from inflating text when rotating to landscape */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
@media (prefers-color-scheme: dark) {
html {
+56 -142
View File
@@ -1,155 +1,69 @@
import type { AutomationConfig } from "../../../src/data/automation";
import type { ScriptConfig } from "../../../src/data/script";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { computeDomain } from "../../../src/common/entity/compute_domain";
import { slugify } from "../../../src/common/string/slugify";
import {
automationEntity,
demoAutomations,
saveDemoAutomation,
selectedDemo,
selectedDemoConfig,
} from "../configs/demo-configs";
import type { DemoAutomation } from "../configs/types";
import { mockAutomationPlatforms } from "./automation_platforms";
import {
addEntityRegistryEntry,
getEntityRegistryEntry,
removeEntityRegistryEntry,
} from "./entity_registry";
const automationConfig = async (
id: string | undefined
): Promise<AutomationConfig> => {
const automation = demoAutomations(
selectedDemo,
await selectedDemoConfig
).find((a) => a.config.id === id);
if (!automation) {
throw new Error("Automation not found");
}
return automation.config;
};
const demoAutomationConfig = (entityId: string): AutomationConfig => ({
id: entityId.split(".")[1],
alias: "Demo automation",
description: "An example automation shown in the demo.",
triggers: [
{ trigger: "state", entity_id: "binary_sensor.basement_floor_wet" },
],
conditions: [],
actions: [
{
action: "light.turn_on",
target: { entity_id: "light.bed_light" },
},
],
mode: "single",
});
const scriptConfig = (
currentHass: MockHomeAssistant,
entityId: string
): ScriptConfig => {
const alias =
currentHass.states[entityId]?.attributes.friendly_name ?? entityId;
return {
alias,
sequence: [
{
action: "persistent_notification.create",
data: { message: `${alias} ran` },
},
],
mode: "single",
};
};
const demoScriptConfig = (): ScriptConfig => ({
alias: "Demo script",
description: "An example script shown in the demo.",
sequence: [
{
action: "light.turn_on",
target: { entity_id: "light.bed_light" },
},
],
mode: "single",
});
export const mockAutomation = (hass: MockHomeAssistant) => {
hass.mockWS(
"automation/config",
async (msg: { entity_id: string }, currentHass: MockHomeAssistant) => ({
config: await automationConfig(
currentHass.states[msg.entity_id]?.attributes.id
),
})
hass.mockWS("automation/config", (msg: { entity_id: string }) => ({
config: demoAutomationConfig(msg.entity_id),
}));
hass.mockWS("script/config", () => ({ config: demoScriptConfig() }));
hass.mockAPI(/config\/automation\/config\/.+/, () =>
demoAutomationConfig("automation.demo")
);
hass.mockAPI(
/config\/automation\/config\/.+/,
(currentHass, method, path, parameters) => {
const id = decodeURIComponent(path.split("/").pop()!);
const existing = Object.values(currentHass.states).find(
(stateObj) =>
computeDomain(stateObj.entity_id) === "automation" &&
stateObj.attributes.id === id
);
if (method === "DELETE") {
// Like core, remove the automation with its entity
saveDemoAutomation(id, null);
if (existing) {
const { [existing.entity_id]: _state, ...states } =
currentHass.states;
const { [existing.entity_id]: _entity, ...entities } =
currentHass.entities;
currentHass.updateHass({ states, entities });
// Otherwise a service call to it would add its state again
delete currentHass.mockEntities[existing.entity_id];
removeEntityRegistryEntry(currentHass, existing.entity_id);
}
return { result: "ok" };
}
if (method === "POST") {
// Core stores the ID in the config
const config = {
...(parameters as DemoAutomation["config"]),
id,
};
config.alias ||= id;
// Like core, update the automation entity, or add it for a new one
// A new automation gets a free entity ID, like core
const slug = `automation.${slugify(config.alias)}`;
let entityId = existing?.entity_id ?? slug;
for (let i = 2; !existing && entityId in currentHass.states; i++) {
entityId = `${slug}_${i}`;
}
const lastTriggered = existing?.attributes.last_triggered;
const automation: DemoAutomation = {
config,
entityId,
state: existing?.state as DemoAutomation["state"],
icon: existing?.attributes.icon,
lastTriggered: lastTriggered
? (Date.now() - Date.parse(lastTriggered)) / 60000
: undefined,
};
// Kept per demo, so it is still there after switching demos
saveDemoAutomation(id, automation);
// A display entry of a new automation is left over from another demo
const display = existing ? currentHass.entities[entityId] : undefined;
currentHass.addEntities(automationEntity(automation));
// Adding the entity resets its display entry, keep what the registry
// holds
if (display) {
const registryName = getEntityRegistryEntry(entityId)?.name;
currentHass.entities[entityId] = {
...currentHass.entities[entityId],
...(registryName && { name: registryName }),
area_id: display.area_id,
labels: display.labels,
icon: display.icon,
hidden: display.hidden,
};
}
if (!existing) {
addEntityRegistryEntry(currentHass, {
entity_id: entityId,
platform: "automation",
unique_id: id,
});
}
return { result: "ok" };
}
return automationConfig(id);
hass.mockAPI(/config\/script\/config\/.+/, () => demoScriptConfig());
// Trigger/condition type pickers subscribe for integration-provided
// platforms. The demo only uses the built-in ones, so emit empty records.
hass.mockWS(
"trigger_platforms/subscribe",
(
_msg,
_hass,
onChange?: (descriptions: Record<string, unknown>) => void
) => {
onChange?.({});
return () => undefined;
}
);
// Scripts do not have demo configs yet, show the script under its own name.
hass.mockWS(
"script/config",
(msg: { entity_id: string }, currentHass: MockHomeAssistant) => ({
config: scriptConfig(currentHass, msg.entity_id),
})
"condition_platforms/subscribe",
(
_msg,
_hass,
onChange?: (descriptions: Record<string, unknown>) => void
) => {
onChange?.({});
return () => undefined;
}
);
hass.mockAPI(/config\/script\/config\/.+/, (currentHass, _method, path) =>
scriptConfig(
currentHass,
`script.${decodeURIComponent(path.split("/").pop()!)}`
)
);
mockAutomationPlatforms(hass);
};
@@ -1,52 +0,0 @@
// This file is auto-generated from Home Assistant Core. Do not edit by hand.
// Regenerate with `script/gen_demo_core_data`.
// Integrations that provide the new style triggers and conditions. Their
// translations are part of the demo build.
export const automationPlatformDomains = [
"air_quality",
"alarm_control_panel",
"assist_satellite",
"battery",
"button",
"calendar",
"climate",
"counter",
"cover",
"door",
"doorbell",
"event",
"fan",
"garage_door",
"gate",
"humidifier",
"humidity",
"illuminance",
"lawn_mower",
"light",
"lock",
"media_player",
"moisture",
"moon",
"motion",
"occupancy",
"power",
"remote",
"scene",
"schedule",
"select",
"siren",
"sun",
"switch",
"temperature",
"text",
"timer",
"todo",
"update",
"vacuum",
"valve",
"vibration",
"water_heater",
"window",
"zone",
];
-270
View File
@@ -1,270 +0,0 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { ensureArray } from "../../../src/common/array/ensure-array";
import { computeDomain } from "../../../src/common/entity/compute_domain";
import type { ConditionDescriptions } from "../../../src/data/condition";
import type { ExtractFromTargetResult } from "../../../src/data/target";
import type { TriggerDescriptions } from "../../../src/data/trigger";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { getLabelIds } from "./label_registry";
import {
conditionDescriptions,
triggerDescriptions,
} from "./automation_platforms_data";
type Descriptions = TriggerDescriptions | ConditionDescriptions;
interface EntityFilter {
integration?: string;
domain?: string | string[];
device_class?: string | string[];
supported_features?: number[];
}
// Expands a target to its areas, devices and entities, like core does
const expandTarget = (
hass: MockHomeAssistant,
target: HassServiceTarget,
primaryEntitiesOnly = true
) => {
const missing = {
floors: [] as string[],
areas: [] as string[],
devices: [] as string[],
labels: [] as string[],
};
const knownLabelIds = getLabelIds();
const floorIds = ensureArray(target.floor_id ?? []);
const labelIds = ensureArray(target.label_id ?? []);
missing.floors.push(...floorIds.filter((id) => !(id in hass.floors)));
// Like core, unknown labels are reported, but still expanded
missing.labels.push(...labelIds.filter((id) => !knownLabelIds.includes(id)));
const hasLabel = (labels?: string[]) =>
labelIds.some((labelId) => labels?.includes(labelId));
// Unknown areas and devices are reported, but still referenced
const areaIds = new Set(ensureArray(target.area_id ?? []));
missing.areas.push(...[...areaIds].filter((id) => !(id in hass.areas)));
for (const area of Object.values(hass.areas)) {
if (
(area.floor_id && floorIds.includes(area.floor_id)) ||
hasLabel(area.labels)
) {
areaIds.add(area.area_id);
}
}
// A targeted or labeled device includes its child devices
const deviceIds = new Set<string>();
const addDevice = (deviceId: string) => {
deviceIds.add(deviceId);
for (const device of Object.values(hass.devices)) {
if (device.parent_device_id === deviceId) {
deviceIds.add(device.id);
}
}
};
for (const deviceId of ensureArray(target.device_id ?? [])) {
if (!(deviceId in hass.devices)) {
missing.devices.push(deviceId);
}
addDevice(deviceId);
}
for (const device of Object.values(hass.devices)) {
if (hasLabel(device.labels)) {
addDevice(device.id);
}
}
// A child device without its own area is in the area of its parent device
const deviceArea = (deviceId: string) => {
const device = hass.devices[deviceId];
return (
device?.area_id ??
(device?.parent_device_id
? hass.devices[device.parent_device_id]?.area_id
: undefined)
);
};
const areaDeviceIds = new Set<string>();
for (const device of Object.values(hass.devices)) {
const areaId = deviceArea(device.id);
if (areaId && areaIds.has(areaId)) {
areaDeviceIds.add(device.id);
}
}
const entityIds = new Set(ensureArray(target.entity_id ?? []));
for (const entity of Object.values(hass.entities)) {
// The display registry keeps the entities of a previous demo, and like
// core, hidden entities are never included indirectly
if (!(entity.entity_id in hass.states) || entity.hidden) {
continue;
}
const primary = !primaryEntitiesOnly || !entity.entity_category;
if (
hasLabel(entity.labels) ||
(primary &&
((entity.device_id && deviceIds.has(entity.device_id)) ||
(entity.area_id && areaIds.has(entity.area_id)) ||
// An entity with its own area is not in the area of its device
(!entity.area_id &&
entity.device_id &&
areaDeviceIds.has(entity.device_id))))
) {
entityIds.add(entity.entity_id);
}
}
return {
areas: [...areaIds],
devices: [...new Set([...deviceIds, ...areaDeviceIds])],
entities: [...entityIds],
missing,
};
};
const matchesFilter = (
hass: MockHomeAssistant,
entityId: string,
filter: EntityFilter
) =>
(!filter.integration ||
hass.entities[entityId]?.platform === filter.integration) &&
(!filter.domain ||
ensureArray(filter.domain).includes(computeDomain(entityId))) &&
(!filter.device_class ||
ensureArray(filter.device_class).includes(
hass.states[entityId]?.attributes.device_class ?? ""
)) &&
// Like core, the entity needs all the features of one of the feature sets
(!filter.supported_features ||
filter.supported_features.some(
(features) =>
// eslint-disable-next-line no-bitwise
((hass.states[entityId]?.attributes.supported_features ?? 0) &
features) ===
features
));
type TargetDescriptions = Record<
string,
{
target?: {
entity?: EntityFilter | EntityFilter[];
primary_entities_only?: boolean;
} | null;
}
>;
// Like core, the components with a target that apply to at least one entity
// of the target. A target without entity filters applies to any entity.
const componentsForTarget = (
hass: MockHomeAssistant,
target: HassServiceTarget,
descriptions: TargetDescriptions
) => {
const entityIds = expandTarget(hass, target, false).entities;
const targetedIds = ensureArray(target.entity_id ?? []);
return Object.entries(descriptions)
.filter(([, description]) => {
if (!description.target) {
return false;
}
const filters = ensureArray(description.target.entity ?? []);
const primaryEntitiesOnly =
description.target.primary_entities_only ?? true;
return entityIds.some(
(entityId) =>
// Entities with a category only count when targeted directly,
// unless the component allows them
(!primaryEntitiesOnly ||
targetedIds.includes(entityId) ||
!hass.entities[entityId]?.entity_category) &&
(!filters.length ||
filters.some((filter) => matchesFilter(hass, entityId, filter)))
);
})
.map(([key]) => key);
};
const mockForTarget = (
hass: MockHomeAssistant,
type: string,
descriptions: Descriptions
) =>
hass.mockWS(
type,
(msg: { target: HassServiceTarget }, currentHass: MockHomeAssistant) =>
componentsForTarget(
currentHass,
msg.target,
descriptions as TargetDescriptions
)
);
const mockPlatformSubscription = (
hass: MockHomeAssistant,
type: string,
descriptions: Descriptions
) =>
hass.mockWS(
type,
(_msg, _hass, onChange?: (descriptions: Descriptions) => void) => {
onChange?.(descriptions);
return () => undefined;
}
);
export const mockAutomationPlatforms = (hass: MockHomeAssistant) => {
mockPlatformSubscription(
hass,
"trigger_platforms/subscribe",
triggerDescriptions
);
mockPlatformSubscription(
hass,
"condition_platforms/subscribe",
conditionDescriptions
);
mockForTarget(hass, "get_triggers_for_target", triggerDescriptions);
mockForTarget(hass, "get_conditions_for_target", conditionDescriptions);
hass.mockWS(
"extract_from_target",
(
msg: { target: HassServiceTarget; primary_entities_only?: boolean },
currentHass: MockHomeAssistant
): ExtractFromTargetResult => {
const { areas, devices, entities, missing } = expandTarget(
currentHass,
msg.target,
msg.primary_entities_only ?? true
);
return {
missing_areas: missing.areas,
missing_devices: missing.devices,
missing_floors: missing.floors,
missing_labels: missing.labels,
referenced_areas: areas,
referenced_devices: devices,
referenced_entities: entities,
};
}
);
hass.mockWS(
"get_services_for_target",
(msg: { target: HassServiceTarget }, currentHass: MockHomeAssistant) =>
componentsForTarget(
currentHass,
msg.target,
// Keyed by <domain>.<service>, like the triggers and conditions
Object.fromEntries(
Object.entries(currentHass.services).flatMap(([domain, services]) =>
Object.entries(services).map(([service, description]) => [
`${domain}.${service}`,
description,
])
)
) as TargetDescriptions
)
);
};
File diff suppressed because it is too large. Load diff
@@ -1,495 +0,0 @@
// This file is auto-generated from Home Assistant Core. Do not edit by hand.
// Regenerate with `script/gen_demo_core_data`.
export const triggerIcons = {
air_quality: {
co2_changed: { trigger: "mdi:molecule-co2" },
co2_crossed_threshold: { trigger: "mdi:molecule-co2" },
co_changed: { trigger: "mdi:molecule-co" },
co_cleared: { trigger: "mdi:check-circle" },
co_crossed_threshold: { trigger: "mdi:molecule-co" },
co_detected: { trigger: "mdi:molecule-co" },
gas_cleared: { trigger: "mdi:check-circle" },
gas_detected: { trigger: "mdi:gas-cylinder" },
n2o_changed: { trigger: "mdi:factory" },
n2o_crossed_threshold: { trigger: "mdi:factory" },
no2_changed: { trigger: "mdi:factory" },
no2_crossed_threshold: { trigger: "mdi:factory" },
no_changed: { trigger: "mdi:factory" },
no_crossed_threshold: { trigger: "mdi:factory" },
ozone_changed: { trigger: "mdi:weather-sunny-alert" },
ozone_crossed_threshold: { trigger: "mdi:weather-sunny-alert" },
pm10_changed: { trigger: "mdi:blur" },
pm10_crossed_threshold: { trigger: "mdi:blur" },
pm1_changed: { trigger: "mdi:blur" },
pm1_crossed_threshold: { trigger: "mdi:blur" },
pm25_changed: { trigger: "mdi:blur" },
pm25_crossed_threshold: { trigger: "mdi:blur" },
pm4_changed: { trigger: "mdi:blur" },
pm4_crossed_threshold: { trigger: "mdi:blur" },
smoke_cleared: { trigger: "mdi:check-circle" },
smoke_detected: { trigger: "mdi:smoke-detector-variant" },
so2_changed: { trigger: "mdi:factory" },
so2_crossed_threshold: { trigger: "mdi:factory" },
voc_changed: { trigger: "mdi:air-filter" },
voc_crossed_threshold: { trigger: "mdi:air-filter" },
voc_ratio_changed: { trigger: "mdi:air-filter" },
voc_ratio_crossed_threshold: { trigger: "mdi:air-filter" },
},
alarm_control_panel: {
armed: { trigger: "mdi:shield" },
armed_away: { trigger: "mdi:shield-lock" },
armed_home: { trigger: "mdi:shield-home" },
armed_night: { trigger: "mdi:shield-moon" },
armed_vacation: { trigger: "mdi:shield-airplane" },
disarmed: { trigger: "mdi:shield-off" },
triggered: { trigger: "mdi:bell-ring" },
},
assist_satellite: {
idle: { trigger: "mdi:chat-sleep" },
listening: { trigger: "mdi:chat-question" },
processing: { trigger: "mdi:chat-processing" },
responding: { trigger: "mdi:chat-alert" },
},
battery: {
became_low: { trigger: "mdi:battery-alert" },
level_changed: { trigger: "mdi:battery-unknown" },
level_crossed_threshold: { trigger: "mdi:battery-alert" },
no_longer_low: { trigger: "mdi:battery" },
started_charging: { trigger: "mdi:battery-charging" },
stopped_charging: { trigger: "mdi:battery" },
},
button: { pressed: { trigger: "mdi:gesture-tap-button" } },
calendar: {
event_ended: { trigger: "mdi:calendar-end" },
event_started: { trigger: "mdi:calendar-start" },
},
climate: {
hvac_mode_changed: { trigger: "mdi:thermostat" },
started_cooling: { trigger: "mdi:snowflake" },
started_drying: { trigger: "mdi:water-percent" },
started_heating: { trigger: "mdi:fire" },
target_humidity_changed: { trigger: "mdi:water-percent" },
target_humidity_crossed_threshold: { trigger: "mdi:water-percent" },
target_temperature_changed: { trigger: "mdi:thermometer" },
target_temperature_crossed_threshold: { trigger: "mdi:thermometer" },
turned_off: { trigger: "mdi:power-off" },
turned_on: { trigger: "mdi:power-on" },
},
counter: {
decremented: { trigger: "mdi:numeric-negative-1" },
incremented: { trigger: "mdi:numeric-positive-1" },
maximum_reached: { trigger: "mdi:sort-numeric-ascending-variant" },
minimum_reached: { trigger: "mdi:sort-numeric-descending-variant" },
reset: { trigger: "mdi:refresh" },
},
cover: {
awning_closed: { trigger: "mdi:storefront-outline" },
awning_opened: { trigger: "mdi:storefront-outline" },
blind_closed: { trigger: "mdi:blinds-horizontal-closed" },
blind_opened: { trigger: "mdi:blinds-horizontal" },
curtain_closed: { trigger: "mdi:curtains-closed" },
curtain_opened: { trigger: "mdi:curtains" },
shade_closed: { trigger: "mdi:roller-shade-closed" },
shade_opened: { trigger: "mdi:roller-shade" },
shutter_closed: { trigger: "mdi:window-shutter" },
shutter_opened: { trigger: "mdi:window-shutter-open" },
},
door: {
closed: { trigger: "mdi:door-closed" },
opened: { trigger: "mdi:door-open" },
},
doorbell: { rang: { trigger: "mdi:doorbell" } },
event: { received: { trigger: "mdi:eye-check" } },
fan: {
turned_off: { trigger: "mdi:fan-off" },
turned_on: { trigger: "mdi:fan" },
},
garage_door: {
closed: { trigger: "mdi:garage" },
opened: { trigger: "mdi:garage-open" },
},
gate: {
closed: { trigger: "mdi:gate" },
opened: { trigger: "mdi:gate-open" },
},
humidifier: {
mode_changed: { trigger: "mdi:air-humidifier" },
started_drying: { trigger: "mdi:arrow-down-bold" },
started_humidifying: { trigger: "mdi:arrow-up-bold" },
turned_off: { trigger: "mdi:air-humidifier-off" },
turned_on: { trigger: "mdi:air-humidifier" },
},
humidity: {
changed: { trigger: "mdi:water-percent" },
crossed_threshold: { trigger: "mdi:water-percent" },
},
illuminance: {
changed: { trigger: "mdi:brightness-6" },
cleared: { trigger: "mdi:brightness-4" },
crossed_threshold: { trigger: "mdi:brightness-6" },
detected: { trigger: "mdi:brightness-7" },
},
lawn_mower: {
became_idle: { trigger: "mdi:stop" },
errored: { trigger: "mdi:alert-circle-outline" },
paused_mowing: { trigger: "mdi:pause" },
returned_to_dock: { trigger: "mdi:home-import-outline" },
started_mowing: { trigger: "mdi:play" },
started_returning: { trigger: "mdi:home-import-outline" },
},
light: {
brightness_changed: { trigger: "mdi:lightbulb-on-50" },
brightness_crossed_threshold: { trigger: "mdi:lightbulb-on-50" },
turned_off: { trigger: "mdi:lightbulb-off" },
turned_on: { trigger: "mdi:lightbulb-on" },
},
lock: {
jammed: { trigger: "mdi:lock-alert" },
locked: { trigger: "mdi:lock" },
opened: { trigger: "mdi:lock-open-variant" },
unlocked: { trigger: "mdi:lock-open-variant" },
},
media_player: {
muted: { trigger: "mdi:volume-mute" },
paused_playing: { trigger: "mdi:pause" },
started_playing: { trigger: "mdi:play" },
stopped_playing: { trigger: "mdi:stop" },
turned_off: { trigger: "mdi:power" },
turned_on: { trigger: "mdi:power" },
unmuted: { trigger: "mdi:volume-high" },
volume_changed: { trigger: "mdi:volume-medium" },
volume_crossed_threshold: { trigger: "mdi:volume-medium" },
},
moisture: {
changed: { trigger: "mdi:water-percent" },
cleared: { trigger: "mdi:water-off" },
crossed_threshold: { trigger: "mdi:water-percent" },
detected: { trigger: "mdi:water" },
},
moon: { phase_changed: { trigger: "mdi:moon-waning-crescent" } },
motion: {
cleared: { trigger: "mdi:motion-sensor-off" },
detected: { trigger: "mdi:motion-sensor" },
},
occupancy: {
cleared: { trigger: "mdi:home-outline" },
detected: { trigger: "mdi:home-account" },
},
power: {
changed: { trigger: "mdi:flash" },
crossed_threshold: { trigger: "mdi:flash" },
},
remote: {
turned_off: { trigger: "mdi:remote-off" },
turned_on: { trigger: "mdi:remote" },
},
scene: { activated: { trigger: "mdi:palette" } },
schedule: {
block_ended: { trigger: "mdi:calendar-blank" },
block_started: { trigger: "mdi:calendar-clock" },
},
select: { selection_changed: { trigger: "mdi:format-list-bulleted" } },
siren: {
turned_off: { trigger: "mdi:bullhorn-outline" },
turned_on: { trigger: "mdi:bullhorn" },
},
sun: {
blue_hour_ended: { trigger: "mdi:weather-sunset" },
blue_hour_started: { trigger: "mdi:weather-sunset" },
dawn: { trigger: "mdi:weather-sunset-up" },
dusk: { trigger: "mdi:weather-sunset-down" },
elevation_changed: { trigger: "mdi:sun-angle" },
elevation_crossed_threshold: { trigger: "mdi:sun-angle" },
golden_hour_ended: { trigger: "mdi:weather-sunset" },
golden_hour_started: { trigger: "mdi:weather-sunset" },
midnight_sun_ended: { trigger: "mdi:weather-sunset" },
midnight_sun_started: { trigger: "mdi:weather-sunny" },
polar_night_ended: { trigger: "mdi:weather-sunset-up" },
polar_night_started: { trigger: "mdi:weather-night" },
solar_midnight: { trigger: "mdi:weather-night" },
solar_noon: { trigger: "mdi:weather-sunny" },
sunrise: { trigger: "mdi:weather-sunset-up" },
sunset: { trigger: "mdi:weather-sunset-down" },
},
switch: {
turned_off: { trigger: "mdi:toggle-switch-variant-off" },
turned_on: { trigger: "mdi:toggle-switch-variant" },
},
temperature: {
changed: { trigger: "mdi:thermometer" },
crossed_threshold: { trigger: "mdi:thermometer" },
},
text: { changed: { trigger: "mdi:form-textbox" } },
timer: {
cancelled: { trigger: "mdi:timer-cancel" },
finished: { trigger: "mdi:timer-check" },
paused: { trigger: "mdi:timer-pause" },
remaining_time_reached: { trigger: "mdi:timer-alert-outline" },
restarted: { trigger: "mdi:timer-refresh" },
started: { trigger: "mdi:timer-play" },
},
todo: {
item_added: { trigger: "mdi:clipboard-plus" },
item_completed: { trigger: "mdi:clipboard-check" },
item_removed: { trigger: "mdi:clipboard-minus" },
},
update: { became_available: { trigger: "mdi:package-up" } },
vacuum: {
errored: { trigger: "mdi:alert-circle-outline" },
paused_cleaning: { trigger: "mdi:pause" },
returned_to_dock: { trigger: "mdi:home-outline" },
started_cleaning: { trigger: "mdi:play" },
started_returning: { trigger: "mdi:home-import-outline" },
},
valve: {
closed: { trigger: "mdi:valve-closed" },
opened: { trigger: "mdi:valve-open" },
},
vibration: {
cleared: { trigger: "mdi:vibrate-off" },
detected: { trigger: "mdi:vibrate" },
},
water_heater: {
operation_mode_changed: { trigger: "mdi:water-boiler" },
target_temperature_changed: { trigger: "mdi:thermometer" },
target_temperature_crossed_threshold: { trigger: "mdi:thermometer" },
turned_off: { trigger: "mdi:water-boiler-off" },
turned_on: { trigger: "mdi:water-boiler" },
},
window: {
closed: { trigger: "mdi:window-closed" },
opened: { trigger: "mdi:window-open" },
},
zone: {
entered: { trigger: "mdi:map-marker-plus" },
left: { trigger: "mdi:map-marker-minus" },
occupancy_cleared: { trigger: "mdi:account-off" },
occupancy_detected: { trigger: "mdi:account-group" },
},
};
export const conditionIcons = {
air_quality: {
is_co2_value: { condition: "mdi:molecule-co2" },
is_co_cleared: { condition: "mdi:check-circle" },
is_co_detected: { condition: "mdi:molecule-co" },
is_co_value: { condition: "mdi:molecule-co" },
is_gas_cleared: { condition: "mdi:check-circle" },
is_gas_detected: { condition: "mdi:gas-cylinder" },
is_n2o_value: { condition: "mdi:factory" },
is_no2_value: { condition: "mdi:factory" },
is_no_value: { condition: "mdi:factory" },
is_ozone_value: { condition: "mdi:weather-sunny-alert" },
is_pm10_value: { condition: "mdi:blur" },
is_pm1_value: { condition: "mdi:blur" },
is_pm25_value: { condition: "mdi:blur" },
is_pm4_value: { condition: "mdi:blur" },
is_smoke_cleared: { condition: "mdi:check-circle" },
is_smoke_detected: { condition: "mdi:smoke-detector-variant" },
is_so2_value: { condition: "mdi:factory" },
is_voc_ratio_value: { condition: "mdi:air-filter" },
is_voc_value: { condition: "mdi:air-filter" },
},
alarm_control_panel: {
is_armed: { condition: "mdi:shield" },
is_armed_away: { condition: "mdi:shield-lock" },
is_armed_home: { condition: "mdi:shield-home" },
is_armed_night: { condition: "mdi:shield-moon" },
is_armed_vacation: { condition: "mdi:shield-airplane" },
is_disarmed: { condition: "mdi:shield-off" },
is_triggered: { condition: "mdi:bell-ring" },
},
assist_satellite: {
is_idle: { condition: "mdi:chat-sleep" },
is_listening: { condition: "mdi:chat-question" },
is_processing: { condition: "mdi:chat-processing" },
is_responding: { condition: "mdi:chat-alert" },
},
battery: {
is_charging: { condition: "mdi:battery-charging" },
is_level: { condition: "mdi:battery-unknown" },
is_low: { condition: "mdi:battery-alert" },
is_not_charging: { condition: "mdi:battery" },
is_not_low: { condition: "mdi:battery" },
},
calendar: { is_event_active: { condition: "mdi:calendar-check" } },
climate: {
is_cooling: { condition: "mdi:snowflake" },
is_drying: { condition: "mdi:water-percent" },
is_heating: { condition: "mdi:fire" },
is_hvac_mode: { condition: "mdi:thermostat" },
is_off: { condition: "mdi:power-off" },
is_on: { condition: "mdi:power-on" },
is_target_humidity: { condition: "mdi:water-percent" },
is_target_temperature: { condition: "mdi:thermometer" },
},
counter: { is_value: { condition: "mdi:counter" } },
cover: {
awning_is_closed: { condition: "mdi:storefront-outline" },
awning_is_open: { condition: "mdi:storefront-outline" },
blind_is_closed: { condition: "mdi:blinds-horizontal-closed" },
blind_is_open: { condition: "mdi:blinds-horizontal" },
curtain_is_closed: { condition: "mdi:curtains-closed" },
curtain_is_open: { condition: "mdi:curtains" },
shade_is_closed: { condition: "mdi:roller-shade-closed" },
shade_is_open: { condition: "mdi:roller-shade" },
shutter_is_closed: { condition: "mdi:window-shutter" },
shutter_is_open: { condition: "mdi:window-shutter-open" },
},
door: {
is_closed: { condition: "mdi:door-closed" },
is_open: { condition: "mdi:door-open" },
},
fan: {
is_off: { condition: "mdi:fan-off" },
is_on: { condition: "mdi:fan" },
},
garage_door: {
is_closed: { condition: "mdi:garage" },
is_open: { condition: "mdi:garage-open" },
},
gate: {
is_closed: { condition: "mdi:gate" },
is_open: { condition: "mdi:gate-open" },
},
humidifier: {
is_drying: { condition: "mdi:arrow-down-bold" },
is_humidifying: { condition: "mdi:arrow-up-bold" },
is_mode: { condition: "mdi:air-humidifier" },
is_off: { condition: "mdi:air-humidifier-off" },
is_on: { condition: "mdi:air-humidifier" },
is_target_humidity: { condition: "mdi:water-percent" },
},
humidity: { is_value: { condition: "mdi:water-percent" } },
illuminance: {
is_detected: { condition: "mdi:brightness-7" },
is_not_detected: { condition: "mdi:brightness-4" },
is_value: { condition: "mdi:brightness-percent" },
},
lawn_mower: {
is_docked: { condition: "mdi:home-import-outline" },
is_encountering_an_error: { condition: "mdi:alert-circle-outline" },
is_idle: { condition: "mdi:stop" },
is_mowing: { condition: "mdi:play" },
is_paused: { condition: "mdi:pause" },
is_returning: { condition: "mdi:home-import-outline" },
},
light: {
is_brightness: { condition: "mdi:lightbulb-on-50" },
is_off: { condition: "mdi:lightbulb-off" },
is_on: { condition: "mdi:lightbulb-on" },
},
lock: {
is_jammed: { condition: "mdi:lock-alert" },
is_locked: { condition: "mdi:lock" },
is_open: { condition: "mdi:lock-open-variant" },
is_unlocked: { condition: "mdi:lock-open-variant" },
},
media_player: {
is_muted: { condition: "mdi:volume-mute" },
is_not_playing: { condition: "mdi:stop" },
is_off: { condition: "mdi:cast-off" },
is_on: { condition: "mdi:cast" },
is_paused: { condition: "mdi:pause" },
is_playing: { condition: "mdi:play" },
is_unmuted: { condition: "mdi:volume-high" },
is_volume: { condition: "mdi:volume-medium" },
},
moisture: {
is_detected: { condition: "mdi:water" },
is_not_detected: { condition: "mdi:water-off" },
is_value: { condition: "mdi:water-percent" },
},
moon: {
is_phase: { condition: "mdi:moon-full" },
is_waning: { condition: "mdi:moon-waning-crescent" },
is_waxing: { condition: "mdi:moon-waxing-crescent" },
},
motion: {
is_detected: { condition: "mdi:motion-sensor" },
is_not_detected: { condition: "mdi:motion-sensor-off" },
},
occupancy: {
is_detected: { condition: "mdi:home-account" },
is_not_detected: { condition: "mdi:home-outline" },
},
power: { is_value: { condition: "mdi:flash" } },
remote: {
is_off: { condition: "mdi:remote-off" },
is_on: { condition: "mdi:remote" },
},
schedule: {
is_off: { condition: "mdi:calendar-blank" },
is_on: { condition: "mdi:calendar-clock" },
},
select: { is_option_selected: { condition: "mdi:format-list-bulleted" } },
siren: {
is_off: { condition: "mdi:bullhorn-outline" },
is_on: { condition: "mdi:bullhorn" },
},
sun: {
elevation: { condition: "mdi:sun-angle" },
is_ascending: { condition: "mdi:weather-sunset-up" },
is_blue_hour: { condition: "mdi:weather-sunset" },
is_descending: { condition: "mdi:weather-sunset-down" },
is_evening_twilight: { condition: "mdi:weather-sunset-down" },
is_golden_hour: { condition: "mdi:weather-sunset" },
is_midnight_sun: { condition: "mdi:weather-sunny" },
is_morning_twilight: { condition: "mdi:weather-sunset-up" },
is_night: { condition: "mdi:weather-night" },
is_polar_night: { condition: "mdi:weather-night" },
is_set: { condition: "mdi:weather-sunny-off" },
is_up: { condition: "mdi:weather-sunny" },
},
switch: {
is_off: { condition: "mdi:toggle-switch-variant-off" },
is_on: { condition: "mdi:toggle-switch-variant" },
},
temperature: { is_value: { condition: "mdi:thermometer" } },
text: { is_equal_to: { condition: "mdi:form-textbox" } },
timer: {
is_active: { condition: "mdi:timer" },
is_idle: { condition: "mdi:timer-off" },
is_paused: { condition: "mdi:timer-pause" },
},
todo: {
all_completed: { condition: "mdi:clipboard-check" },
incomplete: { condition: "mdi:clipboard-alert" },
},
update: {
is_available: { condition: "mdi:package-up" },
is_not_available: { condition: "mdi:package" },
},
vacuum: {
is_cleaning: { condition: "mdi:play" },
is_docked: { condition: "mdi:home-import-outline" },
is_encountering_an_error: { condition: "mdi:alert-circle-outline" },
is_paused: { condition: "mdi:pause" },
is_returning: { condition: "mdi:home-import-outline" },
},
valve: {
is_closed: { condition: "mdi:valve-closed" },
is_open: { condition: "mdi:valve-open" },
},
vibration: {
is_detected: { condition: "mdi:vibrate" },
is_not_detected: { condition: "mdi:vibrate-off" },
},
water_heater: {
is_off: { condition: "mdi:water-boiler-off" },
is_on: { condition: "mdi:water-boiler" },
is_operation_mode: { condition: "mdi:water-boiler" },
is_target_temperature: { condition: "mdi:thermometer" },
},
window: {
is_closed: { condition: "mdi:window-closed" },
is_open: { condition: "mdi:window-open" },
},
zone: {
in_zone: { condition: "mdi:map-marker-check" },
not_in_zone: { condition: "mdi:map-marker-remove" },
occupancy_is_detected: { condition: "mdi:account-group" },
occupancy_is_not_detected: { condition: "mdi:account-off" },
},
};
-1
View File
@@ -39,7 +39,6 @@ const backupInfo: BackupInfo = {
},
],
agent_errors: {},
agent_error_translations: {},
last_attempted_automatic_backup: recent,
last_completed_automatic_backup: recent,
last_action_event: { manager_state: "idle" },
-8
View File
@@ -42,12 +42,4 @@ export const mockBlueprint = (hass: MockHomeAssistant) => {
hass.mockWS("blueprint/list", (msg: { domain: BlueprintDomain }) =>
msg.domain === "script" ? scriptBlueprints : automationBlueprints
);
hass.mockWS(
"blueprint/delete",
(msg: { domain: BlueprintDomain; path: string }) => {
const blueprints =
msg.domain === "script" ? scriptBlueprints : automationBlueprints;
delete blueprints[msg.path];
}
);
};
-2
View File
@@ -1,4 +1,3 @@
import { lorawanFixtures } from "./lorawan/fixtures";
import { bluetoothFixtures } from "./bluetooth/fixtures";
import { infraredFixtures } from "./infrared/fixtures";
import { matterFixtures } from "./matter/fixtures";
@@ -16,7 +15,6 @@ import { zwaveJsFixtures } from "./zwave_js/fixtures";
// data to mock. Each owns its own fixtures, so they can be added and removed
// one at a time.
const INTEGRATIONS: ConnectivityFixtures[] = [
lorawanFixtures,
bluetoothFixtures,
serialFixtures,
modbusFixtures,
-2
View File
@@ -1,4 +1,3 @@
import { mockLoRaWAN } from "./lorawan/mock";
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { mockBluetooth } from "./bluetooth/mock";
import { mockMatter } from "./matter/mock";
@@ -12,7 +11,6 @@ import { mockZwaveJs } from "./zwave_js/mock";
// The WebSocket mocks, code-split into the config panel chunk.
const MOCKS = [
mockLoRaWAN,
mockBluetooth,
mockSerial,
mockModbus,
@@ -1,135 +0,0 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
export const CHIRPSTACK_ENTRY = "mock-lorawan-chirpstack";
export const TTN_ENTRY = "mock-lorawan-ttn";
export const SENSECAP_ENTRY = "mock-lorawan-sensecap";
const REGISTRY_ENTRIES = [
registryEntry(
"sensor.lorawan_greenhouse_temperature",
"lorawan-greenhouse",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Temperature"
),
registryEntry(
"sensor.lorawan_greenhouse_humidity",
"lorawan-greenhouse",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Humidity"
),
registryEntry(
"sensor.lorawan_study_carbon_dioxide",
"lorawan-study",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Carbon dioxide"
),
];
export const lorawanFixtures: ConnectivityFixtures = {
components: [
"lorawan",
"sensecap_lorawan",
"chirpstack",
"thethingsnetwork_lorawan",
],
commands: ["lorawan/"],
manifests: [
manifest("lorawan", "LoRaWAN", { integration_type: "helper" }),
manifest("sensecap_lorawan", "SenseCAP", {
integration_type: "device",
dependencies: ["lorawan"],
}),
manifest("chirpstack", "ChirpStack", { integration_type: "hub" }),
manifest("thethingsnetwork_lorawan", "The Things Network", {
integration_type: "hub",
}),
],
configEntries: [
{
type: "hub",
entry: configEntry(CHIRPSTACK_ENTRY, "chirpstack", "Home ChirpStack"),
},
{
type: "hub",
entry: configEntry(
TTN_ENTRY,
"thethingsnetwork_lorawan",
"The Things Network"
),
},
{
type: "device",
entry: configEntry(SENSECAP_ENTRY, "sensecap_lorawan", "SenseCAP"),
},
],
devices: [
device(
"lorawan-greenhouse",
"Greenhouse",
"Seeed Studio",
"S2101",
SENSECAP_ENTRY,
{
config_entries: [SENSECAP_ENTRY, CHIRPSTACK_ENTRY],
identifiers: [
["sensecap_lorawan", `${CHIRPSTACK_ENTRY}:0000000000000001`],
],
}
),
device(
"lorawan-study",
"Study air quality",
"Seeed Studio",
"S2103",
SENSECAP_ENTRY,
{
config_entries: [SENSECAP_ENTRY, TTN_ENTRY],
identifiers: [["sensecap_lorawan", `${TTN_ENTRY}:0000000000000002`]],
}
),
],
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"sensor.lorawan_greenhouse_temperature": {
entity_id: "sensor.lorawan_greenhouse_temperature",
state: "24.3",
attributes: {
friendly_name: "Greenhouse temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
"sensor.lorawan_greenhouse_humidity": {
entity_id: "sensor.lorawan_greenhouse_humidity",
state: "58",
attributes: {
friendly_name: "Greenhouse humidity",
device_class: "humidity",
state_class: "measurement",
unit_of_measurement: "%",
},
},
"sensor.lorawan_study_carbon_dioxide": {
entity_id: "sensor.lorawan_study_carbon_dioxide",
state: "652",
attributes: {
friendly_name: "Study carbon dioxide",
device_class: "carbon_dioxide",
state_class: "measurement",
unit_of_measurement: "ppm",
},
},
}),
};
-124
View File
@@ -1,124 +0,0 @@
import type { LoRaWANDevice } from "../../../../../src/data/lorawan";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { minutesAgo } from "../helpers";
import { emitInitial } from "../subscription";
import { CHIRPSTACK_ENTRY, TTN_ENTRY } from "./fixtures";
const devices = (): LoRaWANDevice[] => {
const base: LoRaWANDevice = {
dev_eui: "0000000000000001",
name: "Greenhouse",
manufacturer: "Seeed Studio",
model: "S2101",
application_id: "home-sensors",
device_id: "lorawan-greenhouse",
connection: {
entry_id: CHIRPSTACK_ENTRY,
domain: "chirpstack",
title: "Home ChirpStack",
configuration_url: "https://www.chirpstack.io/",
},
support: "supported",
last_uplink: minutesAgo(2),
last_join: minutesAgo(2400),
last_status: minutesAgo(64),
receptions: [{ gateway_id: "home-indoor", rssi: -67, snr: 9.5 }],
battery_level: 87,
external_power_source: false,
downlink_margin: 12,
};
return [
base,
{
...base,
dev_eui: "0000000000000002",
name: "Study air quality",
model: "S2103",
device_id: "lorawan-study",
application_id: "demo-home",
connection: {
entry_id: TTN_ENTRY,
domain: "thethingsnetwork_lorawan",
title: "The Things Network",
configuration_url: "https://eu1.cloud.thethings.network/console",
},
receptions: [
{ gateway_id: "brooklyn-rooftop", rssi: -103, snr: -2.5 },
{ gateway_id: "brooklyn-community", rssi: -110, snr: -5.2 },
],
battery_level: 64,
last_uplink: minutesAgo(4),
last_status: minutesAgo(140),
},
{
...base,
dev_eui: "24E124128E123456",
name: "Irrigation controller",
manufacturer: "Milesight",
model: "UC511",
device_id: null,
support: "integration_not_configured",
battery_level: 93,
last_uplink: minutesAgo(7),
receptions: [{ gateway_id: "home-indoor", rssi: -83, snr: 6.8 }],
},
{
...base,
dev_eui: "A84041FFFF123456",
name: "Garage power meter",
manufacturer: "",
model: "",
device_id: null,
support: "missing_identity",
battery_level: null,
external_power_source: true,
last_uplink: minutesAgo(1),
receptions: [{ gateway_id: "home-indoor", rssi: -75, snr: 8.1 }],
},
{
...base,
dev_eui: "2CF7F1C071500199",
name: "New soil sensor",
model: "S2105",
device_id: null,
support: "unsupported_model",
last_uplink: null,
last_join: null,
last_status: null,
receptions: [],
battery_level: null,
external_power_source: null,
downlink_margin: null,
},
{
...base,
dev_eui: "2CF7F1C071500166",
name: "Garden shed",
device_id: null,
support: "integration_not_configured",
battery_level: 18,
last_uplink: minutesAgo(180),
last_status: minutesAgo(600),
receptions: [{ gateway_id: "home-indoor", rssi: -112, snr: -7.5 }],
},
];
};
export const mockLoRaWAN = (hass: MockHomeAssistant) => {
const deviceList = devices();
const snapshot = {
devices: deviceList,
connections: [
...new Map(
deviceList.map((device) => [
device.connection.entry_id,
device.connection,
])
).values(),
],
};
hass.mockWS("lorawan/devices/list", () => snapshot);
hass.mockWS("lorawan/devices/subscribe", (_msg, _hass, onChange) =>
emitInitial(() => onChange?.(snapshot))
);
};
+3 -255
View File
@@ -1,277 +1,25 @@
import type {
EntityRegistryDisplayEntryResponse,
EntityRegistryEntry,
ExtEntityRegistryEntry,
UpdateEntityRegistryEntryResult,
} from "../../../src/data/entity/entity_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
let entries: EntityRegistryEntry[] = [];
const newEntry = (
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
): EntityRegistryEntry => {
const now = Date.now() / 1000;
return {
config_entry_id: null,
config_subentry_id: null,
device_id: null,
area_id: null,
disabled_by: null,
id: entry.entity_id,
name: null,
icon: null,
labels: [],
categories: {},
hidden_by: null,
entity_category: null,
has_entity_name: false,
options: null,
created_at: now,
modified_at: now,
...entry,
};
};
// The registry settings that the display registry holds. A display entry
// without a registry name shows the name of its state.
const displayFields = (entry: EntityRegistryEntry) => ({
...(entry.name && { name: entry.name }),
icon: entry.icon ?? undefined,
area_id: entry.area_id ?? undefined,
labels: entry.labels,
hidden: entry.hidden_by !== null,
});
const extEntry = (entry: EntityRegistryEntry): ExtEntityRegistryEntry => ({
...entry,
capabilities: {},
aliases: [],
});
export const mockEntityRegistry = (
hass: MockHomeAssistant,
data: EntityRegistryEntry[] = []
) => {
entries = [...data];
// A new array, so subscribers see the registry changed after an update
hass.mockWS("config/entity_registry/list", () => [...entries]);
hass.mockWS("config/entity_registry/list", () => data);
hass.mockWS(
"config/entity_registry/get_entries",
(msg: { entity_ids: string[] }) => {
const result: Record<string, ExtEntityRegistryEntry> = {};
for (const entityId of msg.entity_ids) {
const entry = entries.find((e) => e.entity_id === entityId);
const entry = data.find((e) => e.entity_id === entityId);
if (entry) {
result[entityId] = extEntry(entry);
result[entityId] = { ...entry, capabilities: {}, aliases: [] };
}
}
return result;
}
);
hass.mockWS(
"config/entity_registry/update",
(
msg: Pick<
EntityRegistryEntry,
| "entity_id"
| "name"
| "icon"
| "area_id"
| "labels"
| "categories"
| "hidden_by"
| "disabled_by"
> & { new_entity_id?: string },
currentHass: MockHomeAssistant
): UpdateEntityRegistryEntryResult => {
const {
entity_id: entityId,
new_entity_id: newEntityId,
...updates
} = msg;
// Renaming would have to move the entity in every mock, so refuse it
// instead of reporting a rename that did not happen
if (newEntityId && newEntityId !== entityId) {
throw new Error("Changing the entity ID is not supported in the demo");
}
// The demo entities without an entry get one, like any core entity has
const entry = {
...(entries.find((e) => e.entity_id === entityId) ??
newEntry({
entity_id: entityId,
platform: currentHass.entities[entityId]?.platform ?? "demo",
unique_id: entityId,
})),
...Object.fromEntries(
Object.entries(updates).filter(([key]) => key !== "type")
),
modified_at: Date.now() / 1000,
};
entries = [...entries.filter((e) => e.entity_id !== entityId), entry];
// Core updates the display registry too, only change what was updated
const display = currentHass.entities[entityId];
if (display) {
const fields = displayFields(entry);
currentHass.updateHass({
entities: {
...currentHass.entities,
[entityId]: {
...display,
...("name" in updates && { name: fields.name }),
...("icon" in updates && { icon: fields.icon }),
...("area_id" in updates && { area_id: fields.area_id }),
...("labels" in updates && { labels: fields.labels }),
...("hidden_by" in updates && { hidden: fields.hidden }),
},
},
});
}
currentHass.mockEvent("entity_registry_updated");
return { entity_entry: extEntry(entry) };
}
);
};
// Entries of a platform that were replaced, so their settings are restored
// when the entity comes back after switching demos
const retiredEntries = new Map<string, EntityRegistryEntry>();
// Replaces the entries of a platform, like core does for the entities of a
// demo config
export const setPlatformEntityRegistryEntries = (
hass: MockHomeAssistant,
platform: string,
platformEntries: Pick<EntityRegistryEntry, "entity_id" | "unique_id">[]
) => {
for (const entry of entries) {
if (entry.platform === platform) {
retiredEntries.set(`${platform}.${entry.unique_id}`, entry);
}
}
const restored: EntityRegistryEntry[] = [];
const platformRegistryEntries = platformEntries.map((entry) => {
const retired = retiredEntries.get(`${platform}.${entry.unique_id}`);
if (!retired) {
return newEntry({ ...entry, platform });
}
const restoredEntry = { ...retired, entity_id: entry.entity_id };
restored.push(restoredEntry);
return restoredEntry;
});
entries = [
...entries.filter((e) => e.platform !== platform),
...platformRegistryEntries,
];
// The display registry was rebuilt with the entities, restore its settings
if (restored.length) {
const entities = { ...hass.entities };
for (const entry of restored) {
if (entities[entry.entity_id]) {
entities[entry.entity_id] = {
...entities[entry.entity_id],
...displayFields(entry),
};
}
}
hass.updateHass({ entities });
}
hass.mockEvent("entity_registry_updated");
};
export const getEntityRegistryEntry = (entityId: string) =>
entries.find((e) => e.entity_id === entityId);
export const removeEntityRegistryEntry = (
hass: MockHomeAssistant,
entityId: string
) => {
entries = entries.filter((e) => e.entity_id !== entityId);
hass.mockEvent("entity_registry_updated");
};
export const removeLabelFromEntityRegistry = (
hass: MockHomeAssistant,
labelId: string
) => {
const withoutLabel = (labels: string[]) =>
labels.filter((id) => id !== labelId);
for (const [key, entry] of retiredEntries) {
if (entry.labels.includes(labelId)) {
retiredEntries.set(key, { ...entry, labels: withoutLabel(entry.labels) });
}
}
if (!entries.some((entry) => entry.labels.includes(labelId))) {
return;
}
entries = entries.map((entry) =>
entry.labels.includes(labelId)
? { ...entry, labels: withoutLabel(entry.labels) }
: entry
);
const entities = { ...hass.entities };
for (const entry of entries) {
const entity = entities[entry.entity_id];
if (entity?.labels.includes(labelId)) {
entities[entry.entity_id] = {
...entity,
labels: withoutLabel(entity.labels),
};
}
}
hass.updateHass({ entities });
hass.mockEvent("entity_registry_updated");
};
// Adds an entry like core does when an integration creates an entity
export const addEntityRegistryEntry = (
hass: MockHomeAssistant,
entry: Pick<EntityRegistryEntry, "entity_id" | "platform" | "unique_id">
) => {
entries = [
...entries.filter((e) => e.entity_id !== entry.entity_id),
newEntry(entry),
];
hass.mockEvent("entity_registry_updated");
};
// The app refetches the display registry on entity_registry_updated events.
// Answer with the current display registry, which the demo keeps in hass.
export const mockEntityRegistryDisplay = (hass: MockHomeAssistant) =>
hass.mockWS(
"config/entity_registry/list_for_display",
(
_msg,
currentHass: MockHomeAssistant
): EntityRegistryDisplayEntryResponse => {
const categories = [
...new Set(
Object.values(currentHass.entities)
.map((entity) => entity.entity_category)
.filter((category) => category !== undefined)
),
];
return {
entities: Object.values(currentHass.entities).map((entity) => ({
ei: entity.entity_id,
di: entity.device_id,
ai: entity.area_id,
np: entity.next_name_part,
lb: entity.labels,
ec: entity.entity_category
? categories.indexOf(entity.entity_category)
: undefined,
en: entity.name,
ic: entity.icon,
pl: entity.platform ?? "demo",
tk: entity.translation_key,
hb: entity.hidden,
dp: entity.display_precision,
hn: entity.has_entity_name,
})),
entity_categories: Object.fromEntries(
categories.map((category, index) => [index, category])
),
};
}
);
-11
View File
@@ -12,17 +12,6 @@ export const mockIcons = (hass: MockHomeAssistant) => {
category: IconCategory;
integration?: string;
}) => {
if (category === "triggers" || category === "conditions") {
const { triggerIcons, conditionIcons } =
await import("./automation_platforms_icons");
const icons: Record<string, unknown> =
category === "triggers" ? triggerIcons : conditionIcons;
return {
resources: integration
? { [integration]: icons[integration] ?? {} }
: icons,
};
}
if (integration) {
try {
const response = await fetch(
+1 -25
View File
@@ -1,31 +1,7 @@
import type { LabelRegistryEntry } from "../../../src/data/label/label_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { removeLabelFromEntityRegistry } from "./entity_registry";
let labels: LabelRegistryEntry[] = [];
export const getLabelIds = () => labels.map((label) => label.label_id);
export const mockLabelRegistry = (
hass: MockHomeAssistant,
data: LabelRegistryEntry[] = []
) => {
labels = [...data];
hass.mockWS("config/label_registry/list", () =>
labels.map((label) => ({ ...label }))
);
hass.mockWS(
"config/label_registry/delete",
(msg: { label_id: string }, currentHass: MockHomeAssistant) => {
const index = labels.findIndex(
(label) => label.label_id === msg.label_id
);
if (index === -1) {
throw new Error(`Label ${msg.label_id} not found`);
}
labels.splice(index, 1);
removeLabelFromEntityRegistry(currentHass, msg.label_id);
hass.mockEvent("label_registry_updated");
}
);
};
) => hass.mockWS("config/label_registry/list", () => data);
+2 -16
View File
@@ -1,5 +1,4 @@
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { LovelaceDashboard } from "../../../src/data/lovelace/dashboard";
import type { LovelaceInfo } from "../../../src/data/lovelace/resource";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import {
@@ -12,14 +11,10 @@ import "../custom-cards/ha-demo-card";
import "../custom-cards/ha-demo-next-card";
import { mockMap } from "./map";
let dashboards: LovelaceDashboard[] = [];
export const mockLovelace = (
hass: MockHomeAssistant,
localizePromise: Promise<LocalizeFunc>,
data: LovelaceDashboard[] = []
localizePromise: Promise<LocalizeFunc>
) => {
dashboards = data;
hass.mockWS("lovelace/config", ({ url_path }) => {
if (url_path === "map") {
mockMap(hass);
@@ -39,16 +34,7 @@ export const mockLovelace = (
);
hass.mockWS("lovelace/config/save", () => Promise.resolve());
hass.mockWS("lovelace/resources", () => Promise.resolve([]));
hass.mockWS("lovelace/dashboards/list", () =>
dashboards.map((dashboard) => ({ ...dashboard }))
);
hass.mockWS("lovelace/dashboards/delete", (msg: { dashboard_id: string }) => {
const index = dashboards.findIndex((d) => d.id === msg.dashboard_id);
if (index === -1) {
throw new Error(`Dashboard ${msg.dashboard_id} not found`);
}
dashboards.splice(index, 1);
});
hass.mockWS("lovelace/dashboards/list", () => Promise.resolve([]));
};
customElements.whenDefined("hui-root").then(() => {
+1 -8
View File
@@ -221,14 +221,7 @@ const personEntity = (person: DemoPerson, now: number): EntityInput => {
const personHistory = (person: DemoPerson, now: number): EntityHistoryState[] =>
person.timeline.map(([hoursAgo, state]) => {
const time = (now - hoursAgo * HOUR) / 1000;
const zoneId =
state === "home" ? "home" : zones.find((zone) => zone.name === state)?.id;
return {
s: state,
a: { in_zones: zoneId ? [`zone.${zoneId}`] : [] },
lu: time,
lc: time,
};
return { s: state, a: {}, lu: time, lc: time };
});
export const mockMap = (hass: MockHomeAssistant) => {
+22 -69
View File
@@ -1,10 +1,8 @@
import { TZDate } from "@date-fns/tz";
import {
addDays,
addHours,
addMinutes,
addMonths,
constructFrom,
differenceInHours,
endOfDay,
} from "date-fns";
@@ -15,18 +13,6 @@ import type {
} from "../../../src/data/recorder";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const getFirstDate = (
start: Date,
period: "5minute" | "hour" | "day" | "month"
): Date => {
if (period === "day" || period === "month") {
return constructFrom(start, start);
}
// Core returns UTC-aligned buckets whose start is at or after the request.
const interval = (period === "hour" ? 60 : 5) * 60 * 1000;
return constructFrom(start, Math.ceil(start.getTime() / interval) * interval);
};
const getNextDate = (
currentDate: Date,
period: "5minute" | "hour" | "day" | "month"
@@ -47,7 +33,8 @@ const generateMeanStatistics = (
maxDiff: number
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = getFirstDate(start, period);
let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0);
const now = new Date();
while (end > currentDate && currentDate < now) {
const delta = Math.random() * maxDiff;
@@ -73,17 +60,16 @@ const generateSumStatistics = (
end: Date,
period: "5minute" | "hour" | "day" | "month" = "hour",
initValue: number,
maxDiff: number | ((start: Date, end: Date) => number)
maxDiff: number
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = getFirstDate(start, period);
let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0);
let sum = initValue;
const now = new Date();
while (end > currentDate && currentDate < now) {
const nextDate = getNextDate(currentDate, period);
const bound =
typeof maxDiff === "function" ? maxDiff(currentDate, nextDate) : maxDiff;
const add = Math.random() * bound;
const add = Math.random() * maxDiff;
sum += add;
statistics.push({
start: currentDate.getTime(),
@@ -110,7 +96,8 @@ const generateCurvedStatistics = (
metered: boolean
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = getFirstDate(start, period);
let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0);
let sum = initValue;
const hours = differenceInHours(end, start) - 1;
let i = 0;
@@ -149,15 +136,6 @@ const statisticsFunctions: Record<
period: "5minute" | "hour" | "day" | "month"
) => StatisticValue[]
> = {
"sensor.energy_water": (_id, start, end, period = "hour") =>
generateSumStatistics(
start,
end,
period,
4000,
(bucketStart, bucketEnd) =>
(20 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
),
"sensor.energy_consumption_tarif_1": (
_id: string,
start: Date,
@@ -173,9 +151,9 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const morningEnd = addHours(start, 10);
const morningEnd = new Date(start.getTime() + 10 * 60 * 60 * 1000);
const morningLow = generateSumStatistics(start, morningEnd, period, 0, 0.7);
const eveningStart = addHours(start, 20);
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
const morningFinalVal = morningLow.length
? morningLow[morningLow.length - 1].sum!
: 0;
@@ -210,8 +188,8 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const morningEnd = addHours(start, 9);
const eveningStart = addHours(start, 20);
const morningEnd = new Date(start.getTime() + 9 * 60 * 60 * 1000);
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
const highTarif = generateSumStatistics(
morningEnd,
eveningStart,
@@ -250,12 +228,9 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const productionStart = addHours(start, 9);
const productionEnd = addHours(start, 21);
const dayEnd = constructFrom(
end,
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
);
const productionStart = new Date(start.getTime() + 9 * 60 * 60 * 1000);
const productionEnd = new Date(start.getTime() + 21 * 60 * 60 * 1000);
const dayEnd = new Date(endOfDay(productionEnd));
const production = generateCurvedStatistics(
productionStart,
productionEnd,
@@ -294,12 +269,9 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const productionStart = addHours(start, 7);
const productionEnd = addHours(start, 23);
const dayEnd = constructFrom(
end,
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
);
const productionStart = new Date(start.getTime() + 7 * 60 * 60 * 1000);
const productionEnd = new Date(start.getTime() + 23 * 60 * 60 * 1000);
const dayEnd = new Date(endOfDay(productionEnd));
const production = generateCurvedStatistics(
productionStart,
productionEnd,
@@ -329,11 +301,6 @@ const statisticsFunctions: Record<
return [...morning, ...production, ...evening, ...rest];
},
};
// Prices are per source unit; water uses litres (4 currency units per m³).
const derivedCosts: Record<string, { source: string; price: number }> = {
"sensor.energy_water_cost": { source: "sensor.energy_water", price: 0.004 },
};
export const mockRecorder = (mockHass: MockHomeAssistant) => {
mockHass.mockWS(
"recorder/get_statistics_metadata",
@@ -346,28 +313,14 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
mockHass.mockWS(
"recorder/statistics_during_period",
({ statistic_ids, start_time, end_time, period }, hass): Statistics => {
// Calendar buckets follow the server timezone, independent of the browser.
const start = new TZDate(start_time, hass.config.time_zone);
const end = new TZDate(end_time ?? Date.now(), hass.config.time_zone);
const start = new Date(start_time);
const end = end_time ? new Date(end_time) : new Date();
const statistics: Record<string, StatisticValue[]> = {};
const cache: Record<string, StatisticValue[]> = {};
const getStats = (id: string) => {
cache[id] ??= statisticsFunctions[id](id, start, end, period);
return cache[id];
};
statistic_ids.forEach((id: string) => {
if (id in derivedCosts) {
const { source, price } = derivedCosts[id];
let cost = 0;
statistics[id] = getStats(source).map((statistic) => {
const change = statistic.change! * price;
cost += change;
return { ...statistic, change, state: cost, sum: cost };
});
} else if (id in statisticsFunctions) {
statistics[id] = getStats(id);
if (id in statisticsFunctions) {
statistics[id] = statisticsFunctions[id](id, start, end, period);
} else {
const entityState = hass.states[id];
const state = entityState ? Number(entityState.state) : 1;
+5 -2
View File
@@ -1,5 +1,6 @@
import { html, css, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../src/components/ha-formfield";
import "../../../src/components/ha-switch";
import type { HomeAssistant } from "../../../src/types";
import "./demo-card";
@@ -17,7 +18,9 @@ class DemoCards extends LitElement {
render() {
return html`
<ha-demo-options>
<ha-switch @change=${this._showConfigToggled}>Show config</ha-switch>
<ha-formfield label="Show config">
<ha-switch @change=${this._showConfigToggled}> </ha-switch>
</ha-formfield>
</ha-demo-options>
<div id="container">
<div class="cards">
@@ -48,7 +51,7 @@ class DemoCards extends LitElement {
demo-card {
margin: 16px 16px 32px;
}
ha-switch {
ha-formfield {
margin-right: 16px;
}
#container {
+5 -2
View File
@@ -1,5 +1,6 @@
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../src/components/ha-formfield";
import "../../../src/components/ha-switch";
import type { HomeAssistant } from "../../../src/types";
import "../ha-demo-options";
@@ -16,7 +17,9 @@ class DemoMoreInfos extends LitElement {
render() {
return html`
<ha-demo-options>
<ha-switch @change=${this._showConfigToggled}>Show config</ha-switch>
<ha-formfield label="Show config">
<ha-switch @change=${this._showConfigToggled}> </ha-switch>
</ha-formfield>
</ha-demo-options>
<div id="container">
<div class="cards">
@@ -46,7 +49,7 @@ class DemoMoreInfos extends LitElement {
demo-more-info {
margin: var(--ha-space-4) var(--ha-space-4) var(--ha-space-8);
}
ha-switch {
ha-formfield {
margin-right: var(--ha-space-4);
}
`;
+4 -1
View File
@@ -6,10 +6,12 @@ import "../../../src/components/ha-settings-row";
import "../../../src/components/ha-switch";
import type { HaSwitch } from "../../../src/components/ha-switch";
import "../../../src/components/ha-theme-settings";
import type { ThemeSettings } from "../../../src/types";
import type { HomeAssistant, ThemeSettings } from "../../../src/types";
@customElement("gallery-settings")
class GallerySettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public themeSettings!: ThemeSettings;
@property({ type: Boolean }) public narrow = false;
@@ -24,6 +26,7 @@ class GallerySettings extends LitElement {
Configure how the gallery renders component previews and examples.
</div>
<ha-theme-settings
.hass=${this.hass}
.selectedTheme=${this.themeSettings}
.narrow=${this.narrow}
.heading=${"Theme"}
-10
View File
@@ -151,16 +151,6 @@ export const createMediaPlayerEntities = () => [
supported_features: 289,
},
},
{
entity_id: "media_player.theater_off_assumed",
state: "off",
attributes: {
friendly_name: "TV Off (assumed state, cannot be switched on)",
assumed_state: true,
// Off + Next + Pause
supported_features: 289,
},
},
{
entity_id: "media_player.theater_on_static",
state: "on",
+1 -15
View File
@@ -9,7 +9,6 @@ import { ifDefined } from "lit/directives/if-defined";
import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element";
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
import { listenMediaQuery } from "../../src/common/dom/media_query";
import { computeEntityNameDisplayWithoutContext } from "../../src/common/entity/compute_entity_name_display";
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
import "../../src/components/ha-button";
@@ -32,7 +31,6 @@ import {
floorsContext,
formattersContext,
internationalizationContext,
reducedMotionContext,
registriesContext,
servicesContext,
statesContext,
@@ -163,13 +161,6 @@ class HaGallery extends LitElement {
floors: new ContextProvider(this, { context: floorsContext }),
};
private _reducedMotionProvider = new ContextProvider(this, {
context: reducedMotionContext,
initialValue: false,
});
private _unsubReducedMotion?: () => void;
protected willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
// Refresh the fallback contexts before each render so theme/page changes in
@@ -242,6 +233,7 @@ class HaGallery extends LitElement {
${
isSettingsPage
? html`<gallery-settings
.hass=${this._galleryHass}
.themeSettings=${this._themeSettings}
.narrow=${this._narrow}
.rtl=${this._rtl}
@@ -275,10 +267,6 @@ class HaGallery extends LitElement {
connectedCallback() {
super.connectedCallback();
mql.addEventListener("change", this._systemDarkChanged);
this._unsubReducedMotion = listenMediaQuery(
"(prefers-reduced-motion: reduce)",
(matches) => this._reducedMotionProvider.setValue(matches)
);
}
firstUpdated(changedProps: PropertyValues<this>) {
@@ -312,8 +300,6 @@ class HaGallery extends LitElement {
super.disconnectedCallback();
mql.removeEventListener("change", this._systemDarkChanged);
window.removeEventListener("hashchange", this._hashChanged);
this._unsubReducedMotion?.();
this._unsubReducedMotion = undefined;
}
private _hashChanged = () => {
-5
View File
@@ -12,11 +12,6 @@
<script type="module" src="<%= entry %>"></script>
<% } %>
<style>
html {
/* Prevent iOS Safari from inflating text when rotating to landscape */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
font-family: Roboto, Noto, sans-serif;
margin: 0;
@@ -1,6 +1,7 @@
import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../src/components/ha-formfield";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
import "../../components/demo-black-white-row";
@@ -63,12 +64,13 @@ class DemoHaAutomationEditorAction extends LitElement {
protected render(): TemplateResult {
return html`
<div class="options">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
>Disabled</ha-switch
>
<ha-formfield label="Disabled">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
</div>
${SCHEMAS.map(
(info, sampleIdx) => html`
@@ -109,7 +111,7 @@ class DemoHaAutomationEditorAction extends LitElement {
max-width: 800px;
margin: 16px auto;
}
.options ha-switch {
.options ha-formfield {
margin-right: 16px;
}
`;
@@ -5,6 +5,7 @@ import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import "../../../../src/components/ha-formfield";
import type { ConditionWithShorthand } from "../../../../src/data/automation";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../../../src/panels/config/automation/condition/ha-automation-condition";
@@ -105,12 +106,13 @@ export class DemoAutomationEditorCondition extends LitElement {
protected render(): TemplateResult {
return html`
<div class="options">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
>Disabled</ha-switch
>
<ha-formfield label="Disabled">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
</div>
${SCHEMAS.map(
(info, sampleIdx) => html`
@@ -151,7 +153,7 @@ export class DemoAutomationEditorCondition extends LitElement {
max-width: 800px;
margin: 16px auto;
}
.options ha-switch {
.options ha-formfield {
margin-right: 16px;
}
`;
@@ -8,6 +8,7 @@ import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { mockTags } from "../../../../demo/src/stubs/tags";
import "../../../../src/components/ha-formfield";
import type { Trigger } from "../../../../src/data/automation";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../../../src/panels/config/automation/trigger/ha-automation-trigger";
@@ -145,12 +146,13 @@ export class DemoAutomationEditorTrigger extends LitElement {
protected render(): TemplateResult {
return html`
<div class="options">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
>Disabled</ha-switch
>
<ha-formfield label="Disabled">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
</div>
${SCHEMAS.map(
(info, sampleIdx) => html`
@@ -191,7 +193,7 @@ export class DemoAutomationEditorTrigger extends LitElement {
max-width: 800px;
margin: 16px auto;
}
.options ha-switch {
.options ha-formfield {
margin-right: 16px;
}
`;
@@ -9,6 +9,7 @@ import "../../../../src/components/ha-form/ha-form";
import "../../../../src/components/ha-icon-button";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
const SCHEMA: HaFormSchema[] = [
{ type: "string", name: "Name", default: "", autofocus: true },
@@ -30,8 +31,11 @@ type DialogType =
export class DemoHaAdaptiveDialog extends LitElement {
@state() private _openDialog: DialogType = false;
@state() private _hass?: HomeAssistant;
protected firstUpdated() {
provideHass(this);
const hass = provideHass(this);
this._hass = hass;
}
protected render() {
@@ -85,6 +89,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-card>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic"}
header-title="Basic adaptive dialog"
@closed=${this._handleClosed}
@@ -93,6 +98,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic-subtitle-below"}
header-title="Adaptive dialog with subtitle"
header-subtitle="This is an adaptive dialog with a subtitle below"
@@ -102,6 +108,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic-subtitle-above"}
header-title="Adaptive dialog with subtitle above"
header-subtitle="This is an adaptive dialog with a subtitle above"
@@ -112,6 +119,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "small"}
width="small"
header-title="Small adaptive dialog"
@@ -121,6 +129,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "large"}
width="large"
header-title="Large adaptive dialog"
@@ -130,6 +139,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "form"}
header-title="Adaptive dialog with form"
header-subtitle="This is an adaptive dialog with a form"
@@ -153,6 +163,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.allowModeChange=${this._openDialog === "allow-mode-change"}
header-title="Adaptive dialog with allow mode change"
header-subtitle="Resize the window while this dialog is open"
@@ -165,6 +176,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "actions"}
header-title="Adaptive dialog with actions"
header-subtitle="This is an adaptive dialog with header actions"
@@ -383,6 +395,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
<h3>Example usage</h3>
<pre><code>&lt;ha-adaptive-dialog
.hass=\${this.hass}
open
header-title="Dialog title"
header-subtitle="Dialog subtitle"
@@ -594,7 +607,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
<p>Example:</p>
<pre><code>&lt;ha-adaptive-dialog open&gt;
<pre><code>&lt;ha-adaptive-dialog .hass=\${this.hass} open&gt;
&lt;ha-form autofocus .schema=\${schema}&gt;&lt;/ha-form&gt;
&lt;/ha-adaptive-dialog&gt;</code></pre>
</div>
@@ -9,6 +9,7 @@ import "../../../../src/components/ha-form/ha-form";
import "../../../../src/components/ha-icon-button";
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
const SCHEMA: HaFormSchema[] = [
@@ -22,10 +23,12 @@ type PopoverType = false | "basic" | "small" | "large" | "form" | "actions";
export class DemoHaAdaptivePopover extends LitElement {
@state() private _openPopover: PopoverType = false;
@state() private _hass?: HomeAssistant;
@state() private _dialogAnchor?: HTMLElement;
protected firstUpdated() {
provideHass(this);
this._hass = provideHass(this);
}
protected render() {
@@ -69,6 +72,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-card>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "basic"}
.dialogAnchor=${this._dialogAnchor}
header-title="Basic adaptive popover"
@@ -82,6 +86,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "small"}
.dialogAnchor=${this._dialogAnchor}
width="small"
@@ -92,6 +97,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "large"}
.dialogAnchor=${this._dialogAnchor}
width="large"
@@ -102,6 +108,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "form"}
.dialogAnchor=${this._dialogAnchor}
header-title="Adaptive popover with form"
@@ -126,6 +133,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "actions"}
.dialogAnchor=${this._dialogAnchor}
header-title="Adaptive popover with actions"
@@ -51,12 +51,11 @@ The badge component is a small component that displays a number or status inform
**Properties/Attributes**
| Name | Type | Default | Description |
| --------------- | ----------------------- | ----------- | ------------------------------------------------------------- |
| type | `"badge"` or `"button"` | `"badge"` | If it's button it shows a ripple effect |
| label | string | `undefined` | Text label for the badge, only visible if `iconOnly = false` |
| iconOnly | boolean | `false` | Only show label |
| accessibleLabel | string | `undefined` | Accessible name of the button, only used if `iconOnly = true` |
| Name | Type | Default | Description |
| -------- | ----------------------- | ----------- | ------------------------------------------------------------ |
| type | `"badge"` or `"button"` | `"badge"` | If it's button it shows a ripple effect |
| label | string | `undefined` | Text label for the badge, only visible if `iconOnly = false` |
| iconOnly | boolean | `false` | Only show label |
**CSS Custom Properties**
@@ -63,25 +63,26 @@ means supplying one.
### Properties/Attributes
| Name | Type | Default | Description |
| ------------------ | ------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| getItems | Function | - | Required. Returns the items, optionally filtered by search string and section. |
| getAdditionalItems | Function | - | Extra items appended to search results, for example "add new". |
| value | String | - | Id of the current item. Marks its row `selected`, and the virtualized list opens with that row in view. |
| label | String | - | Placeholder for the search field. Falls back to a localized "Search". |
| mode | "popover"/"dialog" | "popover" | Adjusts padding for the surface the list sits in. |
| shown | Boolean | true | Whether the surface finished animating. `ha-generic-picker` sets it so the virtualizer does not measure rows mid-animation. |
| sections | Array | - | Filter chips. Section headers are plain strings returned by `getItems`, and the sticky title shows the one above the first visible row. |
| selectedSection | String | - | Section filter selected when the list is initialized. |
| searchKeys | Array | - | Fuse weighted keys for fuzzy search. |
| searchFn | Function | - | Post-processes filtered results. |
| rowRenderer | Function | - | Replaces the default row template. |
| allowCustomValue | Boolean | false | Offers the typed string as an item. |
| customValueLabel | String | - | Label for that custom item. |
| notFoundLabel | String/Function | - | Shown when a search matches nothing. |
| emptyLabel | String | - | Shown when there are no items at all. |
| noSort | Boolean | false | Keeps the order `getItems` returned. |
| clearable | Boolean | false | Adjusts search field padding for a clear affordance. |
| Name | Type | Default | Description |
| -------------------- | ------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------- |
| getItems | Function | - | Required. Returns the items, optionally filtered by search string and section. |
| getAdditionalItems | Function | - | Extra items appended to search results, for example "add new". |
| value | String | - | Id of the current item. Marks its row `selected`, and the virtualized list opens with that row in view. |
| label | String | - | Placeholder for the search field. Falls back to a localized "Search". |
| mode | "popover"/"dialog" | "popover" | Adjusts padding for the surface the list sits in. |
| shown | Boolean | true | Whether the surface finished animating. `ha-generic-picker` sets it so the virtualizer does not measure rows mid-animation. |
| sections | Array | - | Filter chips. Section headers are plain strings returned by `getItems`. |
| selectedSection | String | - | Section filter selected when the list is initialized. |
| sectionTitleFunction | Function | - | Builds the sticky section title from the visible range. |
| searchKeys | Array | - | Fuse weighted keys for fuzzy search. |
| searchFn | Function | - | Post-processes filtered results. |
| rowRenderer | Function | - | Replaces the default row template. |
| allowCustomValue | Boolean | false | Offers the typed string as an item. |
| customValueLabel | String | - | Label for that custom item. |
| notFoundLabel | String/Function | - | Shown when a search matches nothing. |
| emptyLabel | String | - | Shown when there are no items at all. |
| noSort | Boolean | false | Keeps the order `getItems` returned. |
| clearable | Boolean | false | Adjusts search field padding for a clear affordance. |
### Events
+30 -25
View File
@@ -15,6 +15,7 @@ import {
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { BlueprintInput } from "../../../../src/data/blueprint";
@@ -1062,30 +1063,34 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
protected render(): TemplateResult {
return html`
<div class="options">
<ha-switch
.name=${"label"}
.checked=${this._label}
@change=${this._handleOptionChange}
>Labels</ha-switch
>
<ha-switch
.name=${"required"}
.checked=${this._required}
@change=${this._handleOptionChange}
>Required</ha-switch
>
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
>Disabled</ha-switch
>
<ha-switch
.name=${"helper"}
.checked=${this._helper}
@change=${this._handleOptionChange}
>Helper text</ha-switch
>
<ha-formfield label="Labels">
<ha-switch
.name=${"label"}
.checked=${this._label}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
<ha-formfield label="Required">
<ha-switch
.name=${"required"}
.checked=${this._required}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
<ha-formfield label="Disabled">
<ha-switch
.name=${"disabled"}
.checked=${this._disabled}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
<ha-formfield label="Helper text">
<ha-switch
.name=${"helper"}
.checked=${this._helper}
@change=${this._handleOptionChange}
></ha-switch>
</ha-formfield>
</div>
${SCHEMAS.map((info, idx) => {
const data = this.data[idx];
@@ -1157,7 +1162,7 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
max-width: 800px;
margin: 16px auto;
}
.options ha-switch {
.options ha-formfield {
margin-right: 16px;
}
`;
+1 -1
View File
@@ -33,4 +33,4 @@ We want to make it as easy for designers to contribute as it is for developers.
## Developers
Everything you need to get started developing can be found in our <a href="https://contributors.home-assistant.io/frontend/" rel="noopener noreferrer" target="_blank">Home Assistant contributor docs</a>.
Everything you need to get started developing can be found in our <a href="https://developers.home-assistant.io" rel="noopener noreferrer" target="_blank">Home Assistant Developer Docs</a>.
+1 -1
View File
@@ -271,7 +271,7 @@ const CONFIGS = [
### ha-alert
You can use our [\`ha-alert\`](https://contributors.home-assistant.io/design/components/alert/) component in markdown content rendered in the Home Assistant Frontend.
You can use our [\`ha-alert\`](https://design.home-assistant.io/#components/ha-alert) component in markdown content rendered in the Home Assistant Frontend.
<ha-alert alert-type="error">This is an error alert — check it out!</ha-alert>
<ha-alert alert-type="warning">This is a warning alert — check it out!</ha-alert>
@@ -102,13 +102,6 @@ const CONFIGS = [
entity: "media_player.theater_off_static",
},
},
{
heading: "Player Off (assumed state, cannot be switched on)",
config: {
type: "media-control",
entity: "media_player.theater_off_assumed",
},
},
{
heading: "Player On (cannot be switched off)",
config: {
+8 -6
View File
@@ -1,6 +1,7 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-select";
import type { HaSelectSelectEvent } from "../../../../src/components/ha-select";
import "../../../../src/components/ha-switch";
@@ -387,12 +388,13 @@ export class DemoMiscCloudAccount
private _toggle(label: string, field: keyof CloudDemoScenario) {
return html`
<ha-switch
.checked=${this._scenario[field] as boolean}
data-field=${field}
@change=${this._switchChanged}
>${label}</ha-switch
>
<ha-formfield .label=${label}>
<ha-switch
.checked=${this._scenario[field] as boolean}
data-field=${field}
@change=${this._switchChanged}
></ha-switch>
</ha-formfield>
`;
}
+11 -6
View File
@@ -1,5 +1,6 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-switch";
import { customElement, property, state } from "lit/decorators";
@@ -257,10 +258,12 @@ export class DemoIntegrationCard extends LitElement {
return html`
<div class="container">
<div class="filters">
<ha-switch @change=${this._toggleCustomIntegration}
>Custom Integration</ha-switch
>
<ha-switch @change=${this._toggleCloud}>Relies on cloud</ha-switch>
<ha-formfield label="Custom Integration">
<ha-switch @change=${this._toggleCustomIntegration}></ha-switch>
</ha-formfield>
<ha-formfield label="Relies on cloud">
<ha-switch @change=${this._toggleCloud}></ha-switch>
</ha-formfield>
</div>
<ha-ignored-config-entry-card
@@ -290,6 +293,7 @@ export class DemoIntegrationCard extends LitElement {
class=${classMap({
highlight: info.highlight !== undefined,
})}
.hass=${this.hass}
domain="esphome"
.items=${info.items}
.manifest=${createManifest(
@@ -312,6 +316,7 @@ export class DemoIntegrationCard extends LitElement {
<!-- One that is standalone to see how it increases height if height
not defined by other cards. -->
<ha-integration-card
.hass=${this.hass}
domain="esphome"
.items=${[
loadedEntry,
@@ -364,9 +369,9 @@ export class DemoIntegrationCard extends LitElement {
max-width: 500px;
}
ha-switch {
ha-formfield {
margin: 8px 0;
display: flex;
display: block;
}
`;
}
+1 -1
View File
@@ -32,7 +32,7 @@ It is similar to the core frontend dev.
- `"mounts": ["source=/path/to/hass/frontend,target=/workspaces/frontend,type=bind,consistency=cached"]`
- use the dev container
- start the dev server with following optional env vars:
- `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://contributors.home-assistant.io/os/supervisor/core-connection/#get-access-to-the-supervisor-api) and set the host of your supervisor. e.g.: `SUPERVISOR_HOST=192.168.0.20:8888`
- `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://developers.home-assistant.io/docs/supervisor/development/#supervisor-api-access) and set the host of your supervisor. e.g.: `SUPERVISOR_HOST=192.168.0.20:8888`
- `SUPERVISOR_TOKEN` the supervisor api token you get from the Remote API proxy Addon Logs
- `FRONTEND_PATH` the path inside your container should be `/workspaces/frontend`
- example: `SUPERVISOR_TOKEN=abc123 SUPERVISOR_HOST=192.168.0.20:8888 FRONTEND_PATH=/workspaces/frontend go run main.go http.go mdns.go`
+1 -1
View File
@@ -6,7 +6,7 @@ export default {
],
"*.{json,css,md,markdown,html,ya?ml}": "prettier --cache --write",
"translations/*/*.json": (files) =>
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://contributors.home-assistant.io/translate/ for details." ' +
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://developers.home-assistant.io/docs/translations/ for details." ' +
files.join(" ") +
" >&2 && exit 1",
"pnpm-lock.yaml": () => "pnpm dedupe",
+43 -35
View File
@@ -40,21 +40,20 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.0.7",
"@babel/runtime": "8.0.5",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.11.1",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.13.1",
"@codemirror/language": "6.12.4",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.6",
"@codemirror/view": "6.43.14",
"@codemirror/view": "6.43.13",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.8.1",
"@formatjs/intl-datetimeformat": "7.8.0",
"@formatjs/intl-displaynames": "7.3.15",
"@formatjs/intl-durationformat": "0.11.1",
"@formatjs/intl-getcanonicallocales": "3.2.12",
@@ -63,6 +62,12 @@
"@formatjs/intl-numberformat": "9.4.3",
"@formatjs/intl-pluralrules": "6.3.15",
"@formatjs/intl-relativetimeformat": "12.3.15",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
"@fullcalendar/list": "6.1.21",
"@fullcalendar/luxon3": "6.1.21",
"@fullcalendar/timegrid": "6.1.21",
"@home-assistant/webawesome": "3.14.0-ha.0",
"@lezer/common": "1.5.3",
"@lezer/highlight": "1.2.5",
@@ -72,24 +77,27 @@
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@lit/task": "1.0.3",
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
"@maplibre/maplibre-gl-leaflet": "0.1.4",
"@material/mwc-formfield": "0.27.0",
"@material/mwc-list": "0.27.0",
"@material/web": "2.5.0",
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@versatiles/style": "6.4.1",
"@versatiles/style": "6.1.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.201.1",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"barcode-detector": "3.2.2",
"cally": "0.9.2",
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.50.0",
"cropperjs": "1.7.0",
"cropperjs": "1.6.3",
"culori": "4.0.2",
"date-fns": "4.4.0",
"deep-clone-simple": "1.1.1",
@@ -97,21 +105,21 @@
"echarts": "6.1.0",
"echarts-extension-chart2music": "0.1.1",
"element-internals-polyfill": "3.0.2",
"fullcalendar": "7.1.1",
"fuse.js": "7.5.0",
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.7.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.3",
"js-yaml": "5.4.3",
"intl-messageformat": "12.1.2",
"js-yaml": "5.4.2",
"leaflet": "1.9.4",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"maplibre-gl": "6.14.0",
"marked": "18.1.1",
"luxon": "3.7.2",
"maplibre-gl": "6.12.0",
"marked": "18.0.14",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"nanoid": "6.0.1",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
"punycode": "2.3.1",
@@ -122,7 +130,6 @@
"sortablejs": "1.15.7",
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"temporal-polyfill": "1.0.5",
"tinykeys": "4.0.1",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.1",
@@ -133,28 +140,28 @@
"workbox-strategies": "7.4.1",
"xss": "1.0.15",
"zrender": "6.1.0",
"zxing-wasm": "3.1.5"
"zxing-wasm": "3.1.4"
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.0.7",
"@babel/helper-compilation-targets": "8.0.7",
"@babel/core": "8.0.6",
"@babel/helper-compilation-targets": "8.0.6",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "8.0.6",
"@babel/preset-env": "8.0.7",
"@babel/preset-env": "8.0.6",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.67.0",
"@html-eslint/eslint-plugin": "0.66.1",
"@lokalise/node-api": "16.6.0",
"@octokit/auth-oauth-device": "8.0.5",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.64.1",
"@rsdoctor/rspack-plugin": "1.6.5",
"@rspack/core": "2.2.9",
"@playwright/test": "1.63.0",
"@rsdoctor/rspack-plugin": "1.6.4",
"@rspack/core": "2.2.8",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.27",
"@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",
@@ -163,24 +170,25 @@
"@types/leaflet": "1.9.22",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/node": "24.19.2",
"@types/luxon": "3.7.6",
"@types/node": "24.19.1",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "5.0.5",
"@vitest/coverage-v8": "5.0.3",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.29.3",
"browserslist-useragent-regexp": "4.1.4",
"core-js-compat": "3.50.0",
"del": "9.0.1",
"del": "8.0.1",
"eslint": "10.12.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
"eslint-plugin-lit": "2.3.1",
"eslint-plugin-lit-a11y": "5.1.3",
"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",
@@ -193,7 +201,7 @@
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "30.1.3",
"jsdom": "30.1.2",
"jszip": "3.10.2",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
@@ -204,7 +212,7 @@
"map-stream": "0.0.7",
"minify-literals": "2.2.0",
"pinst": "3.0.0",
"prettier": "3.9.10",
"prettier": "3.9.9",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.1.0",
@@ -212,16 +220,16 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.71.2",
"vite": "8.3.4",
"typescript-eslint": "8.71.0",
"vite": "8.3.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.5",
"vitest": "5.0.3",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "7.4.1"
},
"packageManager": "pnpm@12.14.0",
"packageManager": "pnpm@12.9.1",
"volta": {
"node": "24.22.0"
"node": "24.21.0"
}
}
+22
View File
@@ -0,0 +1,22 @@
diff --git a/mwc-formfield-base.js b/mwc-formfield-base.js
index 7b763326d7d51835ad52646bfbc80fe21989abd3..f2baa8224e6d03df1fdb0b9fd03f5c6d77fc8747 100644
--- a/mwc-formfield-base.js
+++ b/mwc-formfield-base.js
@@ -9,7 +9,7 @@ import { BaseElement } from '@material/mwc-base/base-element.js';
import { FormElement } from '@material/mwc-base/form-element.js';
import { observer } from '@material/mwc-base/observer.js';
import { html } from 'lit';
-import { property, query, queryAssignedNodes } from 'lit/decorators.js';
+import { property, query, queryAssignedElements } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
export class FormfieldBase extends BaseElement {
constructor() {
@@ -96,7 +96,7 @@ __decorate([
query('.mdc-form-field')
], FormfieldBase.prototype, "mdcRoot", void 0);
__decorate([
- queryAssignedNodes('', true, '*')
+ queryAssignedElements({ slot: "", flatten: true, selector: "*" })
], FormfieldBase.prototype, "slottedInputs", void 0);
__decorate([
query('label')
+1124 -1079
View File
File diff suppressed because it is too large. Load diff
+2
View File
@@ -26,9 +26,11 @@ overrides:
lit-html: 3.3.3
clean-css: 5.3.3
"@lit/reactive-element": 2.1.2
"@fullcalendar/daygrid": 6.1.21
tslib: 2.8.1
patchedDependencies:
"@material/[email protected]": patches/@[email protected]
"@material/[email protected]": patches/@[email protected]
[email protected]: patches/[email protected]
[email protected]: patches/[email protected]
@@ -1,9 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="12" y="13.25" width="6" height="1.5" rx="0.75" fill="black" fill-opacity="0.48"/>
<rect x="37" y="12.5" width="20" height="3" rx="1.5" fill="black" fill-opacity="0.32"/>
<rect x="76" y="13.25" width="6" height="1.5" rx="0.75" fill="black" fill-opacity="0.48"/>
<rect x="78.25" y="11" width="1.5" height="6" rx="0.75" fill="black" fill-opacity="0.48"/>
</svg>

Before

Width:  |  Height:  |  Size: 691 B

@@ -1,9 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="#1C1C1C"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="12" y="13.25" width="6" height="1.5" rx="0.75" fill="white" fill-opacity="0.72"/>
<rect x="37" y="12.5" width="20" height="3" rx="1.5" fill="white" fill-opacity="0.48"/>
<rect x="76" y="13.25" width="6" height="1.5" rx="0.75" fill="white" fill-opacity="0.72"/>
<rect x="78.25" y="11" width="1.5" height="6" rx="0.75" fill="white" fill-opacity="0.72"/>
</svg>

Before

Width:  |  Height:  |  Size: 693 B

@@ -1,7 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="8" y="8" width="46" height="12" rx="3" fill="black" fill-opacity="0.32"/>
<rect x="49" y="10.5" width="2" height="7" rx="1" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 478 B

@@ -1,7 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="#1C1C1C"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="8" y="8" width="46" height="12" rx="3" fill="white" fill-opacity="0.48"/>
<rect x="49" y="10.5" width="2" height="7" rx="1" fill="#1C1C1C"/>
</svg>

Before

Width:  |  Height:  |  Size: 482 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 223 KiB

-11
View File
@@ -1,11 +0,0 @@
#!/usr/bin/env bash
# Safe bash settings
# -e Exit on command fail
# -u Exit on unset variable
# -o pipefail Exit if piped command has error code
set -eu -o pipefail
cd "$(dirname "$0")/.."
./node_modules/.bin/gulp gen-demo-core-data
-11
View File
@@ -39,14 +39,3 @@ export const hexBlend = (c1: string, c2: string, blend = 50): string => {
}
return `#${color}`;
};
/**
* Converts a CSS color, like a color name, to `#rrggbb`.
* @param color - Any CSS color (name, hex, rgb(), hsl() and so on).
* @returns The hex color, or undefined for invalid colors and colors with
* transparency.
*/
export const cssColorToHex = (color: string): string | undefined => {
const parsed = parse(color.trim());
return parsed && (parsed.alpha ?? 1) === 1 ? formatHex(parsed) : undefined;
};
@@ -1,141 +0,0 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import { mainWindow } from "../dom/get_main_window";
import { replaceCurrentUrl } from "../navigate";
import { constructUrlCurrentPath } from "../url/construct-url";
import { currentPath } from "../url/current-path";
import {
updateQueryString,
type QueryParamConfig,
type QueryParamValues,
} from "../url/query-params";
const DEBOUNCE_WAIT = 300;
export interface QueryParamsControllerOptions {
/** Whether the host keeps its state in the URL. */
enabled: () => boolean;
/**
* Called when the page of the host is navigated to while the host is
* connected, like a second link to an open page or coming back to it. Read
* the URL with `searchParams()` and apply it, then `write()` the state back.
*/
navigated: () => void;
}
/**
* Keeps part of a page's state in the query string of the URL, so it can be
* shared, bookmarked and reloaded. Writes replace the current history entry,
* so Back leaves the page instead of undoing each change, and only touch the
* config's params. The controller only acts while the URL is still the
* page the host was connected on.
*/
export class QueryParamsController<
C extends QueryParamConfig,
> implements ReactiveController {
private _path?: string;
private _pending?: QueryParamValues<C>;
private _pendingTimeout?: number;
private _hostUpdated = false;
constructor(
private _host: ReactiveControllerHost,
private _config: C,
private _options: QueryParamsControllerOptions
) {
this._host.addController(this);
}
public hostConnected() {
this._path = currentPath();
mainWindow.addEventListener("location-changed", this._navigated);
mainWindow.addEventListener("popstate", this._navigated);
// Before the first update the host reads the URL itself, after its
// initial state is set
if (this._hostUpdated) {
this._navigated();
}
}
public hostUpdated() {
this._hostUpdated = true;
}
public hostDisconnected() {
mainWindow.removeEventListener("location-changed", this._navigated);
mainWindow.removeEventListener("popstate", this._navigated);
this._cancelPending();
}
/** The current search params, or undefined when not on the host's page. */
public searchParams(): URLSearchParams | undefined {
if (!this._isActive()) {
return undefined;
}
return new URLSearchParams(mainWindow.location.search);
}
/**
* Write values to the URL. Pass `debounced` for values that change while
* typing; any later write replaces a pending one.
*/
public write(values: QueryParamValues<NoInfer<C>>, debounced = false) {
this._cancelPending();
if (debounced) {
this._pending = values;
this._pendingTimeout = window.setTimeout(
() => this.flush(),
DEBOUNCE_WAIT
);
return;
}
this._write(values);
}
/**
* Write a pending debounced write now, like when typing ends, before
* something like a dialog adds a history entry it would land on.
*/
public flush() {
const values = this._pending;
this._cancelPending();
if (values) {
this._write(values);
}
}
private _cancelPending() {
clearTimeout(this._pendingTimeout);
this._pendingTimeout = undefined;
this._pending = undefined;
}
private _isActive(): boolean {
return this._options.enabled() && currentPath() === this._path;
}
private _navigated = () => {
this._cancelPending();
// Leaving the page gets here too, before the router swaps the page
if (this._isActive()) {
this._options.navigated();
}
};
private _write(values: QueryParamValues<C>) {
if (!this._isActive()) {
return;
}
const current = mainWindow.location.search;
const queryString = updateQueryString(current, values, this._config);
if (current !== (queryString ? `?${queryString}` : "")) {
replaceCurrentUrl(constructUrlCurrentPath(queryString));
}
}
}
@@ -66,7 +66,7 @@ export class ScrollFadeController {
if (!this._element) {
return;
}
const rtl = getComputedStyle(this._element).direction === "rtl";
const rtl = this._element.matches(":dir(rtl)");
const scrolled = rtl ? -this._element.scrollLeft : this._element.scrollLeft;
const maxScroll = this._element.scrollWidth - this._element.clientWidth;
const start = scrolled > 1;
-1
View File
@@ -13,7 +13,6 @@ export const canOverrideAlphanumericInput = (composedPath: EventTarget[]) => {
if (
el.tagName === "TEXTAREA" ||
(el as HTMLElement).isContentEditable ||
el.parentElement?.tagName === "HA-SELECT" ||
el.parentElement?.tagName === "HA-DROPDOWN"
) {
-27
View File
@@ -1,27 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "./compute_domain";
export const ALERT_DOMAINS = [
"alarm_control_panel",
"binary_sensor",
"cover",
"lock",
];
export const DEFAULT_ALERT_COLOR = "red";
export const computeAlertActiveStates = (entityId: string): string[] => {
switch (computeDomain(entityId)) {
case "alarm_control_panel":
return ["triggered"];
case "cover":
return ["open"];
case "lock":
return ["jammed", "unlocked", "open"];
default:
return ["on"];
}
};
export const isAlertActive = (stateObj: HassEntity): boolean =>
computeAlertActiveStates(stateObj.entity_id).includes(stateObj.state);
@@ -17,7 +17,7 @@ import { capitalizeFirstLetter } from "../string/capitalize-first-letter";
import { isDate } from "../string/is_date";
import { isTimestamp } from "../string/is_timestamp";
import { blankBeforeUnit } from "../translations/blank_before_unit";
import type { LocalizeFunc, LocalizeKeys } from "../translations/localize";
import type { LocalizeFunc } from "../translations/localize";
import { computeDomain } from "./compute_domain";
import { computeStateDomain } from "./compute_state_domain";
@@ -198,8 +198,6 @@ export const computeAttributeNameDisplay = (
localize(
`component.${domain}.entity_component._.state_attributes.${attribute}.name`
) ||
// Attributes every entity can have, which no integration translates
localize(`ui.components.attributes.names.${attribute}` as LocalizeKeys) ||
capitalizeFirstLetter(
attribute
.replace(/_/g, " ")
+23 -1
View File
@@ -1,7 +1,12 @@
import memoizeOne from "memoize-one";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
import { getDuplicates } from "../string/get_duplicates";
import { computeStateName } from "./compute_state_name";
export const computeDeviceName = (
device: DeviceRegistryEntry
@@ -9,15 +14,32 @@ export const computeDeviceName = (
export const computeDeviceNameDisplay = (
device: DeviceRegistryEntry,
localize: HomeAssistant["localize"]
localize: HomeAssistant["localize"],
hassStates: HomeAssistant["states"],
entities?: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
) =>
computeDeviceName(device) ||
(entities && fallbackDeviceName(hassStates, entities)) ||
localize("ui.panel.config.devices.unnamed_device", {
type: localize(
`ui.panel.config.devices.type.${device.entry_type || "device"}`
),
});
export const fallbackDeviceName = (
hassStates: HomeAssistant["states"],
entities: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
) => {
for (const entity of entities || []) {
const entityId = typeof entity === "string" ? entity : entity.entity_id;
const stateObj = hassStates[entityId];
if (stateObj) {
return computeStateName(stateObj);
}
}
return undefined;
};
export const getDuplicatedDeviceNames = memoizeOne(
(devices: HomeAssistant["devices"]): Set<string> => {
const names = Object.values(devices)
+75
View File
@@ -0,0 +1,75 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
import { computeDeviceName } from "./compute_device_name";
import { computeStateName } from "./compute_state_name";
import {
isDeviceName,
stripPrefixFromEntityName,
} from "./strip_prefix_from_entity_name";
export const computeEntityName = (
stateObj: HassEntity,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): string | undefined => {
const entry = entities[stateObj.entity_id] as
EntityRegistryDisplayEntry | undefined;
if (!entry) {
// Fall back to state name if not in the entity registry (friendly name)
return computeStateName(stateObj);
}
return computeEntityEntryName(entry, devices);
};
export const computeEntityEntryName = (
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
devices: HomeAssistant["devices"],
fallbackStateObj?: HassEntity
): string | undefined => {
const name =
entry.name ??
("original_name" in entry && entry.original_name != null
? String(entry.original_name)
: undefined);
const device = entry.device_id ? devices[entry.device_id] : undefined;
if (!device) {
if (name) {
return name;
}
if (fallbackStateObj) {
return computeStateName(fallbackStateObj);
}
return undefined;
}
const deviceName = computeDeviceName(device);
if (entry.name == null || entry.next_name_part !== "device") {
return name || undefined;
}
// An empty entity name or one that is only the device name means the entity uses the device name
if (!name || (deviceName && isDeviceName(name, deviceName))) {
return undefined;
}
// Remove the device name from the entity name if it starts with it
if (deviceName && name) {
return stripPrefixFromEntityName(name, deviceName) || name;
}
return name;
};
export const entityUseDeviceName = (
stateObj: HassEntity,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): boolean => !computeEntityName(stateObj, entities, devices);
+181 -125
View File
@@ -3,20 +3,19 @@ import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { HomeAssistant, HomeAssistantRegistries } from "../../types";
import type { HomeAssistant } from "../../types";
import { ensureArray } from "../array/ensure-array";
import { computeRTL } from "../util/compute_rtl";
import { computeAreaName } from "./compute_area_name";
import { computeDeviceName } from "./compute_device_name";
import {
computeEntityEntryName,
entityUseDeviceName,
} from "./compute_entity_name";
import { computeFloorName } from "./compute_floor_name";
import { computeStateName } from "./compute_state_name";
import { getEntityEntryContext } from "./context/get_entity_context";
import {
DEFAULT_ENTITY_NAME,
type EntityNameItem,
type EntityNameOptions,
type EntityNameType,
} from "./entity_name_config";
import type { EntityNameItem, EntityNameOptions } from "./entity_name_config";
const DEFAULT_SEPARATOR = " ";
@@ -27,12 +26,6 @@ export type {
EntityNameType,
} from "./entity_name_config";
export type EntityNameParts = Partial<Record<EntityNameType, string>>;
export interface EntityNamePartsOptions {
followContext?: boolean;
}
// Joins items that need no entity context. Returns undefined as soon as one
// item references the registries (device, area, ...).
const computeTextOnlyName = (
@@ -54,7 +47,7 @@ const computeTextOnlyName = (
*/
export const computeEntityNameDisplayWithoutContext = (
stateObj: HassEntity,
name?: string | EntityNameItem | EntityNameItem[],
name: string | EntityNameItem | EntityNameItem[] | undefined,
options?: EntityNameOptions
): string => {
if (typeof name === "string") {
@@ -69,140 +62,183 @@ export const computeEntityNameDisplayWithoutContext = (
);
};
export const computeEntityEntryName = (
entry: EntityRegistryDisplayEntry | EntityRegistryEntry
): string | undefined => {
const name =
entry.name ??
("original_name" in entry && entry.original_name != null
? String(entry.original_name)
: undefined);
return name || undefined;
export const computeEntityNameDisplay = (
stateObj: HassEntity,
name: string | EntityNameItem | EntityNameItem[] | undefined,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
options?: EntityNameOptions
) => {
if (typeof name === "string") {
return name;
}
// If no name config is provided, fall back to the friendly name
if (!name) {
return computeStateName(stateObj);
}
let items = ensureArray(name);
const separator = options?.separator ?? DEFAULT_SEPARATOR;
// If all items are text, just join them
const textOnlyName = computeTextOnlyName(items, options);
if (textOnlyName !== undefined) {
return textOnlyName;
}
const useDeviceName = entityUseDeviceName(stateObj, entities, devices);
// If entity uses device name, and device is not already included, replace it with device name
if (useDeviceName) {
const hasDevice = items.some((n) => n.type === "device");
if (!hasDevice) {
items = items.map((n) => (n.type === "entity" ? { type: "device" } : n));
}
}
const names = computeEntityNameList(
stateObj,
items,
entities,
devices,
areas,
floors,
{ keepAllParts: true }
);
// If after processing there is only one name, return that
if (names.length === 1) {
return names[0] || "";
}
return names.filter((n) => n).join(separator);
};
export const computeEntityEntryNameParts = (
export interface EntityNameListOptions {
keepAllParts?: boolean;
}
export const computeEntityNameList = (
stateObj: HassEntity,
name: EntityNameItem[],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
options?: EntityNameListOptions
): (string | undefined)[] => {
const entry = entities[stateObj.entity_id] as
EntityRegistryDisplayEntry | undefined;
if (!entry) {
return name.map((item) =>
item.type === "entity"
? computeStateName(stateObj)
: item.type === "text"
? item.text
: undefined
);
}
return computeEntityEntryNameList(
entry,
name,
entities,
devices,
areas,
floors,
options
);
};
export const computeEntityEntryNameList = (
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
registries: HomeAssistantRegistries,
options?: EntityNamePartsOptions
): EntityNameParts => {
name: EntityNameItem[],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
options?: EntityNameListOptions
): (string | undefined)[] => {
const { device, parentDevice, area, floor } = getEntityEntryContext(
entry,
registries.entities,
registries.devices,
registries.areas,
registries.floors
entities,
devices,
areas,
floors
);
const entityName = computeEntityEntryName(entry);
const followContext = options?.followContext ?? true;
const entityName = computeEntityEntryName(entry, devices);
const keepAllParts = options?.keepAllParts ?? false;
// Same rule as the backend: an owner only adds its name part while the
// next_name_part links reach it, so owners above the first node with its own
// area are left out. An entity without a name of its own is still named
// after its device.
const nameDevice =
!followContext || entry.next_name_part === "device" || !entityName
keepAllParts || entry.next_name_part === "device" || !entityName
? device
: null;
const nameParentDevice =
!followContext ||
keepAllParts ||
(entry.next_name_part === "device" &&
device?.next_name_part === "parent_device")
? parentDevice
: null;
return {
entity: entityName,
device: nameDevice ? computeDeviceName(nameDevice) : undefined,
parent_device: nameParentDevice
? computeDeviceName(nameParentDevice)
: undefined,
area: area ? computeAreaName(area) : undefined,
floor: floor ? computeFloorName(floor) : undefined,
};
};
export const computeEntityNameParts = (
stateObj: HassEntity,
registries: HomeAssistantRegistries,
options?: EntityNamePartsOptions
): EntityNameParts => {
const entry = registries.entities[stateObj.entity_id] as
EntityRegistryDisplayEntry | undefined;
if (!entry) {
return { entity: computeStateName(stateObj) };
}
return computeEntityEntryNameParts(entry, registries, options);
};
export const computeDefaultEntityNameItems = (
stateObj: HassEntity,
registries: HomeAssistantRegistries
): EntityNameItem[] => {
const parts = computeEntityNameParts(stateObj, registries);
return DEFAULT_ENTITY_NAME.filter((item) => parts[item.type]);
};
export const computeEntityNameDisplay = (
stateObj: HassEntity,
name: string | EntityNameItem | EntityNameItem[] | undefined,
registries: HomeAssistantRegistries,
options?: EntityNameOptions
): string => {
if (typeof name === "string") {
return name;
}
const separator = options?.separator ?? DEFAULT_SEPARATOR;
if (!name) {
const parts = computeEntityNameParts(stateObj, registries);
return (
DEFAULT_ENTITY_NAME.map((item) => parts[item.type])
.filter((n) => n)
.join(separator) || computeStateName(stateObj)
);
}
const items = ensureArray(name);
const textOnlyName = computeTextOnlyName(items, options);
if (textOnlyName !== undefined) {
return textOnlyName;
}
const parts = computeEntityNameParts(stateObj, registries, {
followContext: false,
return name.map((item) => {
switch (item.type) {
case "entity":
return entityName;
case "device":
return nameDevice ? computeDeviceName(nameDevice) : undefined;
case "parent_device":
return nameParentDevice
? computeDeviceName(nameParentDevice)
: undefined;
case "area":
return area ? computeAreaName(area) : undefined;
case "floor":
return floor ? computeFloorName(floor) : undefined;
case "text":
return item.text;
default:
return "";
}
});
// An entity without a name of its own is named after its device, unless the
// device is already part of the name
const useDeviceName =
!parts.entity && !items.some((item) => item.type === "device");
return items
.map((item) =>
item.type === "text"
? item.text
: parts[item.type === "entity" && useDeviceName ? "device" : item.type]
)
.filter((n) => n)
.join(separator);
};
export const computeEntitySearchLabels = (
stateObj: HassEntity,
registries: HomeAssistantRegistries
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
) => {
const parts = computeEntityNameParts(stateObj, registries, {
followContext: false,
});
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
entities,
devices,
areas,
floors,
{ keepAllParts: true }
);
return {
entityName: parts.entity || null,
entityName: entityName || null,
friendlyName: computeStateName(stateObj) || null,
deviceName: parts.device || null,
parentDeviceName: parts.parent_device || null,
areaName: parts.area || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
};
};
@@ -212,20 +248,40 @@ export interface EntityPickerDisplay {
}
export const computeEntityPickerDisplay = (
hass: HomeAssistantRegistries &
Pick<HomeAssistant, "language" | "translationMetadata">,
hass: Pick<
HomeAssistant,
| "entities"
| "devices"
| "areas"
| "floors"
| "language"
| "translationMetadata"
>,
stateObj: HassEntity
): EntityPickerDisplay => {
const parts = computeEntityNameParts(stateObj, hass);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const primary = parts.entity || parts.device || stateObj.entity_id;
const primary = entityName || deviceName || stateObj.entity_id;
const secondary =
[parts.area, parts.parent_device, parts.entity ? parts.device : undefined]
[areaName, parentDeviceName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
+1 -1
View File
@@ -7,7 +7,7 @@ export interface EntityLocation {
gpsAccuracy?: number;
}
export const findFirstActiveZone = (
const findFirstActiveZone = (
inZones: readonly string[],
states: HassEntities
): HassEntity | undefined => {
+5 -4
View File
@@ -4,7 +4,6 @@ import type { HomeAssistant } from "../../types";
import { stringCompare } from "../string/compare";
import { computeDomain } from "./compute_domain";
import { computeStateDomain } from "./compute_state_domain";
import { computeZoneTrackerState } from "../../data/zone";
export const FIXED_DOMAIN_STATES = {
alarm_control_panel: [
@@ -276,10 +275,12 @@ export const getStatesDomain = (
result.push(
...Object.entries(hass.states)
.filter(
([entityId]) =>
computeDomain(entityId) === "zone" && entityId !== "zone.home"
([entityId, stateObj]) =>
computeDomain(entityId) === "zone" &&
entityId !== "zone.home" &&
stateObj.attributes.friendly_name
)
.map(([_entityId, stateObj]) => computeZoneTrackerState(stateObj))
.map(([_entityId, stateObj]) => stateObj.attributes.friendly_name!)
.sort((zone1, zone2) =>
stringCompare(zone1, zone2, hass.locale.language)
)
+21
View File
@@ -0,0 +1,21 @@
/**
* Sort function to help sort states by name
*
* Usage:
* const states = [state1, state2]
* states.sort(statessortStatesByName);
*/
import type { HassEntity } from "home-assistant-js-websocket";
import { computeStateName } from "./compute_state_name";
export const sortStatesByName = (entityA: HassEntity, entityB: HassEntity) => {
const nameA = computeStateName(entityA);
const nameB = computeStateName(entityB);
if (nameA < nameB) {
return -1;
}
if (nameA > nameB) {
return 1;
}
return 0;
};
@@ -0,0 +1,48 @@
const SUFFIXES = [" ", ": ", " - "];
/**
* Strips a device name from an entity name.
* @param entityName the entity name
* @param prefix the prefix to strip
* @returns
*/
export const stripPrefixFromEntityName = (
entityName: string,
prefix: string
) => {
const lowerCasedEntityName = entityName.toLowerCase();
const lowerCasedPrefix = prefix.toLowerCase();
for (const suffix of SUFFIXES) {
const lowerCasedPrefixWithSuffix = `${lowerCasedPrefix}${suffix}`;
if (lowerCasedEntityName.startsWith(lowerCasedPrefixWithSuffix)) {
const newName = entityName.substring(lowerCasedPrefixWithSuffix.length);
if (newName.length) {
// If first word already has an upper case letter (e.g. from brand name)
// leave as-is, otherwise capitalize the first word.
return hasUpperCase(newName.substr(0, newName.indexOf(" ")))
? newName
: newName[0].toUpperCase() + newName.slice(1);
}
}
}
return undefined;
};
/**
* Whether an entity name is only the device name, which core treats as no name of its own.
*/
export const isDeviceName = (entityName: string, deviceName: string) => {
if (!deviceName) {
return false;
}
const lowerCasedEntityName = entityName.toLowerCase();
const lowerCasedDeviceName = deviceName.toLowerCase();
return (
lowerCasedEntityName.startsWith(lowerCasedDeviceName) &&
/^[ :-]*$/.test(lowerCasedEntityName.slice(lowerCasedDeviceName.length))
);
};
const hasUpperCase = (str: string): boolean => str.toLowerCase() !== str;
+24 -1
View File
@@ -1,6 +1,10 @@
import type { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
import type { Map as LeafletMap, TileLayerOptions } from "leaflet";
import type { setWorkerUrl, StyleSpecification } from "maplibre-gl";
import type {
setRTLTextPlugin,
setWorkerUrl,
StyleSpecification,
} from "maplibre-gl";
import { deepEqual } from "../util/deep-equal";
import type { LeafletModuleType } from "../dom/setup-leaflet-map";
import type { ResolvedMapStyle } from "./map-styles";
@@ -20,6 +24,10 @@ const SHIPPED_STYLES = {
dark: "/static/map/dark.json",
} as const;
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
// worker, hence a URL rather than an import.
export const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
// OSM serves no @2x variant.
const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`;
@@ -154,6 +162,20 @@ export const ensureWorkerUrl = (setUrl: typeof setWorkerUrl) => {
setUrl(new URL(__MAPLIBRE_WORKER_URL__, location.href).href);
};
// Global to MapLibre, and it throws when set twice.
let rtlTextPluginRequested = false;
export const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
if (rtlTextPluginRequested) {
return;
}
rtlTextPluginRequested = true;
setPlugin(new URL(RTL_TEXT_PLUGIN_URL, location.href).href, true).catch(
() => {
// RTL labels stay reversed; everything else still renders.
}
);
};
const createVectorLayer = async (
createLayer: typeof maplibreGL,
leaflet: LeafletModuleType,
@@ -368,6 +390,7 @@ export const createBaseLayer = async (
import("maplibre-gl"),
]);
ensureWorkerUrl(maplibre.setWorkerUrl);
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
vectorLayer = await createVectorLayer(
createLayer,
leaflet,
+8 -49
View File
@@ -16,6 +16,7 @@ import {
} from "../marker-accessibility";
import {
CONTEXT_RESTORE_GRACE,
ensureRTLTextPlugin,
ensureWorkerUrl,
loadStyle,
MAP_MAX_ZOOM,
@@ -319,6 +320,7 @@ export class MapLibreMapEngine implements MapEngine {
const maplibre = await import("maplibre-gl");
this._maplibre = maplibre;
ensureWorkerUrl(maplibre.setWorkerUrl);
ensureRTLTextPlugin(maplibre.setRTLTextPlugin);
// MapLibre's stylesheet for controls and popups; one link per root
const root = container.parentNode;
@@ -700,35 +702,6 @@ export class MapLibreMapEngine implements MapEngine {
return;
}
const map = this._map!;
if ("center" in fit) {
// cameraForBounds cannot solve a zero-area bound; center on it instead
if (this._clusterOptions) {
this._groupingZoom = fit.zoom;
try {
this._rebuildClusters(true);
} finally {
this._groupingZoom = undefined;
}
}
// Padding as an offset, not map padding, which would stick
const { top, right, bottom, left } = fit.padding;
const camera = {
center: [fit.center[1], fit.center[0]] as [number, number],
zoom: fit.zoom,
offset: [(left - right) / 2, (top - bottom) / 2] as [number, number],
animate: options?.animate,
};
// flyTo drops the offset under reduced motion (it jumps); easeTo keeps it
if (
options?.fly &&
!window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {
map.flyTo(camera);
} else {
map.easeTo(camera);
}
return;
}
// Regroup now for the zoom the fit lands on
const zoom = this._clusterOptions
? map.cameraForBounds(fit.bounds, fit.options)?.zoom
@@ -748,23 +721,7 @@ export class MapLibreMapEngine implements MapEngine {
});
}
private _fitFor(
points: MapLatLng[],
options?: MapFitOptions
):
| {
center: MapLatLng;
zoom: number;
padding: { top: number; right: number; bottom: number; left: number };
}
| {
bounds: [[number, number], [number, number]];
options: {
maxZoom: number | undefined;
padding: { top: number; right: number; bottom: number; left: number };
};
}
| undefined {
private _fitFor(points: MapLatLng[], options?: MapFitOptions) {
if (!this._map || !this._maplibre || !points.length) {
return undefined;
}
@@ -792,9 +749,11 @@ export class MapLibreMapEngine implements MapEngine {
if (minLat === maxLat && minLng === maxLng) {
// Zero-area bounds: center on the point, keeping the zoom unless given
return {
center: [minLat, minLng] as MapLatLng,
zoom: maxZoom ?? this._map.getZoom(),
padding,
bounds: [
[minLng, minLat],
[minLng, minLat],
] as [[number, number], [number, number]],
options: { maxZoom: maxZoom ?? this._map.getZoom(), padding },
};
}
const pad = options?.pad ?? 0.5;
+1 -1
View File
@@ -59,7 +59,7 @@ const NAME_EN: ExpressionSpecification = ["get", "name_en"];
// Strings compare by code point: anything from Basic Latin up to Latin
// Extended-B, digits and punctuation included.
const IS_LATIN: ExpressionSpecification = ["<", ["coalesce", NAME, ""], "ɐ"];
const IS_LATIN: ExpressionSpecification = ["<", NAME, "ɐ"];
const ENGLISH_SCALE = 0.8;
+1 -3
View File
@@ -4,8 +4,6 @@ import { theme2hex } from "../color/convert-color";
/** The zone marker: a colored circle with the zone's icon or initials, shared by the map and the zone editor */
export const ZONE_CIRCLE_SIZE = 36;
// Same as an entity marker's icon
const ZONE_ICON_SIZE = 24;
// WCAG minimum contrast for icons and other graphics
const MIN_CONTENT_CONTRAST = 3;
@@ -70,6 +68,6 @@ export const zoneMarkerStyles = `
overflow: hidden;
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
--mdc-icon-size: ${ZONE_ICON_SIZE}px;
--mdc-icon-size: ${ZONE_CIRCLE_SIZE / 2}px;
}
`;
Loaded 100 of 755 files, more files were not shown because too many files have changed in this diff. Show more