mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-30 12:11:42 +00:00
Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebd8feb801 | |||
| 9e8d38ea63 | |||
| 4fd976dc8c | |||
| f8d8dc4eaa | |||
| 8ccda740ee | |||
| 8528dd8a15 | |||
| ac2f8ebce3 | |||
| 1462f65f5a | |||
| 3e9d3d90a1 | |||
| f28898551b | |||
| eabbcf3a95 | |||
| 01255cebc6 | |||
| d20e062de9 | |||
| 835c0fa35c | |||
| e308272d89 | |||
| d2ae376058 | |||
| fdd57645ee | |||
| 1ef1655a4c | |||
| aa1108fc41 | |||
| e3c6a57080 | |||
| 1e22649ef8 | |||
| 5abd04d09a | |||
| a5bf35690b | |||
| d98eb47490 | |||
| 738e92d27d | |||
| ade2e9272b | |||
| d8ce60dfb6 | |||
| db9374925e | |||
| 1bcd1293c0 | |||
| b8cf061ebb | |||
| 6585da9a73 | |||
| 368df82e97 | |||
| 1d99a5dff9 | |||
| 0ca72b763a | |||
| 31848a1efd | |||
| c6f79c2093 | |||
| 1a5ab1903a | |||
| a410a53524 | |||
| 012889e51d | |||
| 3b3788b722 | |||
| 9414bbc6ab | |||
| 287aabc9a3 | |||
| 2d505048c5 | |||
| e07cbb9164 | |||
| 9c56ce6386 | |||
| 30fd803506 | |||
| 3ed9b7df8d | |||
| 1c38d80ab2 | |||
| 1c579e207f | |||
| 1b27445485 | |||
| e977d4a9ec | |||
| 001a842d2f | |||
| 473be7f8c8 | |||
| 0d9b257d4e | |||
| 22786df070 | |||
| e5c849359b | |||
| 4bfa4f2816 | |||
| afd86975d6 | |||
| 7b1eff9eef | |||
| 4f0c228756 | |||
| c86101ac6e | |||
| 29fa351b16 | |||
| 7c67633146 | |||
| 180e23ad9b | |||
| 9e7ddb3e5e | |||
| 4a0e46dc2c | |||
| 6af0040e73 | |||
| ba58ef6dc2 | |||
| fafbd7a674 | |||
| 07290a5d7e | |||
| 06141043a7 | |||
| 03e4f968b4 | |||
| 17d4f67f69 |
@@ -0,0 +1,190 @@
|
||||
name: Pull request standards
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, no PR code checkout
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- ready_for_review
|
||||
branches:
|
||||
- dev
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check pull request follows contribution standards
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write # To label and comment on pull requests
|
||||
steps:
|
||||
- name: Check pull request standards
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
|
||||
if (pr.user.type === "Bot") {
|
||||
core.info(`Skipping bot author: ${pr.user.login}`);
|
||||
return;
|
||||
}
|
||||
if (pr.draft) {
|
||||
core.info("Skipping draft pull request");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: pr.user.login,
|
||||
});
|
||||
core.info(`Skipping organization member: ${pr.user.login}`);
|
||||
return;
|
||||
} catch (error) {
|
||||
core.info(`${pr.user.login} is not an organization member, checking standards`);
|
||||
}
|
||||
|
||||
const label = "Needs Template";
|
||||
const marker = "<!-- pr-standards-check -->";
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = pr.number;
|
||||
|
||||
const body = (pr.body || "").replace(/<!--[\s\S]*?-->/g, "");
|
||||
const normalized = body.toLowerCase();
|
||||
|
||||
// Ignore 404s from mutations that race manual edits or cancelled runs.
|
||||
const ignoreMissing = async (fn) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info("Target already removed, nothing to do");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Hide/restore our comment via GraphQL (REST cannot minimize).
|
||||
const setMinimized = async (subjectId, minimized) => {
|
||||
const mutation = minimized
|
||||
? `mutation($id: ID!) {
|
||||
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
|
||||
clientMutationId
|
||||
}
|
||||
}`
|
||||
: `mutation($id: ID!) {
|
||||
unminimizeComment(input: { subjectId: $id }) {
|
||||
clientMutationId
|
||||
}
|
||||
}`;
|
||||
try {
|
||||
await github.graphql(mutation, { id: subjectId });
|
||||
} catch (error) {
|
||||
core.info(
|
||||
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Content of a "## <name>" section, or null when the heading is absent.
|
||||
const section = (name) => {
|
||||
const match = body.match(
|
||||
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
|
||||
);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const problems = [];
|
||||
|
||||
const requiredHeadings = [
|
||||
"## proposed change",
|
||||
"## type of change",
|
||||
"## checklist",
|
||||
];
|
||||
if (requiredHeadings.some((h) => !normalized.includes(h))) {
|
||||
problems.push(
|
||||
"Use the pull request template without removing its sections."
|
||||
);
|
||||
}
|
||||
|
||||
const typeOfChange = section("type of change");
|
||||
if (typeOfChange !== null) {
|
||||
const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length;
|
||||
if (ticked !== 1) {
|
||||
problems.push(
|
||||
'Select exactly one option under "Type of change".'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const proposedChange = section("proposed change");
|
||||
if (proposedChange !== null && proposedChange.trim().length === 0) {
|
||||
problems.push('Describe your changes under "Proposed change".');
|
||||
}
|
||||
|
||||
const isValid = problems.length === 0;
|
||||
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ owner, repo, issue_number, per_page: 100 }
|
||||
);
|
||||
const existing = comments.find((c) => c.body.includes(marker));
|
||||
const hasLabel = pr.labels.some((l) => l.name === label);
|
||||
|
||||
if (isValid) {
|
||||
core.info("Pull request standards met");
|
||||
|
||||
if (hasLabel) {
|
||||
await ignoreMissing(() =>
|
||||
github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number, name: label,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (existing) {
|
||||
await setMinimized(existing.node_id, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
|
||||
|
||||
if (!hasLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number, labels: [label],
|
||||
});
|
||||
}
|
||||
|
||||
const message =
|
||||
`${marker}\n` +
|
||||
`Hey @${pr.user.login}!\n\n` +
|
||||
`Thank you for your contribution! To help reviewers, please update ` +
|
||||
`this pull request to follow our pull request standards:\n\n` +
|
||||
problems.map((p) => `- ${p}`).join("\n") +
|
||||
`\n\n` +
|
||||
`Please complete the ` +
|
||||
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
|
||||
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
|
||||
`for more on creating a great pull request (see point 6).`;
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner, repo, comment_id: existing.id, body: message,
|
||||
});
|
||||
await setMinimized(existing.node_id, false);
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number, body: message,
|
||||
});
|
||||
}
|
||||
|
||||
// Fail this check so it can block the PR from being merged
|
||||
core.setFailed(
|
||||
`Pull request standards not met:\n- ${problems.join("\n- ")}`
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Sync numeric device classes
|
||||
|
||||
# Mirrors Home Assistant Core's numeric `SensorDeviceClass` list into the
|
||||
# build-time default in src/data/sensor_numeric_device_classes.ts and opens a PR
|
||||
# when it drifts. Reads homeassistant/generated/sensor.json from core.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 4 * * *" # Daily, 04:00 UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Regenerate numeric device classes
|
||||
run: ./script/gen_numeric_device_classes
|
||||
|
||||
- name: Format
|
||||
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
|
||||
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
branch: chore/sync-numeric-device-classes
|
||||
commit-message: Update numeric sensor device classes
|
||||
title: Update numeric sensor device classes
|
||||
body: |
|
||||
Regenerated `SENSOR_NUMERIC_DEVICE_CLASSES` from Home Assistant Core's
|
||||
`SensorDeviceClass`.
|
||||
|
||||
Automated by `.github/workflows/sync-numeric-device-classes.yaml`.
|
||||
+325
-325
File diff suppressed because one or more lines are too long
+1
-1
@@ -13,4 +13,4 @@ nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 3d
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.16.0.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.17.0.cjs
|
||||
|
||||
@@ -103,12 +103,29 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
|
||||
|
||||
if (!toProcess) {
|
||||
console.error("Unknown category", group.category);
|
||||
if (!group.pages) {
|
||||
if (!group.subsections && !group.pages) {
|
||||
group.pages = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (group.subsections) {
|
||||
// Listed pages keep their per-subsection order.
|
||||
for (const subsection of group.subsections) {
|
||||
for (const page of subsection.pages) {
|
||||
if (!toProcess.delete(page)) {
|
||||
console.error("Found unreferenced demo", page);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Any remaining pages land in a trailing "Other" subsection.
|
||||
const leftover = Array.from(toProcess).sort();
|
||||
if (leftover.length) {
|
||||
group.subsections.push({ header: "Other", pages: leftover });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Any pre-defined groups will not be sorted.
|
||||
if (group.pages) {
|
||||
for (const page of group.pages) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
import gulp from "gulp";
|
||||
import paths from "../paths.cjs";
|
||||
|
||||
const SOURCE_URL =
|
||||
process.env.SENSOR_METADATA_URL ||
|
||||
"https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/generated/sensor.json";
|
||||
|
||||
const TARGET = join(
|
||||
paths.root_dir,
|
||||
"src",
|
||||
"data",
|
||||
"sensor_numeric_device_classes.ts"
|
||||
);
|
||||
|
||||
gulp.task("gen-numeric-device-classes", async () => {
|
||||
const response = await fetch(SOURCE_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const classes = [...(data.numeric_device_classes ?? [])].sort();
|
||||
if (!classes.length) {
|
||||
throw new Error(`No numeric_device_classes found in ${SOURCE_URL}`);
|
||||
}
|
||||
|
||||
const content = `// This file is auto-generated from Home Assistant Core's \`SensorDeviceClass\`
|
||||
// (all values minus \`NON_NUMERIC_DEVICE_CLASSES\`). Do not edit by hand.
|
||||
// Regenerate with \`script/gen_numeric_device_classes\`.
|
||||
|
||||
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
|
||||
${classes.map((deviceClass) => ` "${deviceClass}",`).join("\n")}
|
||||
];
|
||||
`;
|
||||
|
||||
await writeFile(TARGET, content);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import "./fetch-nightly-translations.js";
|
||||
import "./gallery.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./gen-numeric-device-classes.js";
|
||||
import "./landing-page.js";
|
||||
import "./locale-data.js";
|
||||
import "./rspack.js";
|
||||
|
||||
+38
-3
@@ -8,7 +8,7 @@ import type { HomeAssistant } from "../../src/types";
|
||||
import { selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { mockAreaRegistry } from "./stubs/area_registry";
|
||||
import { mockAuth } from "./stubs/auth";
|
||||
import { mockConfigEntries } from "./stubs/config_entries";
|
||||
import { demoDevices } from "./stubs/devices";
|
||||
import { mockDeviceRegistry } from "./stubs/device_registry";
|
||||
import { mockEnergy } from "./stubs/energy";
|
||||
import { energyEntities } from "./stubs/entities";
|
||||
@@ -16,6 +16,7 @@ import { mockEntityRegistry } from "./stubs/entity_registry";
|
||||
import { mockEvents } from "./stubs/events";
|
||||
import { mockFloorRegistry } from "./stubs/floor_registry";
|
||||
import { mockFrontend } from "./stubs/frontend";
|
||||
import { mockIntegration } from "./stubs/integration";
|
||||
import { mockLabelRegistry } from "./stubs/label_registry";
|
||||
import { mockIcons } from "./stubs/icons";
|
||||
import { mockHistory } from "./stubs/history";
|
||||
@@ -29,6 +30,31 @@ import { mockTemplate } from "./stubs/template";
|
||||
import { mockTodo } from "./stubs/todo";
|
||||
import { mockTranslations } from "./stubs/translations";
|
||||
|
||||
// WS command / REST path prefixes whose mocks live in the lazily imported
|
||||
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
|
||||
const CONFIG_PANEL_COMMANDS = [
|
||||
"cloud/",
|
||||
"validate_config",
|
||||
"config_entries/",
|
||||
"device_automation/",
|
||||
"entity/source",
|
||||
"blueprint/",
|
||||
"homeassistant/expose",
|
||||
"zone/list",
|
||||
"person/list",
|
||||
"network/url",
|
||||
"application_credentials/",
|
||||
"system_health/",
|
||||
"backup/",
|
||||
"automation/config",
|
||||
"script/config",
|
||||
"config/automation/config",
|
||||
"config/script/config",
|
||||
"config/scene/config",
|
||||
"search/related",
|
||||
"tag/list",
|
||||
];
|
||||
|
||||
@customElement("ha-demo")
|
||||
export class HaDemo extends HomeAssistantAppEl {
|
||||
protected async _initializeHass() {
|
||||
@@ -61,9 +87,18 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockIcons(hass);
|
||||
mockEnergy(hass);
|
||||
mockPersistentNotification(hass);
|
||||
mockConfigEntries(hass);
|
||||
// Consumed app-wide via the lazy manifests context, so register eagerly.
|
||||
mockIntegration(hass);
|
||||
// Config panel mocks are code-split: the loader runs (and the chunk is
|
||||
// dynamically imported) the first time one of these config-only WS/REST
|
||||
// commands is requested, i.e. when the config panel is opened.
|
||||
hass.mockLazyLoad(
|
||||
(command) => CONFIG_PANEL_COMMANDS.some((p) => command.startsWith(p)),
|
||||
() =>
|
||||
import("./stubs/config-panel").then((mod) => mod.mockConfigPanel(hass))
|
||||
);
|
||||
mockAreaRegistry(hass);
|
||||
mockDeviceRegistry(hass);
|
||||
mockDeviceRegistry(hass, demoDevices);
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(hass);
|
||||
mockEntityRegistry(hass, [
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ApplicationCredential } from "../../../src/data/application_credential";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const credentials: ApplicationCredential[] = [
|
||||
{
|
||||
id: "mock-credential",
|
||||
domain: "spotify",
|
||||
client_id: "demo-client-id",
|
||||
client_secret: "demo-client-secret",
|
||||
name: "Spotify",
|
||||
},
|
||||
];
|
||||
|
||||
export const mockApplicationCredentials = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("application_credentials/list", () => credentials);
|
||||
hass.mockWS("application_credentials/config", () => ({
|
||||
integrations: { spotify: { description_placeholders: {} } },
|
||||
}));
|
||||
};
|
||||
@@ -3,4 +3,7 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
export const mockAuth = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("config/auth/list", () => []);
|
||||
hass.mockWS("auth/refresh_tokens", () => []);
|
||||
hass.mockWS("auth/sign_path", (msg: { path: string }) => ({
|
||||
path: msg.path,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -0,0 +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";
|
||||
|
||||
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 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", (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\/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;
|
||||
}
|
||||
);
|
||||
hass.mockWS(
|
||||
"condition_platforms/subscribe",
|
||||
(
|
||||
_msg,
|
||||
_hass,
|
||||
onChange?: (descriptions: Record<string, unknown>) => void
|
||||
) => {
|
||||
onChange?.({});
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
import type {
|
||||
BackupAgentsInfo,
|
||||
BackupConfig,
|
||||
BackupContent,
|
||||
BackupInfo,
|
||||
} from "../../../src/data/backup";
|
||||
import { BackupScheduleRecurrence } from "../../../src/data/backup";
|
||||
import type { ManagerStateEvent } from "../../../src/data/backup_manager";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const lastBackupDate = new Date(Date.now() - 86400000).toISOString();
|
||||
const nextBackupDate = new Date(Date.now() + 86400000).toISOString();
|
||||
|
||||
const backups: BackupContent[] = [
|
||||
{
|
||||
backup_id: "demo-backup-1",
|
||||
name: "Automatic backup DEMO",
|
||||
date: lastBackupDate,
|
||||
with_automatic_settings: true,
|
||||
agents: {
|
||||
"backup.local": { size: 1024 * 1024 * 512, protected: true },
|
||||
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const backupInfo: BackupInfo = {
|
||||
backups,
|
||||
agent_errors: {},
|
||||
last_attempted_automatic_backup: lastBackupDate,
|
||||
last_completed_automatic_backup: lastBackupDate,
|
||||
last_action_event: { manager_state: "idle" },
|
||||
next_automatic_backup: nextBackupDate,
|
||||
next_automatic_backup_additional: false,
|
||||
state: "idle",
|
||||
};
|
||||
|
||||
const backupConfig: BackupConfig = {
|
||||
automatic_backups_configured: true,
|
||||
last_attempted_automatic_backup: lastBackupDate,
|
||||
last_completed_automatic_backup: lastBackupDate,
|
||||
next_automatic_backup: nextBackupDate,
|
||||
next_automatic_backup_additional: false,
|
||||
create_backup: {
|
||||
agent_ids: ["backup.local", "cloud.cloud"],
|
||||
include_addons: [],
|
||||
include_all_addons: true,
|
||||
include_database: true,
|
||||
include_folders: [],
|
||||
name: null,
|
||||
password: null,
|
||||
},
|
||||
retention: { copies: 3, days: null },
|
||||
schedule: {
|
||||
recurrence: BackupScheduleRecurrence.DAILY,
|
||||
time: null,
|
||||
days: [],
|
||||
},
|
||||
agents: {
|
||||
"backup.local": { protected: true, retention: null },
|
||||
"cloud.cloud": { protected: true, retention: null },
|
||||
},
|
||||
};
|
||||
|
||||
const agentsInfo: BackupAgentsInfo = {
|
||||
agents: [
|
||||
{ agent_id: "backup.local", name: "This device" },
|
||||
{ agent_id: "cloud.cloud", name: "Home Assistant Cloud" },
|
||||
],
|
||||
};
|
||||
|
||||
export const mockBackup = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("backup/info", () => backupInfo);
|
||||
hass.mockWS("backup/config/info", () => ({ config: backupConfig }));
|
||||
hass.mockWS("backup/agents/info", () => agentsInfo);
|
||||
hass.mockWS(
|
||||
"backup/subscribe_events",
|
||||
(_msg, _hass, onChange?: (event: ManagerStateEvent) => void) => {
|
||||
onChange?.({ manager_state: "idle" });
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { BlueprintDomain, Blueprints } from "../../../src/data/blueprint";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const automationBlueprints: Blueprints = {
|
||||
"homeassistant/motion_light.yaml": {
|
||||
metadata: {
|
||||
domain: "automation",
|
||||
name: "Motion-activated Light",
|
||||
description: "Turn on a light when motion is detected.",
|
||||
author: "Home Assistant",
|
||||
source_url:
|
||||
"https://github.com/home-assistant/core/blob/dev/homeassistant/components/automation/blueprints/motion_light.yaml",
|
||||
input: {
|
||||
motion_entity: { name: "Motion Sensor" },
|
||||
light_target: { name: "Light" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"homeassistant/notify_leaving_zone.yaml": {
|
||||
metadata: {
|
||||
domain: "automation",
|
||||
name: "Send notification when leaving a zone",
|
||||
description: "Get a notification when a person leaves a zone.",
|
||||
author: "Home Assistant",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const scriptBlueprints: Blueprints = {
|
||||
"homeassistant/confirmable_notification.yaml": {
|
||||
metadata: {
|
||||
domain: "script",
|
||||
name: "Confirmable Notification",
|
||||
description:
|
||||
"A script that sends an actionable notification with a confirmation.",
|
||||
author: "Home Assistant",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockBlueprint = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("blueprint/list", (msg: { domain: BlueprintDomain }) =>
|
||||
msg.domain === "script" ? scriptBlueprints : automationBlueprints
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
CloudStatusLoggedIn,
|
||||
SubscriptionInfo,
|
||||
} from "../../../src/data/cloud";
|
||||
import type { CloudTTSInfo } from "../../../src/data/cloud/tts";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const emptyFilter = () => ({
|
||||
include_domains: [],
|
||||
include_entities: [],
|
||||
exclude_domains: [],
|
||||
exclude_entities: [],
|
||||
});
|
||||
|
||||
// A single mutable status object so that preference changes made in the demo
|
||||
// are reflected back in the UI.
|
||||
const cloudStatus: CloudStatusLoggedIn = {
|
||||
logged_in: true,
|
||||
cloud: "connected",
|
||||
cloud_last_disconnect_reason: null,
|
||||
email: "demo@home-assistant.io",
|
||||
google_registered: true,
|
||||
google_entities: emptyFilter(),
|
||||
google_domains: ["light", "switch", "climate", "cover"],
|
||||
alexa_registered: true,
|
||||
alexa_entities: emptyFilter(),
|
||||
remote_domain: "demo-instance.ui.nabu.casa",
|
||||
remote_connected: true,
|
||||
remote_certificate: {
|
||||
common_name: "demo-instance.ui.nabu.casa",
|
||||
expire_date: "2099-01-01T00:00:00+00:00",
|
||||
fingerprint: "demodemodemodemodemodemodemodemodemodemodemodemodemo",
|
||||
alternative_names: ["demo-instance.ui.nabu.casa"],
|
||||
},
|
||||
remote_certificate_status: "ready",
|
||||
http_use_ssl: false,
|
||||
active_subscription: true,
|
||||
prefs: {
|
||||
google_enabled: true,
|
||||
alexa_enabled: true,
|
||||
remote_enabled: true,
|
||||
remote_allow_remote_enable: true,
|
||||
strict_connection: "disabled",
|
||||
google_secure_devices_pin: undefined,
|
||||
cloudhooks: {},
|
||||
alexa_report_state: true,
|
||||
google_report_state: true,
|
||||
tts_default_voice: ["en-US", "JennyNeural"],
|
||||
cloud_ice_servers_enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
const subscription: SubscriptionInfo = {
|
||||
human_description: "Demo subscription, renews automatically",
|
||||
provider: "Nabu Casa, Inc.",
|
||||
plan_renewal_date: 4102444800,
|
||||
};
|
||||
|
||||
const ttsInfo: CloudTTSInfo = {
|
||||
languages: [
|
||||
["en-US", "JennyNeural", "Jenny"],
|
||||
["en-US", "GuyNeural", "Guy"],
|
||||
["en-GB", "LibbyNeural", "Libby"],
|
||||
["nl-NL", "ColetteNeural", "Colette"],
|
||||
["de-DE", "KatjaNeural", "Katja"],
|
||||
],
|
||||
};
|
||||
|
||||
export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("cloud/status", () => cloudStatus);
|
||||
hass.mockWS("cloud/subscription", () => subscription);
|
||||
hass.mockWS("cloud/tts/info", () => ttsInfo);
|
||||
|
||||
hass.mockWS("cloud/update_prefs", (msg) => {
|
||||
const { type, ...prefs } = msg;
|
||||
cloudStatus.prefs = { ...cloudStatus.prefs, ...prefs };
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/cloudhook/create", (msg) => {
|
||||
const webhook = {
|
||||
webhook_id: msg.webhook_id,
|
||||
cloudhook_id: "demo-cloudhook-id",
|
||||
cloudhook_url: `https://hooks.nabu.casa/demo-${msg.webhook_id}`,
|
||||
managed: false,
|
||||
};
|
||||
cloudStatus.prefs.cloudhooks = {
|
||||
...cloudStatus.prefs.cloudhooks,
|
||||
[msg.webhook_id]: webhook,
|
||||
};
|
||||
return webhook;
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/cloudhook/delete", (msg) => {
|
||||
const cloudhooks = { ...cloudStatus.prefs.cloudhooks };
|
||||
delete cloudhooks[msg.webhook_id];
|
||||
cloudStatus.prefs.cloudhooks = cloudhooks;
|
||||
return null;
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/remote/connect", () => {
|
||||
cloudStatus.remote_connected = true;
|
||||
return null;
|
||||
});
|
||||
hass.mockWS("cloud/remote/disconnect", () => {
|
||||
cloudStatus.remote_connected = false;
|
||||
return null;
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/remove_data", () => null);
|
||||
hass.mockWS("cloud/google_assistant/entities/update", () => null);
|
||||
hass.mockWS("cloud/alexa/entities", () => []);
|
||||
hass.mockWS("cloud/google_assistant/entities", () => []);
|
||||
|
||||
hass.mockAPI("cloud/logout", () => ({}));
|
||||
hass.mockAPI("cloud/google_actions/sync", () => ({}));
|
||||
hass.mockAPI("cloud/support_package", () => "Demo support package");
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { mockApplicationCredentials } from "./application_credentials";
|
||||
import { mockAutomation } from "./automation";
|
||||
import { mockBackup } from "./backup";
|
||||
import { mockBlueprint } from "./blueprint";
|
||||
import { mockCloud } from "./cloud";
|
||||
import { mockConfig } from "./config";
|
||||
import { mockConfigEntries } from "./config_entries";
|
||||
import { mockDeviceAutomation } from "./device_automation";
|
||||
import { mockEntitySources } from "./entity_sources";
|
||||
import { mockExpose } from "./expose";
|
||||
import { mockNetwork } from "./network";
|
||||
import { mockPerson } from "./person";
|
||||
import { mockScene } from "./scene";
|
||||
import { mockSearch } from "./search";
|
||||
import { mockSystemHealth } from "./system_health";
|
||||
import { mockTags } from "./tags";
|
||||
import { mockZone } from "./zone";
|
||||
|
||||
// Registers every mock that is only needed once the config panel is opened.
|
||||
// This module is dynamically imported so its data stays out of the main bundle.
|
||||
export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockCloud(hass);
|
||||
mockConfig(hass);
|
||||
mockConfigEntries(hass);
|
||||
mockDeviceAutomation(hass);
|
||||
mockEntitySources(hass);
|
||||
mockBlueprint(hass);
|
||||
mockExpose(hass);
|
||||
mockZone(hass);
|
||||
mockPerson(hass);
|
||||
mockNetwork(hass);
|
||||
mockApplicationCredentials(hass);
|
||||
mockSystemHealth(hass);
|
||||
mockBackup(hass);
|
||||
mockAutomation(hass);
|
||||
mockScene(hass);
|
||||
mockSearch(hass);
|
||||
mockTags(hass);
|
||||
};
|
||||
@@ -1,26 +1,126 @@
|
||||
import type { getConfigEntries } from "../../../src/data/config_entries";
|
||||
import type {
|
||||
ConfigEntry,
|
||||
ConfigEntryUpdate,
|
||||
} from "../../../src/data/config_entries";
|
||||
import type { ConfigFlowInProgressMessage } from "../../../src/data/config_flow";
|
||||
import type { IntegrationType } from "../../../src/data/integration";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockConfigEntries = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS<typeof getConfigEntries>("config_entries/get", () => [
|
||||
{
|
||||
entry_id: "mock-entry-co2signal",
|
||||
const baseEntry = {
|
||||
source: "user",
|
||||
state: "loaded" as const,
|
||||
supports_options: false,
|
||||
supports_remove_device: false,
|
||||
supports_unload: true,
|
||||
supports_reconfigure: true,
|
||||
supported_subentry_types: {},
|
||||
num_subentries: 0,
|
||||
pref_disable_new_entities: false,
|
||||
pref_disable_polling: false,
|
||||
disabled_by: null,
|
||||
reason: null,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
};
|
||||
|
||||
// Each entry is tagged with its integration type so we can honor the
|
||||
// `type_filter` that the integrations and helpers panels subscribe with.
|
||||
export const demoConfigEntries: {
|
||||
entry: ConfigEntry;
|
||||
type: IntegrationType;
|
||||
}[] = [
|
||||
{
|
||||
type: "service",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "co2signal",
|
||||
domain: "co2signal",
|
||||
title: "Electricity Maps",
|
||||
source: "user",
|
||||
state: "loaded",
|
||||
supports_options: false,
|
||||
supports_remove_device: false,
|
||||
supports_unload: true,
|
||||
supports_reconfigure: true,
|
||||
supported_subentry_types: {},
|
||||
pref_disable_new_entities: false,
|
||||
pref_disable_polling: false,
|
||||
disabled_by: null,
|
||||
reason: null,
|
||||
num_subentries: 0,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
},
|
||||
]);
|
||||
},
|
||||
{
|
||||
type: "hub",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-hue",
|
||||
domain: "hue",
|
||||
title: "Philips Hue",
|
||||
source: "zeroconf",
|
||||
supports_options: true,
|
||||
supports_remove_device: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "hub",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-sonos",
|
||||
domain: "sonos",
|
||||
title: "Sonos",
|
||||
source: "zeroconf",
|
||||
supports_options: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "service",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-met",
|
||||
domain: "met",
|
||||
title: "Forecast.Home",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "helper",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-template-helper",
|
||||
domain: "template",
|
||||
title: "Comfort level",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const filterEntries = (filters?: {
|
||||
type_filter?: IntegrationType[];
|
||||
domain?: string;
|
||||
}): ConfigEntry[] =>
|
||||
demoConfigEntries
|
||||
.filter(
|
||||
(e) =>
|
||||
(!filters?.type_filter || filters.type_filter.includes(e.type)) &&
|
||||
(!filters?.domain || filters.domain === e.entry.domain)
|
||||
)
|
||||
.map((e) => e.entry);
|
||||
|
||||
export const mockConfigEntries = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"config_entries/get",
|
||||
(msg: { type_filter?: IntegrationType[]; domain?: string }) =>
|
||||
filterEntries(msg)
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"config_entries/subscribe",
|
||||
(
|
||||
msg: { type_filter?: IntegrationType[]; domain?: string },
|
||||
_hass,
|
||||
onChange?: (updates: ConfigEntryUpdate[]) => void
|
||||
) => {
|
||||
onChange?.(filterEntries(msg).map((entry) => ({ type: null, entry })));
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"config_entries/flow/subscribe",
|
||||
(
|
||||
_msg,
|
||||
_hass,
|
||||
onChange?: (updates: ConfigFlowInProgressMessage[]) => void
|
||||
) => {
|
||||
onChange?.([]);
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
// The demo's devices don't expose device-specific automations, so report empty
|
||||
// lists and no extra capability fields for the device automation pickers.
|
||||
export const mockDeviceAutomation = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("device_automation/trigger/list", () => []);
|
||||
hass.mockWS("device_automation/condition/list", () => []);
|
||||
hass.mockWS("device_automation/action/list", () => []);
|
||||
hass.mockWS("device_automation/trigger/capabilities", () => ({
|
||||
extra_fields: [],
|
||||
}));
|
||||
hass.mockWS("device_automation/condition/capabilities", () => ({
|
||||
extra_fields: [],
|
||||
}));
|
||||
hass.mockWS("device_automation/action/capabilities", () => ({
|
||||
extra_fields: [],
|
||||
}));
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
|
||||
const baseDevice = {
|
||||
config_entries_subentries: {},
|
||||
connections: [] as [string, string][],
|
||||
identifiers: [] as [string, string][],
|
||||
model_id: null,
|
||||
labels: [] as string[],
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
serial_number: null,
|
||||
via_device_id: null,
|
||||
area_id: null,
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
|
||||
export const demoDevices: DeviceRegistryEntry[] = [
|
||||
{
|
||||
...baseDevice,
|
||||
id: "co2signal",
|
||||
name: "Electricity Maps",
|
||||
manufacturer: "Electricity Maps",
|
||||
model: "CO2 Signal",
|
||||
config_entries: ["co2signal"],
|
||||
primary_config_entry: "co2signal",
|
||||
entry_type: "service",
|
||||
},
|
||||
{
|
||||
...baseDevice,
|
||||
id: "hue-bridge",
|
||||
name: "Philips Hue Bridge",
|
||||
manufacturer: "Signify",
|
||||
model: "Hue Bridge (BSB002)",
|
||||
sw_version: "1.50.0",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
},
|
||||
{
|
||||
...baseDevice,
|
||||
id: "sonos-living",
|
||||
name: "Living Room",
|
||||
manufacturer: "Sonos",
|
||||
model: "One",
|
||||
config_entries: ["mock-sonos"],
|
||||
primary_config_entry: "mock-sonos",
|
||||
entry_type: null,
|
||||
},
|
||||
];
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
|
||||
import type {
|
||||
EntityRegistryEntry,
|
||||
ExtEntityRegistryEntry,
|
||||
} from "../../../src/data/entity/entity_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockEntityRegistry = (
|
||||
@@ -6,4 +9,17 @@ export const mockEntityRegistry = (
|
||||
data: EntityRegistryEntry[] = []
|
||||
) => {
|
||||
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 = data.find((e) => e.entity_id === entityId);
|
||||
if (entry) {
|
||||
result[entityId] = { ...entry, capabilities: {}, aliases: [] };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { EntitySources } from "../../../src/data/entity/entity_sources";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockEntitySources = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"entity/source",
|
||||
(): EntitySources => ({
|
||||
"sensor.co2_intensity": { domain: "co2signal" },
|
||||
"sensor.grid_fossil_fuel_percentage": { domain: "co2signal" },
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ExposeEntitySettings } from "../../../src/data/expose";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const exposedEntities: Record<string, ExposeEntitySettings> = {
|
||||
"light.bed_light": {
|
||||
conversation: true,
|
||||
"cloud.alexa": true,
|
||||
"cloud.google_assistant": true,
|
||||
},
|
||||
"light.ceiling_lights": {
|
||||
conversation: true,
|
||||
"cloud.alexa": true,
|
||||
"cloud.google_assistant": false,
|
||||
},
|
||||
"switch.decorative_lights": {
|
||||
conversation: true,
|
||||
"cloud.alexa": false,
|
||||
"cloud.google_assistant": true,
|
||||
},
|
||||
"climate.ecobee": {
|
||||
conversation: true,
|
||||
"cloud.alexa": true,
|
||||
"cloud.google_assistant": true,
|
||||
},
|
||||
};
|
||||
|
||||
export const mockExpose = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("homeassistant/expose_entity/list", () => ({
|
||||
exposed_entities: exposedEntities,
|
||||
}));
|
||||
hass.mockWS(
|
||||
"homeassistant/expose_new_entities/get",
|
||||
(msg: { assistant: string }) => ({
|
||||
expose_new: msg.assistant !== "cloud.google_assistant",
|
||||
})
|
||||
);
|
||||
hass.mockWS("homeassistant/expose_entity", () => null);
|
||||
hass.mockWS("homeassistant/expose_new_entities/set", () => null);
|
||||
};
|
||||
@@ -42,6 +42,7 @@ export const mockFrontend = (hass: MockHomeAssistant) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return () => {};
|
||||
});
|
||||
hass.mockWS("frontend/get_system_data", () => ({ value: null }));
|
||||
hass.mockWS("repairs/list_issues", () => ({ issues: [] }));
|
||||
hass.mockWS("frontend/get_themes", (_msg, currentHass) => currentHass.themes);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { IntegrationManifest } from "../../../src/data/integration";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const manifest = (
|
||||
domain: string,
|
||||
name: string,
|
||||
overrides: Partial<IntegrationManifest> = {}
|
||||
): IntegrationManifest => ({
|
||||
is_built_in: true,
|
||||
domain,
|
||||
name,
|
||||
config_flow: true,
|
||||
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
|
||||
iot_class: "local_push",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const manifests: IntegrationManifest[] = [
|
||||
manifest("co2signal", "Electricity Maps", { iot_class: "cloud_polling" }),
|
||||
manifest("hue", "Philips Hue"),
|
||||
manifest("sonos", "Sonos"),
|
||||
manifest("met", "Met.no", { iot_class: "cloud_polling" }),
|
||||
// Helpers
|
||||
manifest("template", "Template", { integration_type: "helper" }),
|
||||
manifest("input_boolean", "Toggle", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("input_number", "Number", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("input_select", "Dropdown", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("input_text", "Text", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("input_datetime", "Date and/or time", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("counter", "Counter", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("timer", "Timer", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("schedule", "Schedule", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
];
|
||||
|
||||
export const mockIntegration = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("manifest/list", () => manifests);
|
||||
hass.mockWS("manifest/get", (msg: { integration: string }) =>
|
||||
manifests.find((m) => m.domain === msg.integration)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { NetworkUrls } from "../../../src/data/network";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockNetwork = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"network/url",
|
||||
(): NetworkUrls => ({
|
||||
internal: "http://homeassistant.local:8123",
|
||||
external: "https://demo-instance.ui.nabu.casa",
|
||||
cloud: "https://demo-instance.ui.nabu.casa",
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Person } from "../../../src/data/person";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const storage: Person[] = [
|
||||
{
|
||||
id: "demo_user",
|
||||
name: "Demo User",
|
||||
user_id: "abcd",
|
||||
device_trackers: [],
|
||||
},
|
||||
{
|
||||
id: "anne_therese",
|
||||
name: "Anne Therese",
|
||||
device_trackers: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockPerson = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("person/list", () => ({ storage, config: [] as Person[] }));
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { SceneConfig } from "../../../src/data/scene";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const demoSceneConfig = (id: string): SceneConfig => ({
|
||||
id,
|
||||
name: "Demo scene",
|
||||
entities: {
|
||||
"light.bed_light": { state: "on" },
|
||||
},
|
||||
});
|
||||
|
||||
export const mockScene = (hass: MockHomeAssistant) => {
|
||||
hass.mockAPI(/config\/scene\/config\/.+/, (_hass, method, path) => {
|
||||
const id = path.split("/").pop()!;
|
||||
// GET returns the config; POST/DELETE just acknowledge.
|
||||
return method === "GET" ? demoSceneConfig(id) : {};
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { RelatedResult } from "../../../src/data/search";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockSearch = (hass: MockHomeAssistant) => {
|
||||
// The demo has no relationship graph, so report no related items.
|
||||
hass.mockWS("search/related", (): RelatedResult => ({}));
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockSystemHealth = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"system_health/info",
|
||||
(_msg, _hass, onChange?: (event: any) => void) => {
|
||||
// Defer so the consumer's unsubscribe handle is initialized first
|
||||
// (real WS events arrive asynchronously).
|
||||
setTimeout(() => {
|
||||
onChange?.({
|
||||
type: "initial",
|
||||
data: {
|
||||
homeassistant: {
|
||||
info: {
|
||||
version: "DEMO",
|
||||
installation_type: "Home Assistant OS",
|
||||
dev: false,
|
||||
hassio: true,
|
||||
docker: true,
|
||||
container_arch: "aarch64",
|
||||
user: "root",
|
||||
virtualenv: false,
|
||||
python_version: "3.13.0",
|
||||
os_name: "Linux",
|
||||
os_version: "6.6.0",
|
||||
arch: "aarch64",
|
||||
timezone: "America/Los_Angeles",
|
||||
config_dir: "/config",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,33 @@
|
||||
import type { LoggedError } from "../../../src/data/system_log";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
const logs: LoggedError[] = [
|
||||
{
|
||||
name: "homeassistant.components.demo",
|
||||
message: ["Demo integration failed to update sensor data"],
|
||||
level: "warning",
|
||||
source: ["components/demo/sensor.py", 142],
|
||||
exception: "",
|
||||
count: 2,
|
||||
timestamp: now - 120,
|
||||
first_occurred: now - 3600,
|
||||
},
|
||||
{
|
||||
name: "homeassistant.config_entries",
|
||||
message: ["Config entry for met.no could not be set up"],
|
||||
level: "error",
|
||||
source: ["config_entries.py", 512],
|
||||
exception:
|
||||
'Traceback (most recent call last):\n File "config_entries.py", line 512',
|
||||
count: 1,
|
||||
timestamp: now - 600,
|
||||
first_occurred: now - 600,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockSystemLog = (hass: MockHomeAssistant) => {
|
||||
hass.mockAPI("error/all", () => []);
|
||||
hass.mockWS("system_log/list", () => logs);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Zone } from "../../../src/data/zone";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const zones: Zone[] = [
|
||||
{
|
||||
id: "home",
|
||||
name: "Home",
|
||||
icon: "mdi:home",
|
||||
latitude: 52.3731339,
|
||||
longitude: 4.8903147,
|
||||
radius: 100,
|
||||
passive: false,
|
||||
},
|
||||
{
|
||||
id: "work",
|
||||
name: "Work",
|
||||
icon: "mdi:briefcase",
|
||||
latitude: 52.3909184,
|
||||
longitude: 4.8530821,
|
||||
radius: 200,
|
||||
passive: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockZone = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("zone/list", () => zones);
|
||||
};
|
||||
@@ -62,6 +62,17 @@ Use `sidebar.js` when a page needs a visible section, section header, or determi
|
||||
- New categories without a sidebar entry are appended by the generator with their category name as the header.
|
||||
- If a listed page does not exist, the generator logs an error during `gather-gallery-pages`.
|
||||
|
||||
### Subsections
|
||||
|
||||
A section can group its pages under named subsections instead of one flat list. Use this for large categories where related pages should sit together.
|
||||
|
||||
- `subsections` is an array of `{ header, pages }`. It is mutually exclusive with a flat `pages` array on the same group.
|
||||
- Each subsection `header` is a non-collapsible label rendered inside the section's expansion panel; the section stays the only collapsible level.
|
||||
- Listed pages keep their per-subsection order.
|
||||
- Any pages found in the category but not listed in a subsection are collected into a generated `Other` subsection, appended alphabetically. The `Other` subsection is omitted when there are no leftovers.
|
||||
- A listed page that does not exist still logs an error during `gather-gallery-pages`.
|
||||
- Use sentence case for subsection headers and follow the content standards below.
|
||||
|
||||
## Markdown Pages
|
||||
|
||||
Use markdown pages for explanations, design guidance, API notes, and copy standards.
|
||||
|
||||
+164
-9
@@ -10,6 +10,10 @@ import {
|
||||
mdiViewDashboard,
|
||||
} from "@mdi/js";
|
||||
|
||||
// A group may list its pages flat in `pages`, or group them under named
|
||||
// `subsections`. The two are mutually exclusive. Listed pages keep their order;
|
||||
// any pages found in the category but not listed are appended alphabetically
|
||||
// (to a generated "Other" subsection when the group uses subsections).
|
||||
export default [
|
||||
{
|
||||
// This section has no header and so all page links are shown directly in the sidebar
|
||||
@@ -27,31 +31,162 @@ export default [
|
||||
category: "components",
|
||||
icon: mdiPuzzle,
|
||||
header: "Components",
|
||||
subsections: [
|
||||
{
|
||||
header: "Form and selectors",
|
||||
pages: [
|
||||
"ha-form",
|
||||
"ha-selector",
|
||||
"ha-select-box",
|
||||
"ha-input",
|
||||
"ha-textarea",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Controls and sliders",
|
||||
pages: [
|
||||
"ha-button",
|
||||
"ha-control-button",
|
||||
"ha-progress-button",
|
||||
"ha-switch",
|
||||
"ha-control-switch",
|
||||
"ha-slider",
|
||||
"ha-control-slider",
|
||||
"ha-control-circular-slider",
|
||||
"ha-control-number-buttons",
|
||||
"ha-control-select",
|
||||
"ha-control-select-menu",
|
||||
"ha-hs-color-picker",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Overlays",
|
||||
pages: [
|
||||
"ha-dialog",
|
||||
"ha-dialogs",
|
||||
"ha-adaptive-dialog",
|
||||
"ha-adaptive-popover",
|
||||
"ha-dropdown",
|
||||
"ha-tooltip",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Lists and disclosure",
|
||||
pages: ["ha-list", "ha-expansion-panel", "ha-faded"],
|
||||
},
|
||||
{
|
||||
header: "Feedback and status",
|
||||
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
|
||||
},
|
||||
{
|
||||
header: "Labels and text",
|
||||
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "lovelace",
|
||||
icon: mdiViewDashboard,
|
||||
// Label for in the sidebar
|
||||
header: "Dashboards",
|
||||
// Specify order of pages. Any pages in the category folder but not listed here will
|
||||
// automatically be added after the pages listed here.
|
||||
pages: ["introduction"],
|
||||
subsections: [
|
||||
{
|
||||
header: "Introduction",
|
||||
pages: ["introduction"],
|
||||
},
|
||||
{
|
||||
header: "Entity cards",
|
||||
pages: [
|
||||
"entities-card",
|
||||
"entity-button-card",
|
||||
"entity-filter-card",
|
||||
"glance-card",
|
||||
"tile-card",
|
||||
"area-card",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Picture cards",
|
||||
pages: [
|
||||
"picture-card",
|
||||
"picture-elements-card",
|
||||
"picture-entity-card",
|
||||
"picture-glance-card",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Domain cards",
|
||||
pages: [
|
||||
"light-card",
|
||||
"thermostat-card",
|
||||
"alarm-panel-card",
|
||||
"gauge-card",
|
||||
"plant-card",
|
||||
"map-card",
|
||||
"media-control-card",
|
||||
"media-player-row",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Layout and utility",
|
||||
pages: [
|
||||
"grid-and-stack-card",
|
||||
"conditional-card",
|
||||
"iframe-card",
|
||||
"markdown-card",
|
||||
"todo-list-card",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "more-info",
|
||||
icon: mdiInformationOutline,
|
||||
header: "More Info dialogs",
|
||||
subsections: [
|
||||
{
|
||||
header: "Climate and water",
|
||||
pages: ["climate", "humidifier", "water-heater", "fan"],
|
||||
},
|
||||
{
|
||||
header: "Covers and access",
|
||||
pages: ["cover", "lock", "lawn-mower", "vacuum"],
|
||||
},
|
||||
{
|
||||
header: "Lighting",
|
||||
pages: ["light", "scene"],
|
||||
},
|
||||
{
|
||||
header: "Media",
|
||||
pages: ["media-player"],
|
||||
},
|
||||
{
|
||||
header: "Inputs and values",
|
||||
pages: ["input-number", "input-text", "number", "timer"],
|
||||
},
|
||||
{
|
||||
header: "System",
|
||||
pages: ["update"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "automation",
|
||||
icon: mdiRobot,
|
||||
header: "Automation",
|
||||
pages: [
|
||||
"editor-trigger",
|
||||
"editor-condition",
|
||||
"editor-action",
|
||||
"trace",
|
||||
"trace-timeline",
|
||||
subsections: [
|
||||
{
|
||||
header: "Editors",
|
||||
pages: ["editor-trigger", "editor-condition", "editor-action"],
|
||||
},
|
||||
{
|
||||
header: "Descriptions",
|
||||
pages: ["describe-trigger", "describe-condition", "describe-action"],
|
||||
},
|
||||
{
|
||||
header: "Traces",
|
||||
pages: ["trace", "trace-timeline"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -64,6 +199,26 @@ export default [
|
||||
category: "date-time",
|
||||
icon: mdiCalendarClock,
|
||||
header: "Date and Time",
|
||||
subsections: [
|
||||
{
|
||||
header: "Date",
|
||||
pages: ["date"],
|
||||
},
|
||||
{
|
||||
header: "Time",
|
||||
pages: ["time", "time-seconds", "time-weekday"],
|
||||
},
|
||||
{
|
||||
header: "Combined",
|
||||
pages: [
|
||||
"date-time",
|
||||
"date-time-numeric",
|
||||
"date-time-seconds",
|
||||
"date-time-short",
|
||||
"date-time-short-year",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "misc",
|
||||
|
||||
+60
-20
@@ -40,15 +40,26 @@ interface GalleryPage {
|
||||
demo?: unknown;
|
||||
}
|
||||
|
||||
interface GallerySidebarSubsection {
|
||||
header: string;
|
||||
pages: string[];
|
||||
}
|
||||
|
||||
interface GallerySidebarGroup {
|
||||
category: string;
|
||||
header?: string;
|
||||
icon?: string;
|
||||
pages: string[];
|
||||
pages?: string[];
|
||||
subsections?: GallerySidebarSubsection[];
|
||||
}
|
||||
|
||||
const groupPages = (group: GallerySidebarGroup): string[] =>
|
||||
group.subsections
|
||||
? group.subsections.flatMap((subsection) => subsection.pages)
|
||||
: (group.pages ?? []);
|
||||
|
||||
const GALLERY_SIDEBAR = SIDEBAR as GallerySidebarGroup[];
|
||||
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${GALLERY_SIDEBAR[0].pages[0]}`;
|
||||
const DEFAULT_PAGE = `${GALLERY_SIDEBAR[0].category}/${groupPages(GALLERY_SIDEBAR[0])[0]}`;
|
||||
|
||||
const mql = matchMedia("(prefers-color-scheme: dark)");
|
||||
|
||||
@@ -284,26 +295,15 @@ class HaGallery extends LitElement {
|
||||
const sidebar: unknown[] = [];
|
||||
|
||||
for (const group of GALLERY_SIDEBAR) {
|
||||
const links: unknown[] = [];
|
||||
const expanded = group.pages.some(
|
||||
const expanded = groupPages(group).some(
|
||||
(page) => this._page === `${group.category}/${page}`
|
||||
);
|
||||
|
||||
for (const page of group.pages) {
|
||||
const key = `${group.category}/${page}`;
|
||||
if (!(key in PAGES)) {
|
||||
console.error("Undefined page referenced in sidebar.js:", key);
|
||||
continue;
|
||||
}
|
||||
links.push(
|
||||
this._renderPageLink(
|
||||
key,
|
||||
PAGES[key].metadata.title || page,
|
||||
group.header ? undefined : "main-navigation",
|
||||
group.header ? undefined : group.icon
|
||||
const content = group.subsections
|
||||
? group.subsections.map((subsection) =>
|
||||
this._renderSidebarSubsection(group, subsection)
|
||||
)
|
||||
);
|
||||
}
|
||||
: this._renderPageLinks(group, group.pages ?? []);
|
||||
|
||||
sidebar.push(
|
||||
group.header
|
||||
@@ -321,16 +321,46 @@ class HaGallery extends LitElement {
|
||||
.path=${group.icon}
|
||||
></ha-svg-icon>`
|
||||
: nothing}
|
||||
${links}
|
||||
${content}
|
||||
</ha-expansion-panel>
|
||||
`
|
||||
: links
|
||||
: content
|
||||
);
|
||||
}
|
||||
|
||||
return sidebar;
|
||||
}
|
||||
|
||||
private _renderSidebarSubsection(
|
||||
group: GallerySidebarGroup,
|
||||
subsection: GallerySidebarSubsection
|
||||
) {
|
||||
return html`
|
||||
<div class="gallery-sidebar-subheader">${subsection.header}</div>
|
||||
${this._renderPageLinks(group, subsection.pages)}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderPageLinks(group: GallerySidebarGroup, pages: string[]) {
|
||||
const links: unknown[] = [];
|
||||
for (const page of pages) {
|
||||
const key = `${group.category}/${page}`;
|
||||
if (!(key in PAGES)) {
|
||||
console.error("Undefined page referenced in sidebar.js:", key);
|
||||
continue;
|
||||
}
|
||||
links.push(
|
||||
this._renderPageLink(
|
||||
key,
|
||||
PAGES[key].metadata.title || page,
|
||||
group.header ? undefined : "main-navigation",
|
||||
group.header ? undefined : group.icon
|
||||
)
|
||||
);
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
private _renderPageLink(
|
||||
page: string,
|
||||
title: string,
|
||||
@@ -585,6 +615,16 @@ class HaGallery extends LitElement {
|
||||
width: var(--ha-sidebar-expanded-section-item-width, 248px);
|
||||
}
|
||||
|
||||
.gallery-sidebar-subheader {
|
||||
margin: var(--ha-space-2) var(--ha-space-4) var(--ha-space-1);
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.gallery-sidebar-icon,
|
||||
.gallery-nav-item ha-svg-icon[slot="start"] {
|
||||
color: var(--sidebar-icon-color);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { mockAreaRegistry } from "../../../../demo/src/stubs/area_registry";
|
||||
@@ -14,6 +15,11 @@ import "../../../../src/components/ha-selector/ha-selector";
|
||||
import "../../../../src/components/ha-settings-row";
|
||||
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
|
||||
import type { BlueprintInput } from "../../../../src/data/blueprint";
|
||||
import {
|
||||
configContext,
|
||||
internationalizationContext,
|
||||
} from "../../../../src/data/context";
|
||||
import { updateHassGroups } from "../../../../src/data/context/updateContext";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
|
||||
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
|
||||
@@ -518,6 +524,17 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
|
||||
private data = SCHEMAS.map(() => ({}));
|
||||
|
||||
// The date/datetime selectors and the date-picker dialog consume these
|
||||
// contexts (provided by the root element in the real app). Provide them here
|
||||
// so they work in the gallery.
|
||||
private _i18nProvider = new ContextProvider(this, {
|
||||
context: internationalizationContext,
|
||||
});
|
||||
|
||||
private _configProvider = new ContextProvider(this, {
|
||||
context: configContext,
|
||||
});
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const hass = provideHass(this);
|
||||
@@ -539,6 +556,16 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
el.hass = this.hass;
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("hass") && this.hass) {
|
||||
this._i18nProvider.setValue(
|
||||
updateHassGroups.internationalization(this.hass)
|
||||
);
|
||||
this._configProvider.setValue(updateHassGroups.config(this.hass));
|
||||
}
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("show-dialog", this._dialogManager);
|
||||
|
||||
@@ -4,21 +4,30 @@ title: Home
|
||||
|
||||
# Welcome to Home Assistant Design
|
||||
|
||||
This portal aims to aid designers and developers on improving the Home Assistant interface. It consists of working code, resources and guidelines.
|
||||
This is the design gallery for the Home Assistant frontend: a living reference of working components, dashboard cards, and brand and copy guidance. Every page runs outside a Home Assistant instance, so you can explore the interface, try components in isolation, and review changes against a consistent baseline.
|
||||
|
||||
## Home Assistant interface
|
||||
## Browse the gallery
|
||||
|
||||
The Home Assistant frontend allows users to browse and control the state of their home, manage their automations and configure integrations. The frontend is designed as a mobile-first experience. It is a progressive web application and offers an app-like experience to our users. The Home Assistant frontend needs to be fast. But it also needs to work on a wide range of old devices.
|
||||
- [Brand](#brand/logo): the logo, personality, and the story behind the Open Home.
|
||||
- [Components](#components/ha-button): the `ha-*` component library with live demos and API notes.
|
||||
- [Dashboards](#lovelace/introduction): Lovelace cards rendered from real card configuration.
|
||||
- [More Info dialogs](#more-info/light): the more-info experience for each entity type.
|
||||
- [Automation](#automation/editor-trigger): trigger, condition, and action editors, plus trace views.
|
||||
- [Users](#user-test/user-types): the audiences we design for.
|
||||
- [Date and time](#date-time/date): date and time formatting examples.
|
||||
- [Miscellaneous](#misc/entity-state): smaller utilities and patterns, plus how to edit this gallery.
|
||||
|
||||
### Material Design
|
||||
## Testing and playground
|
||||
|
||||
The Home Assistant interface is based on Material Design. It's a design system created by Google to quickly build high-quality digital experiences. Components and guidelines that are custom made for Home Assistant are documented on this portal. For all other components check <a href="https://material.io" rel="noopener noreferrer" target="_blank">material.io</a>.
|
||||
Every page runs against fake state, so you can interact with components safely and reproducibly. Treat the demo pages as a playground: change a value, resize the window, or switch the layout to right-to-left to check spacing and direction. Use the gallery to reproduce a UI state in isolation before debugging it in a full Home Assistant setup.
|
||||
|
||||
Open **Settings** from the gear icon in the sidebar to switch between light and dark themes or preview the interface in right-to-left.
|
||||
|
||||
## Designers
|
||||
|
||||
We want to make it as easy for designers to contribute as it is for developers. There’s a lot a designer can contribute to:
|
||||
We want to make it as easy for designers to contribute as it is for developers. There's a lot a designer can contribute to:
|
||||
|
||||
- Meet us at <a href="https://www.home-assistant.io/join-chat-design" rel="noopener noreferrer" target="_blank">Discord #designers channel</a>. If you can't see the channel, make sure you set the correct role in Channels & Roles.
|
||||
- Meet us in the <a href="https://www.home-assistant.io/join-chat-design" rel="noopener noreferrer" target="_blank">Discord #designers channel</a>. If you can't see the channel, make sure you set the correct role in Channels & Roles.
|
||||
- Start designing with our <a href="https://www.figma.com/design/2WGI8IDGyxINjSV6NRvPur/Home-Assistant-Design-Kit" rel="noopener noreferrer" target="_blank">Figma DesignKit</a>.
|
||||
- Find the latest UX <a href="https://github.com/home-assistant/frontend/discussions?discussions_q=label%3Aux" rel="noopener noreferrer" target="_blank">discussions</a> and <a href="https://github.com/home-assistant/frontend/labels/ux" rel="noopener noreferrer" target="_blank">issues</a> on GitHub. Everyone can start a new issue or discussion!
|
||||
|
||||
|
||||
@@ -372,7 +372,6 @@ export class DemoEntityState extends LitElement {
|
||||
hass.localize,
|
||||
entry.stateObj,
|
||||
hass.locale,
|
||||
[], // numericDeviceClasses
|
||||
hass.config,
|
||||
hass.entities
|
||||
)}`,
|
||||
|
||||
+5
-5
@@ -154,12 +154,12 @@
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@vitest/coverage-v8": "4.1.8",
|
||||
"@vitest/coverage-v8": "4.1.9",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.4.1",
|
||||
"eslint": "10.5.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.16.2",
|
||||
@@ -197,7 +197,7 @@
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.61.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.8",
|
||||
"vitest": "4.1.9",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
|
||||
@@ -213,8 +213,8 @@
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"glob@^10.2.2": "^10.5.0"
|
||||
},
|
||||
"packageManager": "yarn@4.16.0",
|
||||
"packageManager": "yarn@4.17.0",
|
||||
"volta": {
|
||||
"node": "24.16.0"
|
||||
"node": "24.17.0"
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/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-numeric-device-classes
|
||||
@@ -4,7 +4,8 @@ import { ensureArray } from "../array/ensure-array";
|
||||
import { isComponentLoaded } from "./is_component_loaded";
|
||||
|
||||
export const canShowPage = (hass: HomeAssistant, page: PageNavigation) =>
|
||||
isCore(page) || isLoadedIntegration(hass, page);
|
||||
(isCore(page) || isLoadedIntegration(hass, page)) &&
|
||||
(!page.filter || page.filter(hass));
|
||||
|
||||
export const isLoadedIntegration = (
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -110,6 +110,25 @@ export const DOMAINS_WITH_DYNAMIC_PICTURE = new Set([
|
||||
"media_player",
|
||||
]);
|
||||
|
||||
/** Domains that use a timestamp for state. */
|
||||
export const TIMESTAMP_STATE_DOMAINS = new Set([
|
||||
"ai_task",
|
||||
"button",
|
||||
"conversation",
|
||||
"event",
|
||||
"image",
|
||||
"infrared",
|
||||
"input_button",
|
||||
"notify",
|
||||
"radio_frequency",
|
||||
"scene",
|
||||
"stt",
|
||||
"tag",
|
||||
"tts",
|
||||
"wake_word",
|
||||
"datetime",
|
||||
]);
|
||||
|
||||
/** Temperature units. */
|
||||
export const UNIT_C = "°C";
|
||||
export const UNIT_F = "°F";
|
||||
|
||||
@@ -3,23 +3,24 @@ import type { FrontendLocaleData } from "../../data/translation";
|
||||
import { selectUnit } from "../util/select-unit";
|
||||
|
||||
const formatRelTimeMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.RelativeTimeFormat(locale.language, { numeric: "auto" })
|
||||
(locale: FrontendLocaleData, style: Intl.RelativeTimeFormatStyle) =>
|
||||
new Intl.RelativeTimeFormat(locale.language, { numeric: "auto", style })
|
||||
);
|
||||
|
||||
export const relativeTime = (
|
||||
from: Date,
|
||||
locale: FrontendLocaleData,
|
||||
to?: Date,
|
||||
includeTense = true
|
||||
includeTense = true,
|
||||
style: Intl.RelativeTimeFormatStyle = "long"
|
||||
): string => {
|
||||
const diff = selectUnit(from, to, locale);
|
||||
if (includeTense) {
|
||||
return formatRelTimeMem(locale).format(diff.value, diff.unit);
|
||||
return formatRelTimeMem(locale, style).format(diff.value, diff.unit);
|
||||
}
|
||||
return Intl.NumberFormat(locale.language, {
|
||||
style: "unit",
|
||||
unit: diff.unit,
|
||||
unitDisplay: "long",
|
||||
unitDisplay: style,
|
||||
}).format(Math.abs(diff.value));
|
||||
};
|
||||
|
||||
@@ -60,6 +60,17 @@ export const computeAttributeValueToParts = (
|
||||
return [{ type: "value", value: localize("state.default.unknown") }];
|
||||
}
|
||||
|
||||
// Device class attribute, return the integration's translated name
|
||||
if (attribute === "device_class" && typeof attributeValue === "string") {
|
||||
const domain = computeStateDomain(stateObj);
|
||||
const deviceClassName = localize(
|
||||
`component.${domain}.entity_component.${attributeValue}.name`
|
||||
);
|
||||
if (deviceClassName) {
|
||||
return [{ type: "value", value: deviceClassName }];
|
||||
}
|
||||
}
|
||||
|
||||
// Number value, return formatted number
|
||||
if (typeof attributeValue === "number") {
|
||||
const domain = computeStateDomain(stateObj);
|
||||
|
||||
@@ -30,7 +30,7 @@ export const computeEntityEntryName = (
|
||||
fallbackStateObj?: HassEntity
|
||||
): string | undefined => {
|
||||
const name =
|
||||
entry.name ||
|
||||
entry.name ??
|
||||
("original_name" in entry && entry.original_name != null
|
||||
? String(entry.original_name)
|
||||
: undefined);
|
||||
@@ -59,7 +59,8 @@ export const computeEntityEntryName = (
|
||||
return stripPrefixFromEntityName(name, deviceName) || name;
|
||||
}
|
||||
|
||||
return name;
|
||||
// Empty name = main entity → undefined, so callers fall back to the device name.
|
||||
return name || undefined;
|
||||
};
|
||||
|
||||
export const entityUseDeviceName = (
|
||||
|
||||
@@ -17,30 +17,15 @@ import {
|
||||
import { blankBeforeUnit } from "../translations/blank_before_unit";
|
||||
import type { LocalizeFunc } from "../translations/localize";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
import { SENSOR_TIMESTAMP_DEVICE_CLASSES } from "../../data/sensor";
|
||||
import {
|
||||
isNumericSensorDeviceClass,
|
||||
SENSOR_TIMESTAMP_DEVICE_CLASSES,
|
||||
} from "../../data/sensor";
|
||||
import { TIMESTAMP_STATE_DOMAINS } from "../const";
|
||||
|
||||
// Domains whose state is a timezone-agnostic date and/or time string.
|
||||
const DATE_TIME_DOMAINS = new Set(["date", "input_datetime", "time"]);
|
||||
|
||||
// Domains whose state is a timestamp.
|
||||
const TIMESTAMP_DOMAINS = new Set([
|
||||
"ai_task",
|
||||
"button",
|
||||
"conversation",
|
||||
"event",
|
||||
"image",
|
||||
"infrared",
|
||||
"input_button",
|
||||
"notify",
|
||||
"radio_frequency",
|
||||
"scene",
|
||||
"stt",
|
||||
"tag",
|
||||
"tts",
|
||||
"wake_word",
|
||||
"datetime",
|
||||
]);
|
||||
|
||||
// Maps Intl.NumberFormat part types to ValuePart types for monetary states.
|
||||
const MONETARY_TYPE_MAP: Record<string, ValuePart["type"]> = {
|
||||
integer: "value",
|
||||
@@ -53,11 +38,12 @@ const MONETARY_TYPE_MAP: Record<string, ValuePart["type"]> = {
|
||||
currency: "unit",
|
||||
};
|
||||
|
||||
const NUMERICAL_DOMAINS = ["counter", "input_number", "number"];
|
||||
|
||||
export const computeStateDisplay = (
|
||||
localize: LocalizeFunc,
|
||||
stateObj: HassEntity,
|
||||
locale: FrontendLocaleData,
|
||||
sensorNumericDeviceClasses: string[],
|
||||
config: HassConfig,
|
||||
entities: HomeAssistant["entities"],
|
||||
state?: string
|
||||
@@ -68,7 +54,6 @@ export const computeStateDisplay = (
|
||||
return computeStateDisplayFromEntityAttributes(
|
||||
localize,
|
||||
locale,
|
||||
sensorNumericDeviceClasses,
|
||||
config,
|
||||
entity,
|
||||
stateObj.entity_id,
|
||||
@@ -80,7 +65,6 @@ export const computeStateDisplay = (
|
||||
export const computeStateDisplayFromEntityAttributes = (
|
||||
localize: LocalizeFunc,
|
||||
locale: FrontendLocaleData,
|
||||
sensorNumericDeviceClasses: string[],
|
||||
config: HassConfig,
|
||||
entity: EntityRegistryDisplayEntry | undefined,
|
||||
entityId: string,
|
||||
@@ -90,7 +74,6 @@ export const computeStateDisplayFromEntityAttributes = (
|
||||
const parts = computeStateToPartsFromEntityAttributes(
|
||||
localize,
|
||||
locale,
|
||||
sensorNumericDeviceClasses,
|
||||
config,
|
||||
entity,
|
||||
entityId,
|
||||
@@ -103,7 +86,6 @@ export const computeStateDisplayFromEntityAttributes = (
|
||||
const computeStateToPartsFromEntityAttributes = (
|
||||
localize: LocalizeFunc,
|
||||
locale: FrontendLocaleData,
|
||||
sensorNumericDeviceClasses: string[],
|
||||
config: HassConfig,
|
||||
entity: EntityRegistryDisplayEntry | undefined,
|
||||
entityId: string,
|
||||
@@ -120,15 +102,15 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
}
|
||||
|
||||
const domain = computeDomain(entityId);
|
||||
const is_number_domain =
|
||||
domain === "counter" || domain === "number" || domain === "input_number";
|
||||
// Entities with a `unit_of_measurement` or `state_class` are numeric values and should use `formatNumber`
|
||||
const isNumberDomain = NUMERICAL_DOMAINS.includes(domain);
|
||||
const isSensorDomain = domain === "sensor";
|
||||
|
||||
// Numeric values (by attributes, number domain,
|
||||
// or numeric sensor device class) use formatNumber.
|
||||
if (
|
||||
isNumericFromAttributes(
|
||||
attributes,
|
||||
domain === "sensor" ? sensorNumericDeviceClasses : []
|
||||
) ||
|
||||
is_number_domain
|
||||
isNumericFromAttributes(attributes) ||
|
||||
isNumberDomain ||
|
||||
(isSensorDomain && isNumericSensorDeviceClass(attributes.device_class))
|
||||
) {
|
||||
// state is duration
|
||||
if (
|
||||
@@ -273,7 +255,7 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
|
||||
// state is a timestamp
|
||||
if (
|
||||
TIMESTAMP_DOMAINS.has(domain) ||
|
||||
TIMESTAMP_STATE_DOMAINS.has(domain) ||
|
||||
(domain === "sensor" &&
|
||||
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(attributes.device_class))
|
||||
) {
|
||||
@@ -314,7 +296,6 @@ export const computeStateToParts = (
|
||||
localize: LocalizeFunc,
|
||||
stateObj: HassEntity,
|
||||
locale: FrontendLocaleData,
|
||||
sensorNumericDeviceClasses: string[],
|
||||
config: HassConfig,
|
||||
entities: HomeAssistant["entities"],
|
||||
state?: string
|
||||
@@ -325,7 +306,6 @@ export const computeStateToParts = (
|
||||
return computeStateToPartsFromEntityAttributes(
|
||||
localize,
|
||||
locale,
|
||||
sensorNumericDeviceClasses,
|
||||
config,
|
||||
entity,
|
||||
stateObj.entity_id,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { supportsFeature } from "./supports-feature";
|
||||
|
||||
export type FeatureClassNames<T extends number = number> = Partial<
|
||||
Record<T, string>
|
||||
>;
|
||||
|
||||
// Expects classNames to be an object mapping feature-bit -> className
|
||||
export const featureClassNames = (
|
||||
stateObj: HassEntity,
|
||||
classNames: FeatureClassNames
|
||||
) => {
|
||||
if (!stateObj || !stateObj.attributes.supported_features) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return Object.keys(classNames)
|
||||
.map((feature) =>
|
||||
supportsFeature(stateObj, Number(feature)) ? classNames[feature] : ""
|
||||
)
|
||||
.filter((attr) => attr !== "")
|
||||
.join(" ");
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { AITaskEntityFeature } from "../../data/ai_task";
|
||||
import { AlarmControlPanelEntityFeature } from "../../data/alarm_control_panel";
|
||||
import { AssistSatelliteEntityFeature } from "../../data/assist_satellite";
|
||||
import { CalendarEntityFeature } from "../../data/calendar";
|
||||
import { CameraEntityFeature } from "../../data/camera";
|
||||
import { ClimateEntityFeature } from "../../data/climate";
|
||||
import { ConversationEntityFeature } from "../../data/conversation";
|
||||
import { CoverEntityFeature } from "../../data/cover";
|
||||
import { FanEntityFeature } from "../../data/fan";
|
||||
import { HumidifierEntityFeature } from "../../data/humidifier";
|
||||
import { LawnMowerEntityFeature } from "../../data/lawn_mower";
|
||||
import { LightEntityFeature } from "../../data/light";
|
||||
import { LockEntityFeature } from "../../data/lock";
|
||||
import { MediaPlayerEntityFeature } from "../../data/media-player";
|
||||
import { NotifyEntityFeature } from "../../data/notify";
|
||||
import { RemoteEntityFeature } from "../../data/remote";
|
||||
import { SirenEntityFeature } from "../../data/siren";
|
||||
import { TodoListEntityFeature } from "../../data/todo";
|
||||
import { UpdateEntityFeature } from "../../data/update";
|
||||
import { VacuumEntityFeature } from "../../data/vacuum";
|
||||
import { ValveEntityFeature } from "../../data/valve";
|
||||
import { WaterHeaterEntityFeature } from "../../data/water_heater";
|
||||
import { WeatherEntityFeature } from "../../data/weather";
|
||||
|
||||
export type FeatureEnum = Record<string | number, string | number>;
|
||||
|
||||
const DOMAIN_ENUMS = {
|
||||
ai_task: AITaskEntityFeature,
|
||||
alarm_control_panel: AlarmControlPanelEntityFeature,
|
||||
assist_satellite: AssistSatelliteEntityFeature,
|
||||
calendar: CalendarEntityFeature,
|
||||
camera: CameraEntityFeature,
|
||||
climate: ClimateEntityFeature,
|
||||
conversation: ConversationEntityFeature,
|
||||
cover: CoverEntityFeature,
|
||||
fan: FanEntityFeature,
|
||||
humidifier: HumidifierEntityFeature,
|
||||
lawn_mower: LawnMowerEntityFeature,
|
||||
light: LightEntityFeature,
|
||||
lock: LockEntityFeature,
|
||||
media_player: MediaPlayerEntityFeature,
|
||||
notify: NotifyEntityFeature,
|
||||
remote: RemoteEntityFeature,
|
||||
siren: SirenEntityFeature,
|
||||
todo: TodoListEntityFeature,
|
||||
update: UpdateEntityFeature,
|
||||
vacuum: VacuumEntityFeature,
|
||||
valve: ValveEntityFeature,
|
||||
water_heater: WaterHeaterEntityFeature,
|
||||
weather: WeatherEntityFeature,
|
||||
};
|
||||
|
||||
export function getFeatures(domain: string): FeatureEnum | undefined {
|
||||
const enumObj = DOMAIN_ENUMS[domain] as FeatureEnum;
|
||||
return enumObj;
|
||||
}
|
||||
@@ -22,16 +22,13 @@ export const FIXED_DOMAIN_STATES = {
|
||||
assist_satellite: ["idle", "listening", "responding", "processing"],
|
||||
automation: ["on", "off"],
|
||||
binary_sensor: ["on", "off"],
|
||||
button: [],
|
||||
calendar: ["on", "off"],
|
||||
camera: ["idle", "recording", "streaming"],
|
||||
cover: ["closed", "closing", "open", "opening"],
|
||||
device_tracker: ["home", "not_home"],
|
||||
fan: ["on", "off"],
|
||||
humidifier: ["on", "off"],
|
||||
infrared: [],
|
||||
input_boolean: ["on", "off"],
|
||||
input_button: [],
|
||||
lawn_mower: ["error", "paused", "mowing", "returning", "docked"],
|
||||
light: ["on", "off"],
|
||||
lock: [
|
||||
@@ -56,7 +53,6 @@ export const FIXED_DOMAIN_STATES = {
|
||||
plant: ["ok", "problem"],
|
||||
radio_frequency: [],
|
||||
remote: ["on", "off"],
|
||||
scene: [],
|
||||
schedule: ["on", "off"],
|
||||
script: ["on", "off"],
|
||||
siren: ["on", "off"],
|
||||
@@ -290,6 +286,81 @@ export const getStatesDomain = (
|
||||
return result;
|
||||
};
|
||||
|
||||
// Maps a value attribute (or the main state, keyed `_`) to the attribute listing
|
||||
// its options. Naming is irregular per domain, so it's mapped explicitly.
|
||||
export const DOMAIN_OPTIONS_ATTRIBUTES: Record<
|
||||
string,
|
||||
Record<string, string>
|
||||
> = {
|
||||
climate: {
|
||||
_: "hvac_modes",
|
||||
fan_mode: "fan_modes",
|
||||
preset_mode: "preset_modes",
|
||||
swing_mode: "swing_modes",
|
||||
swing_horizontal_mode: "swing_horizontal_modes",
|
||||
},
|
||||
event: {
|
||||
event_type: "event_types",
|
||||
},
|
||||
fan: {
|
||||
preset_mode: "preset_modes",
|
||||
},
|
||||
humidifier: {
|
||||
mode: "available_modes",
|
||||
},
|
||||
input_select: {
|
||||
_: "options",
|
||||
},
|
||||
select: {
|
||||
_: "options",
|
||||
},
|
||||
light: {
|
||||
effect: "effect_list",
|
||||
color_mode: "supported_color_modes",
|
||||
},
|
||||
media_player: {
|
||||
sound_mode: "sound_mode_list",
|
||||
source: "source_list",
|
||||
},
|
||||
remote: {
|
||||
current_activity: "activity_list",
|
||||
},
|
||||
sensor: {
|
||||
_: "options",
|
||||
},
|
||||
vacuum: {
|
||||
fan_speed: "fan_speed_list",
|
||||
},
|
||||
water_heater: {
|
||||
_: "operation_list",
|
||||
operation_mode: "operation_list",
|
||||
},
|
||||
};
|
||||
|
||||
const DOMAIN_VALUE_ATTRIBUTES: Record<
|
||||
string,
|
||||
Record<string, string>
|
||||
> = Object.fromEntries(
|
||||
Object.entries(DOMAIN_OPTIONS_ATTRIBUTES).map(([domain, mapping]) => [
|
||||
domain,
|
||||
Object.fromEntries(
|
||||
Object.entries(mapping).map(([value, list]) => [list, value])
|
||||
),
|
||||
])
|
||||
);
|
||||
|
||||
// value attribute (or main state) → its options-list attribute
|
||||
export const getOptionsAttribute = (
|
||||
domain: string,
|
||||
attribute?: string
|
||||
): string | undefined => DOMAIN_OPTIONS_ATTRIBUTES[domain]?.[attribute ?? "_"];
|
||||
|
||||
// options-list attribute → its value attribute (`_` = main state)
|
||||
export const getValueAttribute = (
|
||||
domain: string,
|
||||
optionsAttribute: string
|
||||
): string | undefined => DOMAIN_VALUE_ATTRIBUTES[domain]?.[optionsAttribute];
|
||||
|
||||
export const getStates = (
|
||||
hass: HomeAssistant,
|
||||
state: HassEntity,
|
||||
@@ -302,78 +373,15 @@ export const getStates = (
|
||||
result.push(...getStatesDomain(hass, domain, attribute));
|
||||
|
||||
// Dynamic values based on the entities
|
||||
switch (domain) {
|
||||
case "climate":
|
||||
if (!attribute) {
|
||||
result.push(...state.attributes.hvac_modes);
|
||||
} else if (attribute === "fan_mode") {
|
||||
result.push(...state.attributes.fan_modes);
|
||||
} else if (attribute === "preset_mode") {
|
||||
result.push(...state.attributes.preset_modes);
|
||||
} else if (attribute === "swing_mode") {
|
||||
result.push(...state.attributes.swing_modes);
|
||||
} else if (attribute === "swing_horizontal_mode") {
|
||||
result.push(...state.attributes.swing_horizontal_modes);
|
||||
}
|
||||
break;
|
||||
case "event":
|
||||
if (attribute === "event_type") {
|
||||
result.push(...state.attributes.event_types);
|
||||
}
|
||||
break;
|
||||
case "fan":
|
||||
if (attribute === "preset_mode") {
|
||||
result.push(...state.attributes.preset_modes);
|
||||
}
|
||||
break;
|
||||
case "humidifier":
|
||||
if (attribute === "mode") {
|
||||
result.push(...state.attributes.available_modes);
|
||||
}
|
||||
break;
|
||||
case "input_select":
|
||||
case "select":
|
||||
if (!attribute) {
|
||||
result.push(...state.attributes.options);
|
||||
}
|
||||
break;
|
||||
case "light":
|
||||
if (attribute === "effect" && state.attributes.effect_list) {
|
||||
result.push(...state.attributes.effect_list);
|
||||
} else if (
|
||||
attribute === "color_mode" &&
|
||||
state.attributes.supported_color_modes
|
||||
) {
|
||||
result.push(...state.attributes.supported_color_modes);
|
||||
}
|
||||
break;
|
||||
case "media_player":
|
||||
if (attribute === "sound_mode") {
|
||||
result.push(...state.attributes.sound_mode_list);
|
||||
} else if (attribute === "source") {
|
||||
result.push(...state.attributes.source_list);
|
||||
}
|
||||
break;
|
||||
case "remote":
|
||||
if (attribute === "current_activity") {
|
||||
result.push(...state.attributes.activity_list);
|
||||
}
|
||||
break;
|
||||
case "sensor":
|
||||
if (!attribute && state.attributes.device_class === "enum") {
|
||||
result.push(...state.attributes.options);
|
||||
}
|
||||
break;
|
||||
case "vacuum":
|
||||
if (attribute === "fan_speed") {
|
||||
result.push(...state.attributes.fan_speed_list);
|
||||
}
|
||||
break;
|
||||
case "water_heater":
|
||||
if (!attribute || attribute === "operation_mode") {
|
||||
result.push(...state.attributes.operation_list);
|
||||
}
|
||||
break;
|
||||
const optionsAttribute = getOptionsAttribute(domain, attribute);
|
||||
if (optionsAttribute) {
|
||||
const options = state.attributes[optionsAttribute];
|
||||
// Sensors only expose their options when their device class is `enum`.
|
||||
const enumSensor =
|
||||
domain !== "sensor" || state.attributes.device_class === "enum";
|
||||
if (enumSensor && Array.isArray(options)) {
|
||||
result.push(...options);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(result)];
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { OFF, UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
import { TIMESTAMP_STATE_DOMAINS } from "../const";
|
||||
|
||||
export function stateActive(stateObj: HassEntity, state?: string): boolean {
|
||||
const domain = computeDomain(stateObj.entity_id);
|
||||
const compareState = state !== undefined ? state : stateObj?.state;
|
||||
|
||||
if (
|
||||
[
|
||||
"button",
|
||||
"event",
|
||||
"infrared",
|
||||
"input_button",
|
||||
"radio_frequency",
|
||||
"scene",
|
||||
].includes(domain)
|
||||
) {
|
||||
if (TIMESTAMP_STATE_DOMAINS.has(domain)) {
|
||||
return compareState !== UNAVAILABLE;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,12 +14,8 @@ export const isNumericState = (stateObj: HassEntity): boolean =>
|
||||
isNumericFromAttributes(stateObj.attributes);
|
||||
|
||||
export const isNumericFromAttributes = (
|
||||
attributes: HassEntityAttributeBase,
|
||||
numericDeviceClasses?: string[]
|
||||
): boolean =>
|
||||
!!attributes.unit_of_measurement ||
|
||||
!!attributes.state_class ||
|
||||
(numericDeviceClasses || []).includes(attributes.device_class || "");
|
||||
attributes: HassEntityAttributeBase
|
||||
): boolean => !!attributes.unit_of_measurement || !!attributes.state_class;
|
||||
|
||||
export const numberFormatToLocale = (
|
||||
localeOptions: FrontendLocaleData
|
||||
|
||||
@@ -46,8 +46,7 @@ export const computeFormatFunctions = async (
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"],
|
||||
sensorNumericDeviceClasses: string[]
|
||||
floors: HomeAssistant["floors"]
|
||||
): Promise<{
|
||||
formatEntityState: FormatEntityStateFunc;
|
||||
formatEntityStateToParts: FormatEntityStateToPartsFunc;
|
||||
@@ -66,25 +65,9 @@ export const computeFormatFunctions = async (
|
||||
|
||||
return {
|
||||
formatEntityState: (stateObj, state) =>
|
||||
computeStateDisplay(
|
||||
localize,
|
||||
stateObj,
|
||||
locale,
|
||||
sensorNumericDeviceClasses,
|
||||
config,
|
||||
entities,
|
||||
state
|
||||
),
|
||||
computeStateDisplay(localize, stateObj, locale, config, entities, state),
|
||||
formatEntityStateToParts: (stateObj, state) =>
|
||||
computeStateToParts(
|
||||
localize,
|
||||
stateObj,
|
||||
locale,
|
||||
sensorNumericDeviceClasses,
|
||||
config,
|
||||
entities,
|
||||
state
|
||||
),
|
||||
computeStateToParts(localize, stateObj, locale, config, entities, state),
|
||||
formatEntityAttributeValue: (stateObj, attribute, value) =>
|
||||
computeAttributeValueDisplay(
|
||||
localize,
|
||||
|
||||
@@ -17,8 +17,6 @@ export type LocalizeKeys =
|
||||
| `ui.common.${string}`
|
||||
| `ui.components.calendar.event.rrule.${string}`
|
||||
| `ui.components.selectors.file.${string}`
|
||||
| `ui.components.logbook.messages.detected_device_classes.${string}`
|
||||
| `ui.components.logbook.messages.cleared_device_classes.${string}`
|
||||
| `ui.dialogs.entity_registry.editor.${string}`
|
||||
| `ui.dialogs.more_info_control.lawn_mower.${string}`
|
||||
| `ui.dialogs.more_info_control.vacuum.${string}`
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Records like the entity, device, area and floor registries are re-fetched and
|
||||
* rebuilt in full on every registry-updated event, producing brand-new objects
|
||||
* for every item even when nothing relevant changed. That gives every item a new
|
||||
* reference, so all consumers needlessly re-render.
|
||||
*
|
||||
* Returns `next` with each item replaced by the equal `previous` item, so
|
||||
* unchanged items keep their object identity, and returns the `previous` record
|
||||
* untouched when nothing changed at all (so the update can be skipped entirely).
|
||||
*/
|
||||
export const preserveUnchangedRecord = <T>(
|
||||
previous: Record<string, T> | undefined,
|
||||
next: Record<string, T>,
|
||||
equal: (a: T, b: T) => boolean
|
||||
): Record<string, T> => {
|
||||
if (!previous) {
|
||||
return next;
|
||||
}
|
||||
let changed = Object.keys(previous).length !== Object.keys(next).length;
|
||||
for (const key of Object.keys(next)) {
|
||||
const previousItem = previous[key];
|
||||
if (previousItem !== undefined && equal(previousItem, next[key])) {
|
||||
next[key] = previousItem;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : previous;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { ReactiveControllerHost } from "lit";
|
||||
import { clamp } from "../number/clamp";
|
||||
|
||||
// Count columns from the container's real width (not the viewport) so a
|
||||
// docked sidebar is accounted for, like the dashboard sections view.
|
||||
const MIN_COLUMN_WIDTH = 320;
|
||||
const DEFAULT_COLUMN_GAP = 16;
|
||||
|
||||
const parsePx = (value: string) => parseInt(value, 10) || 0;
|
||||
|
||||
export const createColumnsController = (
|
||||
host: ReactiveControllerHost & Element,
|
||||
maxColumns: number
|
||||
) =>
|
||||
new ResizeController<number>(host, {
|
||||
target: null,
|
||||
skipInitial: true,
|
||||
callback: (entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) {
|
||||
return maxColumns;
|
||||
}
|
||||
const width = entry.contentRect.width;
|
||||
const gap =
|
||||
parsePx(getComputedStyle(entry.target).columnGap) || DEFAULT_COLUMN_GAP;
|
||||
const columns = Math.floor((width + gap) / (MIN_COLUMN_WIDTH + gap));
|
||||
return clamp(columns, 1, maxColumns);
|
||||
},
|
||||
});
|
||||
@@ -4,10 +4,10 @@ type ResultCache<T> = Record<string, Promise<T> | undefined>;
|
||||
|
||||
/**
|
||||
* Call a function with result caching per entity.
|
||||
* @param cacheKey key to store the cache on hass object
|
||||
* @param cacheKey key to namespace the cache
|
||||
* @param cacheTime time to cache the results
|
||||
* @param func function to fetch the data
|
||||
* @param hass Home Assistant object
|
||||
* @param hass Home Assistant object (or slice) the cache is keyed on
|
||||
* @param entityId entity to fetch data for
|
||||
* @param args extra arguments to pass to the function to fetch the data
|
||||
* @returns
|
||||
@@ -15,8 +15,12 @@ type ResultCache<T> = Record<string, Promise<T> | undefined>;
|
||||
export const timeCacheEntityPromiseFunc = async <T>(
|
||||
cacheKey: string,
|
||||
cacheTime: number,
|
||||
func: (hass: HomeAssistant, entityId: string, ...args: any[]) => Promise<T>,
|
||||
hass: HomeAssistant,
|
||||
func: (
|
||||
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
|
||||
entityId: string,
|
||||
...args: any[]
|
||||
) => Promise<T>,
|
||||
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
|
||||
entityId: string,
|
||||
...args: any[]
|
||||
): Promise<T> => {
|
||||
@@ -39,11 +43,11 @@ export const timeCacheEntityPromiseFunc = async <T>(
|
||||
// When successful, set timer to clear cache
|
||||
() =>
|
||||
setTimeout(() => {
|
||||
cache![entityId] = undefined;
|
||||
cache[entityId] = undefined;
|
||||
}, cacheTime),
|
||||
// On failure, clear cache right away
|
||||
() => {
|
||||
cache![entityId] = undefined;
|
||||
cache[entityId] = undefined;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -16,14 +16,12 @@ interface CacheResult<T> {
|
||||
* @param args extra arguments to pass to the function to fetch the data
|
||||
* @returns
|
||||
*/
|
||||
export const timeCachePromiseFunc = async <T>(
|
||||
export const timeCachePromiseFunc = async <T, H = HomeAssistant>(
|
||||
cacheKey: string,
|
||||
cacheTime: number,
|
||||
func: (hass: HomeAssistant, ...args: any[]) => Promise<T>,
|
||||
generateCacheKey:
|
||||
| ((hass: HomeAssistant, lastResult: T) => unknown)
|
||||
| undefined,
|
||||
hass: HomeAssistant,
|
||||
func: (hass: H, ...args: any[]) => Promise<T>,
|
||||
generateCacheKey: ((hass: H, lastResult: T) => unknown) | undefined,
|
||||
hass: H,
|
||||
...args: any[]
|
||||
): Promise<T> => {
|
||||
const anyHass = hass as any;
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../ha-svg-icon";
|
||||
import {
|
||||
mdiAlertCircle,
|
||||
mdiCheckCircle,
|
||||
mdiCloseCircle,
|
||||
mdiHelpCircle,
|
||||
} from "@mdi/js";
|
||||
|
||||
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
|
||||
|
||||
@@ -19,46 +26,59 @@ export class HaAutomationRowLiveTest extends LitElement {
|
||||
|
||||
@property() public label = "";
|
||||
|
||||
private get _iconPath() {
|
||||
switch (this.state) {
|
||||
case "pass":
|
||||
return mdiCheckCircle;
|
||||
case "fail":
|
||||
return mdiCloseCircle;
|
||||
case "invalid":
|
||||
return mdiAlertCircle;
|
||||
default:
|
||||
return mdiHelpCircle;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div
|
||||
id="indicator"
|
||||
role="status"
|
||||
tabindex="0"
|
||||
aria-label=${this.label}
|
||||
></div>
|
||||
<div id="indicator" role="status" tabindex="0" aria-label=${this.label}>
|
||||
<ha-svg-icon .path=${this._iconPath}></ha-svg-icon>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
inset-inline-end: -6px;
|
||||
top: -8px;
|
||||
inset-inline-end: -8px;
|
||||
display: inline-block;
|
||||
}
|
||||
#indicator {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
border: var(--ha-border-width-md) solid;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--card-background-color);
|
||||
box-shadow: 0 0 0 2px var(--card-background-color);
|
||||
transition: all var(--ha-animation-duration-normal) ease-in-out;
|
||||
}
|
||||
#indicator ha-svg-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
--mdc-icon-size: 16px;
|
||||
}
|
||||
:host([state="pass"]) #indicator {
|
||||
background-color: var(--ha-color-green-60);
|
||||
border-color: var(--ha-color-green-60);
|
||||
color: var(--ha-color-green-60);
|
||||
}
|
||||
:host([state="fail"]) #indicator {
|
||||
border-color: var(--ha-color-orange-60);
|
||||
color: var(--ha-color-orange-60);
|
||||
}
|
||||
:host([state="invalid"]) #indicator {
|
||||
border-color: var(--ha-color-red-60);
|
||||
color: var(--ha-color-red-60);
|
||||
}
|
||||
:host([state="unknown"]) #indicator {
|
||||
border-color: var(--ha-color-neutral-60);
|
||||
color: var(--ha-color-neutral-60);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -47,66 +47,9 @@ export function fillDataGapsAndRoundCaps(
|
||||
)
|
||||
).sort((a, b) => a - b);
|
||||
|
||||
// Phase 1: align every dataset to the shared buckets, inserting zero-valued
|
||||
// gap fillers. The original implementation spliced gaps in-place while
|
||||
// iterating bucket-by-bucket, which shifts the whole tail of the array on
|
||||
// every gap (O(points * gaps) in the worst case). Rebuilding each dataset in
|
||||
// a single forward merge against the bucket axis yields the byte-identical
|
||||
// aligned array without the per-gap shifting cost.
|
||||
const bucketCount = buckets.length;
|
||||
for (let i = datasets.length - 1; i >= 0; i--) {
|
||||
const data = datasets[i].data;
|
||||
if (!data || data.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const dataLength = data.length;
|
||||
const aligned: typeof data = [];
|
||||
let srcIdx = 0;
|
||||
// The original loop runs once per bucket index and reads data[index],
|
||||
// which (thanks to the in-place splices) always points at the next
|
||||
// unconsumed source element. A defined element with a non-matching x
|
||||
// inserts a gap and keeps the element for the next index; a matching or
|
||||
// otherwise-defined element is kept and the pointer advances; once the
|
||||
// source is exhausted, no further gaps are added.
|
||||
for (let index = 0; index < bucketCount; index++) {
|
||||
if (srcIdx >= dataLength) {
|
||||
break;
|
||||
}
|
||||
const dataPoint = data[srcIdx];
|
||||
const item: any =
|
||||
dataPoint && typeof dataPoint === "object" && "value" in dataPoint
|
||||
? dataPoint
|
||||
: { value: dataPoint };
|
||||
const x = item.value?.[0];
|
||||
if (x === undefined) {
|
||||
// Malformed element: left untouched in place, pointer advances.
|
||||
aligned.push(dataPoint);
|
||||
srcIdx++;
|
||||
} else if (Number(x) !== buckets[index]) {
|
||||
aligned.push({
|
||||
value: [buckets[index], 0],
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
} as (typeof data)[number]);
|
||||
} else {
|
||||
aligned.push(dataPoint);
|
||||
srcIdx++;
|
||||
}
|
||||
}
|
||||
// Trailing source elements past the last consumed bucket index are never
|
||||
// reached by the original loop and stay in place unchanged.
|
||||
for (; srcIdx < dataLength; srcIdx++) {
|
||||
aligned.push(data[srcIdx]);
|
||||
}
|
||||
datasets[i].data = aligned;
|
||||
}
|
||||
|
||||
// Phase 2: per bucket, mark only the uppermost positive and lowermost
|
||||
// negative bar of each stack with a rounded cap, and strip the border from
|
||||
// zero values. Datasets are now aligned, so data[index] always corresponds
|
||||
// to buckets[index] (or is undefined past the dataset's last point).
|
||||
for (let index = 0; index < bucketCount; index++) {
|
||||
// make sure all datasets have the same buckets
|
||||
// otherwise the chart will render incorrectly in some cases
|
||||
buckets.forEach((bucket, index) => {
|
||||
const capRounded = {};
|
||||
const capRoundedNegative = {};
|
||||
for (let i = datasets.length - 1; i >= 0; i--) {
|
||||
@@ -120,7 +63,14 @@ export function fillDataGapsAndRoundCaps(
|
||||
if (x === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (item.value?.[1] === 0) {
|
||||
if (Number(x) !== bucket) {
|
||||
datasets[i].data?.splice(index, 0, {
|
||||
value: [bucket, 0],
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
});
|
||||
} else if (item.value?.[1] === 0) {
|
||||
// remove the border for zero values or it will be rendered
|
||||
datasets[i].data![index] = {
|
||||
...item,
|
||||
@@ -149,5 +99,5 @@ export function fillDataGapsAndRoundCaps(
|
||||
capRoundedNegative[stack] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,35 +162,40 @@ export function generateStatisticsChartData(
|
||||
// endTime is "now" and client time is not in sync with server time.
|
||||
return;
|
||||
}
|
||||
statDataSets.forEach((d, i) => {
|
||||
if (chartType === "line") {
|
||||
if (
|
||||
prevEndTime &&
|
||||
prevValues &&
|
||||
prevEndTime.getTime() !== start.getTime()
|
||||
) {
|
||||
const isLineChart = chartType === "line";
|
||||
// For bar charts, optionally center the bar within its time range. The
|
||||
// centered time is shared by every series of this data point.
|
||||
const barTime =
|
||||
!isLineChart && centerBars
|
||||
? new Date((start.getTime() + end.getTime()) / 2)
|
||||
: start;
|
||||
// Whether a gap needs to be drawn before this data point (line charts).
|
||||
const drawGap =
|
||||
isLineChart &&
|
||||
!!prevEndTime &&
|
||||
!!prevValues &&
|
||||
prevEndTime.getTime() !== start.getTime();
|
||||
for (let i = 0; i < statDataSets.length; i++) {
|
||||
const d = statDataSets[i];
|
||||
const dataValue = dataValues[i];
|
||||
if (isLineChart) {
|
||||
if (drawGap) {
|
||||
// if the end of the previous data doesn't match the start of the current data,
|
||||
// we have to draw a gap so add a value at the end time, and then an empty value.
|
||||
d.data!.push([prevEndTime, ...prevValues[i]!]);
|
||||
d.data!.push([prevEndTime, null]);
|
||||
d.data!.push([prevEndTime!, ...prevValues![i]!]);
|
||||
d.data!.push([prevEndTime!, null]);
|
||||
}
|
||||
d.data!.push([start, ...dataValues[i]!]);
|
||||
d.data!.push([start, ...dataValue!]);
|
||||
// For band-top rows dataValues[i] is [diff, top]; the actual Y is
|
||||
// the last element. For regular rows it's [value]. Same call works.
|
||||
trackY(dataValues[i][dataValues[i].length - 1]);
|
||||
trackY(dataValue[dataValue.length - 1]);
|
||||
} else {
|
||||
let time = start;
|
||||
if (centerBars) {
|
||||
// If centering bars, set the time to the midpoint between start and end instead
|
||||
// of the start time.
|
||||
time = new Date((start.getTime() + end.getTime()) / 2);
|
||||
}
|
||||
// Data value should always be a scalar for bar charts. Pass in
|
||||
// real start time as extra value to allow formatting tooltip.
|
||||
d.data!.push([time, dataValues[i][0]!, start, end]);
|
||||
trackY(dataValues[i][0]);
|
||||
d.data!.push([barTime, dataValue[0]!, start, end]);
|
||||
trackY(dataValue[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
prevValues = dataValues;
|
||||
prevEndTime = limit;
|
||||
};
|
||||
@@ -314,44 +319,68 @@ export function generateStatisticsChartData(
|
||||
}
|
||||
});
|
||||
|
||||
let prevDate: Date | null = null;
|
||||
let prevStart: number | null = null;
|
||||
// Process chart data.
|
||||
let firstSum: number | null | undefined = null;
|
||||
stats.forEach((stat) => {
|
||||
|
||||
// The per-type branch decisions in the inner loop are invariant across all
|
||||
// stats of this statistic, so classify each type once up front.
|
||||
// kind: 0 = sum (cumulative diff), 1 = band-top ([diff, top]), 2 = plain.
|
||||
const SUM_KIND = 0;
|
||||
const BAND_KIND = 1;
|
||||
const PLAIN_KIND = 2;
|
||||
const bandBottomHidden = hiddenStats.has(`${statistic_id}-${bandBottom}`);
|
||||
const isLine = chartType === "line";
|
||||
const typeKinds = statTypes.map((type) => {
|
||||
if (type === "sum") {
|
||||
return SUM_KIND;
|
||||
}
|
||||
if (type === bandTop && isLine && drawBands && !bandBottomHidden) {
|
||||
return BAND_KIND;
|
||||
}
|
||||
return PLAIN_KIND;
|
||||
});
|
||||
const numTypes = statTypes.length;
|
||||
const statHidden = hiddenStats.has(statistic_id);
|
||||
|
||||
for (const stat of stats) {
|
||||
// Skip consecutive stats that share the same start time. Compare the raw
|
||||
// numeric start so the dedup actually fires (a `Date` reference compare
|
||||
// never would) and so we skip allocating a `Date` on the dropped path.
|
||||
if (prevStart === stat.start) {
|
||||
continue;
|
||||
}
|
||||
prevStart = stat.start;
|
||||
const startDate = new Date(stat.start);
|
||||
const endDate = new Date(stat.end);
|
||||
if (prevDate === startDate) {
|
||||
return;
|
||||
}
|
||||
prevDate = startDate;
|
||||
const dataValues: (number | null)[][] = [];
|
||||
statTypes.forEach((type) => {
|
||||
for (let t = 0; t < numTypes; t++) {
|
||||
const type = statTypes[t];
|
||||
const val: (number | null)[] = [];
|
||||
if (type === "sum") {
|
||||
if (firstSum === null || firstSum === undefined) {
|
||||
val.push(0);
|
||||
firstSum = stat.sum;
|
||||
} else {
|
||||
val.push((stat.sum || 0) - firstSum);
|
||||
switch (typeKinds[t]) {
|
||||
case SUM_KIND:
|
||||
if (firstSum === null || firstSum === undefined) {
|
||||
val.push(0);
|
||||
firstSum = stat.sum;
|
||||
} else {
|
||||
val.push((stat.sum || 0) - firstSum);
|
||||
}
|
||||
break;
|
||||
case BAND_KIND: {
|
||||
const top = stat[bandTop] || 0;
|
||||
val.push(Math.abs(top - (stat[bandBottom] || 0)));
|
||||
val.push(top);
|
||||
break;
|
||||
}
|
||||
} else if (
|
||||
type === bandTop &&
|
||||
chartType === "line" &&
|
||||
drawBands &&
|
||||
!hiddenStats.has(`${statistic_id}-${bandBottom}`)
|
||||
) {
|
||||
const top = stat[bandTop] || 0;
|
||||
val.push(Math.abs(top - (stat[bandBottom] || 0)));
|
||||
val.push(top);
|
||||
} else {
|
||||
val.push(stat[type] ?? null);
|
||||
default:
|
||||
val.push(stat[type] ?? null);
|
||||
}
|
||||
dataValues.push(val);
|
||||
});
|
||||
if (!hiddenStats.has(statistic_id)) {
|
||||
}
|
||||
if (!statHidden) {
|
||||
pushData(startDate, endDate, endTime, dataValues);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// For line charts, close out the last stat segment at prevEndTime
|
||||
const lastEndTime = prevEndTime;
|
||||
|
||||
@@ -79,9 +79,11 @@ function computeTimelineEnumColor(
|
||||
const domain = computeStateDomain(stateObj);
|
||||
const states =
|
||||
FIXED_DOMAIN_STATES[domain] ||
|
||||
(domain === "sensor" &&
|
||||
stateObj.attributes.device_class === "enum" &&
|
||||
stateObj.attributes.options) ||
|
||||
((domain === "sensor" && stateObj.attributes.device_class === "enum") ||
|
||||
domain === "select" ||
|
||||
domain === "input_select"
|
||||
? stateObj.attributes.options
|
||||
: undefined) ||
|
||||
[];
|
||||
const idx = states.indexOf(state);
|
||||
if (idx === -1) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiAlertCircle,
|
||||
mdiChevronDown,
|
||||
@@ -10,7 +11,9 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import {
|
||||
runAssistPipeline,
|
||||
type AssistPipeline,
|
||||
@@ -18,10 +21,19 @@ import {
|
||||
type ConversationChatLogToolResultDelta,
|
||||
type PipelineRunEvent,
|
||||
} from "../data/assist_pipeline";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
statesContext,
|
||||
} from "../data/context";
|
||||
import { ConversationEntityFeature } from "../data/conversation";
|
||||
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantConfig,
|
||||
HomeAssistantConnection,
|
||||
} from "../types";
|
||||
import { AudioRecorder } from "../util/audio-recorder";
|
||||
import { documentationUrl } from "../util/documentation-url";
|
||||
import "./ha-alert";
|
||||
@@ -47,8 +59,6 @@ interface AssistMessage {
|
||||
|
||||
@customElement("ha-assist-chat")
|
||||
export class HaAssistChat extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public pipeline?: AssistPipeline;
|
||||
|
||||
@property({ type: Boolean, attribute: "disable-speech" })
|
||||
@@ -71,6 +81,22 @@ export class HaAssistChat extends LitElement {
|
||||
|
||||
@state() private _processing = false;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: HomeAssistant["states"];
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config!: HomeAssistantConfig;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection!: HomeAssistantConnection;
|
||||
|
||||
private _conversationId: string | null = null;
|
||||
|
||||
private _audioRecorder?: AudioRecorder;
|
||||
@@ -86,7 +112,7 @@ export class HaAssistChat extends LitElement {
|
||||
this._conversation = [
|
||||
{
|
||||
who: "hass",
|
||||
text: this.hass.localize("ui.dialogs.voice_command.how_can_i_help"),
|
||||
text: this._localize("ui.dialogs.voice_command.how_can_i_help"),
|
||||
thinking: "",
|
||||
tool_calls: {},
|
||||
},
|
||||
@@ -124,9 +150,9 @@ export class HaAssistChat extends LitElement {
|
||||
const controlHA = !this.pipeline
|
||||
? false
|
||||
: this.pipeline.prefer_local_intents ||
|
||||
(this.hass.states[this.pipeline.conversation_engine]
|
||||
(this._states[this.pipeline.conversation_engine]
|
||||
? supportsFeature(
|
||||
this.hass.states[this.pipeline.conversation_engine],
|
||||
this._states[this.pipeline.conversation_engine],
|
||||
ConversationEntityFeature.CONTROL
|
||||
)
|
||||
: true);
|
||||
@@ -139,7 +165,7 @@ export class HaAssistChat extends LitElement {
|
||||
? nothing
|
||||
: html`
|
||||
<ha-alert>
|
||||
${this.hass.localize(
|
||||
${this._localize(
|
||||
"ui.dialogs.voice_command.conversation_no_control"
|
||||
)}
|
||||
</ha-alert>
|
||||
@@ -180,7 +206,7 @@ export class HaAssistChat extends LitElement {
|
||||
.path=${mdiCommentProcessingOutline}
|
||||
></ha-svg-icon>
|
||||
<span class="thinking-label">
|
||||
${this.hass.localize(
|
||||
${this._localize(
|
||||
"ui.dialogs.voice_command.show_details"
|
||||
)}
|
||||
</span>
|
||||
@@ -251,7 +277,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
|
||||
id="message-input"
|
||||
@keyup=${this._handleKeyUp}
|
||||
@input=${this._handleInput}
|
||||
.label=${this.hass.localize(`ui.dialogs.voice_command.input_label`)}
|
||||
.label=${this._localize(`ui.dialogs.voice_command.input_label`)}
|
||||
>
|
||||
<div slot="end">
|
||||
${this._showSendButton || !supportsSTT
|
||||
@@ -261,7 +287,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
|
||||
.path=${mdiSend}
|
||||
@click=${this._handleSendMessage}
|
||||
.disabled=${this._processing}
|
||||
.label=${this.hass.localize(
|
||||
.label=${this._localize(
|
||||
"ui.dialogs.voice_command.send_text"
|
||||
)}
|
||||
>
|
||||
@@ -282,7 +308,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
|
||||
.path=${mdiMicrophone}
|
||||
@click=${this._handleListeningButton}
|
||||
.disabled=${this._processing}
|
||||
.label=${this.hass.localize(
|
||||
.label=${this._localize(
|
||||
"ui.dialogs.voice_command.start_listening"
|
||||
)}
|
||||
>
|
||||
@@ -391,21 +417,21 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
|
||||
text:
|
||||
// New lines matter for messages
|
||||
// prettier-ignore
|
||||
html`${this.hass.localize(
|
||||
html`${this._localize(
|
||||
"ui.dialogs.voice_command.not_supported_microphone_browser"
|
||||
)}
|
||||
|
||||
${this.hass.localize(
|
||||
${this._localize(
|
||||
"ui.dialogs.voice_command.not_supported_microphone_documentation",
|
||||
{
|
||||
documentation_link: html`<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href=${documentationUrl(
|
||||
this.hass,
|
||||
this._config,
|
||||
"/docs/configuration/securing/#remote-access"
|
||||
)}
|
||||
>${this.hass.localize(
|
||||
>${this._localize(
|
||||
"ui.dialogs.voice_command.not_supported_microphone_documentation_link"
|
||||
)}</a>`,
|
||||
}
|
||||
@@ -443,7 +469,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
|
||||
|
||||
try {
|
||||
const unsub = await runAssistPipeline(
|
||||
this.hass,
|
||||
this._connection,
|
||||
(event: PipelineRunEvent) => {
|
||||
if (event.type === "run-start") {
|
||||
this._stt_binary_handler_id =
|
||||
@@ -539,7 +565,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
|
||||
}
|
||||
|
||||
private _sendAudioChunk(chunk: Int16Array) {
|
||||
this.hass.connection.socket!.binaryType = "arraybuffer";
|
||||
this._connection.connection.socket!.binaryType = "arraybuffer";
|
||||
|
||||
// eslint-disable-next-line eqeqeq
|
||||
if (this._stt_binary_handler_id == undefined) {
|
||||
@@ -550,7 +576,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
|
||||
data[0] = this._stt_binary_handler_id;
|
||||
data.set(new Uint8Array(chunk.buffer), 1);
|
||||
|
||||
this.hass.connection.socket!.send(data);
|
||||
this._connection.connection.socket!.send(data);
|
||||
}
|
||||
|
||||
private _unloadAudio = () => {
|
||||
@@ -570,7 +596,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
|
||||
hassMessageProcesser.addMessage();
|
||||
try {
|
||||
const unsub = await runAssistPipeline(
|
||||
this.hass,
|
||||
this._connection,
|
||||
(event) => {
|
||||
if (event.type.startsWith("intent-")) {
|
||||
hassMessageProcesser.processEvent(event);
|
||||
@@ -593,7 +619,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre
|
||||
);
|
||||
} catch {
|
||||
hassMessageProcesser.setError(
|
||||
this.hass.localize("ui.dialogs.voice_command.error")
|
||||
this._localize("ui.dialogs.voice_command.error")
|
||||
);
|
||||
} finally {
|
||||
this._processing = false;
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
entitiesContext,
|
||||
} from "../data/context";
|
||||
import { attributeIcon } from "../data/icons";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-icon";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
@customElement("ha-attribute-icon")
|
||||
export class HaAttributeIcon extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public stateObj?: HassEntity;
|
||||
|
||||
@property() public attribute?: string;
|
||||
@@ -19,6 +23,18 @@ export class HaAttributeIcon extends LitElement {
|
||||
|
||||
@property() public icon?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config?: ContextType<typeof configContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection?: ContextType<typeof connectionContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: entitiesContext, subscribe: true })
|
||||
private _entities?: ContextType<typeof entitiesContext>;
|
||||
|
||||
protected render() {
|
||||
if (this.icon) {
|
||||
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
|
||||
@@ -28,12 +44,14 @@ export class HaAttributeIcon extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
if (!this.hass) {
|
||||
if (!this._config || !this._connection || !this._entities) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const icon = attributeIcon(
|
||||
this.hass,
|
||||
this._config.config,
|
||||
this._connection.connection,
|
||||
this._entities,
|
||||
this.stateObj,
|
||||
this.attribute,
|
||||
this.attributeValue
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import { getValueAttribute } from "../common/entity/get_states";
|
||||
import { formattersContext } from "../data/context";
|
||||
|
||||
@customElement("ha-attribute-value")
|
||||
@@ -56,6 +58,26 @@ class HaAttributeValue extends LitElement {
|
||||
return html`<pre>${until(yaml, "")}</pre>`;
|
||||
}
|
||||
|
||||
// Options-list attributes (effect_list, preset_modes, …) translated through
|
||||
// their value attribute, or the main state for lists like hvac_modes.
|
||||
if (Array.isArray(attributeValue)) {
|
||||
const domain = computeStateDomain(this.stateObj);
|
||||
const valueAttribute = getValueAttribute(domain, this.attribute);
|
||||
if (valueAttribute) {
|
||||
return attributeValue
|
||||
.map((item) =>
|
||||
valueAttribute === "_"
|
||||
? this._formatters!.formatEntityState(this.stateObj!, item)
|
||||
: this._formatters!.formatEntityAttributeValue(
|
||||
this.stateObj!,
|
||||
valueAttribute,
|
||||
item
|
||||
)
|
||||
)
|
||||
.join(", ");
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hideUnit) {
|
||||
const parts = this._formatters!.formatEntityAttributeValueToParts(
|
||||
this.stateObj!,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { formatNumber } from "../common/number/format_number";
|
||||
import { blankBeforeUnit } from "../common/translations/blank_before_unit";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { internationalizationContext } from "../data/context";
|
||||
|
||||
@customElement("ha-big-number")
|
||||
export class HaBigNumber extends LitElement {
|
||||
@@ -15,17 +17,16 @@ export class HaBigNumber extends LitElement {
|
||||
@property({ attribute: "unit-position" })
|
||||
public unitPosition: "top" | "bottom" = "top";
|
||||
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public formatOptions: Intl.NumberFormatOptions = {};
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
protected render() {
|
||||
const formatted = formatNumber(
|
||||
this.value,
|
||||
this.hass?.locale,
|
||||
this.formatOptions
|
||||
);
|
||||
const locale = this._i18n!.locale;
|
||||
const formatted = formatNumber(this.value, locale, this.formatOptions);
|
||||
const [integer] = formatted.includes(".")
|
||||
? formatted.split(".")
|
||||
: formatted.split(",");
|
||||
@@ -33,9 +34,7 @@ export class HaBigNumber extends LitElement {
|
||||
const temperatureDecimal = formatted.replace(integer, "");
|
||||
|
||||
const formattedValue = `${this.value}${
|
||||
this.unit
|
||||
? `${blankBeforeUnit(this.unit, this.hass?.locale)}${this.unit}`
|
||||
: ""
|
||||
this.unit ? `${blankBeforeUnit(this.unit, locale)}${this.unit}` : ""
|
||||
}`;
|
||||
|
||||
const unitBottom = this.unitPosition === "bottom";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
@@ -7,7 +8,7 @@ import memoizeOne from "memoize-one";
|
||||
import { computeStateName } from "../common/entity/compute_state_name";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import {
|
||||
CAMERA_SUPPORT_STREAM,
|
||||
CameraEntityFeature,
|
||||
type CameraCapabilities,
|
||||
type CameraEntity,
|
||||
computeMJPEGStreamUrl,
|
||||
@@ -17,7 +18,7 @@ import {
|
||||
STREAM_TYPE_WEB_RTC,
|
||||
type StreamType,
|
||||
} from "../data/camera";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { apiContext, configContext, connectionContext } from "../data/context";
|
||||
import "./ha-hls-player";
|
||||
import "./ha-web-rtc-player";
|
||||
|
||||
@@ -30,7 +31,17 @@ interface Stream {
|
||||
|
||||
@customElement("ha-camera-stream")
|
||||
export class HaCameraStream extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config!: ContextType<typeof configContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection!: ContextType<typeof connectionContext>;
|
||||
|
||||
@property({ attribute: false }) public stateObj?: CameraEntity;
|
||||
|
||||
@@ -58,21 +69,33 @@ export class HaCameraStream extends LitElement {
|
||||
|
||||
@state() private _webRtcStreams?: { hasAudio: boolean; hasVideo: boolean };
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>): void {
|
||||
private _thumbnailApi = memoizeOne(
|
||||
(
|
||||
api: ContextType<typeof apiContext>,
|
||||
connection: ContextType<typeof connectionContext>
|
||||
) => ({
|
||||
callWS: api.callWS,
|
||||
hassUrl: connection.hassUrl,
|
||||
})
|
||||
);
|
||||
|
||||
public willUpdate(changedProps: PropertyValues): void {
|
||||
const entityChanged =
|
||||
changedProps.has("stateObj") &&
|
||||
this.stateObj &&
|
||||
(changedProps.get("stateObj") as CameraEntity | undefined)?.entity_id !==
|
||||
this.stateObj.entity_id;
|
||||
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
const oldConfig = changedProps.get("_config") as
|
||||
| ContextType<typeof configContext>
|
||||
| undefined;
|
||||
const backendStarted =
|
||||
changedProps.has("hass") &&
|
||||
this.hass &&
|
||||
changedProps.has("_config") &&
|
||||
this._config &&
|
||||
this.stateObj &&
|
||||
oldHass &&
|
||||
this.hass.config.state === STATE_RUNNING &&
|
||||
oldHass.config?.state !== STATE_RUNNING;
|
||||
oldConfig &&
|
||||
this._config.config.state === STATE_RUNNING &&
|
||||
oldConfig.config?.state !== STATE_RUNNING;
|
||||
|
||||
if (entityChanged || backendStarted) {
|
||||
this._getCapabilities();
|
||||
@@ -137,7 +160,6 @@ export class HaCameraStream extends LitElement {
|
||||
.allowExoPlayer=${this.allowExoPlayer}
|
||||
.muted=${this.muted}
|
||||
.controls=${this.controls}
|
||||
.hass=${this.hass}
|
||||
.entityid=${this.stateObj.entity_id}
|
||||
.posterUrl=${this._posterUrl}
|
||||
@streams=${this._handleHlsStreams}
|
||||
@@ -153,7 +175,6 @@ export class HaCameraStream extends LitElement {
|
||||
playsinline
|
||||
.muted=${this.muted}
|
||||
.controls=${this.controls}
|
||||
.hass=${this.hass}
|
||||
.entityid=${this.stateObj.entity_id}
|
||||
.posterUrl=${this._posterUrl}
|
||||
@streams=${this._handleWebRtcStreams}
|
||||
@@ -170,12 +191,12 @@ export class HaCameraStream extends LitElement {
|
||||
this._capabilities = undefined;
|
||||
this._hlsStreams = undefined;
|
||||
this._webRtcStreams = undefined;
|
||||
if (!supportsFeature(this.stateObj!, CAMERA_SUPPORT_STREAM)) {
|
||||
if (!supportsFeature(this.stateObj!, CameraEntityFeature.STREAM)) {
|
||||
this._capabilities = { frontend_stream_types: [] };
|
||||
return;
|
||||
}
|
||||
this._capabilities = await fetchCameraCapabilities(
|
||||
this.hass!,
|
||||
this._api,
|
||||
this.stateObj!.entity_id
|
||||
);
|
||||
}
|
||||
@@ -183,7 +204,7 @@ export class HaCameraStream extends LitElement {
|
||||
private async _getPosterUrl(): Promise<void> {
|
||||
try {
|
||||
this._posterUrl = await fetchThumbnailUrlWithCache(
|
||||
this.hass!,
|
||||
this._thumbnailApi(this._api, this._connection),
|
||||
this.stateObj!.entity_id,
|
||||
this.clientWidth,
|
||||
this.clientHeight
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type HlsType from "hls.js";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { isComponentLoaded } from "../common/config/is_component_loaded";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { nextRender } from "../common/util/render-status";
|
||||
import { fetchStreamUrl } from "../data/camera";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { apiContext, configContext, connectionContext } from "../data/context";
|
||||
import "./ha-alert";
|
||||
|
||||
type HlsLite = Omit<
|
||||
@@ -17,7 +20,21 @@ type HlsLite = Omit<
|
||||
|
||||
@customElement("ha-hls-player")
|
||||
class HaHLSPlayer extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config!: ContextType<typeof configContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection!: ContextType<typeof connectionContext>;
|
||||
|
||||
@property() public entityid?: string;
|
||||
|
||||
@@ -140,7 +157,7 @@ class HaHLSPlayer extends LitElement {
|
||||
this._cleanUp();
|
||||
this._resetError();
|
||||
|
||||
if (!isComponentLoaded(this.hass.config, "stream")) {
|
||||
if (!isComponentLoaded(this._config.config, "stream")) {
|
||||
this._setFatalError("Streaming component is not loaded.");
|
||||
return;
|
||||
}
|
||||
@@ -149,9 +166,12 @@ class HaHLSPlayer extends LitElement {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { url } = await fetchStreamUrl(this.hass!, this.entityid);
|
||||
const { url } = await fetchStreamUrl(
|
||||
{ callWS: this._api.callWS, hassUrl: this._connection.hassUrl },
|
||||
this.entityid
|
||||
);
|
||||
|
||||
this._url = this.hass.hassUrl(url);
|
||||
this._url = this._connection.hassUrl(url);
|
||||
this._cleanUp();
|
||||
this._resetError();
|
||||
this._startHls();
|
||||
@@ -184,13 +204,13 @@ class HaHLSPlayer extends LitElement {
|
||||
|
||||
if (!hlsSupported) {
|
||||
this._setFatalError(
|
||||
this.hass.localize("ui.components.media-browser.video_not_supported")
|
||||
this._localize("ui.components.media-browser.video_not_supported")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const useExoPlayer =
|
||||
this.allowExoPlayer && this.hass.auth.external?.config.hasExoPlayer;
|
||||
this.allowExoPlayer && this._config.auth.external?.config.hasExoPlayer;
|
||||
const masterPlaylist = await (await masterPlaylistPromise).text();
|
||||
|
||||
if (!this.isConnected) {
|
||||
@@ -236,7 +256,7 @@ class HaHLSPlayer extends LitElement {
|
||||
window.addEventListener("resize", this._resizeExoPlayer);
|
||||
this.updateComplete.then(() => nextRender()).then(this._resizeExoPlayer);
|
||||
this._videoEl.style.visibility = "hidden";
|
||||
await this.hass!.auth.external!.fireMessage({
|
||||
await this._config.auth.external!.fireMessage({
|
||||
type: "exoplayer/play_hls",
|
||||
payload: {
|
||||
url,
|
||||
@@ -250,7 +270,7 @@ class HaHLSPlayer extends LitElement {
|
||||
return;
|
||||
}
|
||||
const rect = this._videoEl.getBoundingClientRect();
|
||||
this.hass!.auth.external!.fireMessage({
|
||||
this._config.auth.external!.fireMessage({
|
||||
type: "exoplayer/resize",
|
||||
payload: {
|
||||
left: rect.left,
|
||||
@@ -362,7 +382,7 @@ class HaHLSPlayer extends LitElement {
|
||||
}
|
||||
if (this._exoPlayer) {
|
||||
window.removeEventListener("resize", this._resizeExoPlayer);
|
||||
this.hass!.auth.external!.fireMessage({ type: "exoplayer/stop" });
|
||||
this._config.auth.external!.fireMessage({ type: "exoplayer/stop" });
|
||||
this._exoPlayer = false;
|
||||
}
|
||||
if (this._videoEl) {
|
||||
|
||||
@@ -101,15 +101,9 @@ export class HaLabelsPicker extends LitElement {
|
||||
language: string
|
||||
) =>
|
||||
value
|
||||
?.map(
|
||||
(id) =>
|
||||
labels?.find((label) => label.label_id === id) || {
|
||||
label_id: id,
|
||||
name: id,
|
||||
color: "rgba(var(--rgb-primary-text-color), 0.15)",
|
||||
}
|
||||
)
|
||||
.sort((a, b) => stringCompare(a?.name || "", b?.name || "", language))
|
||||
?.map((id) => labels?.find((label) => label.label_id === id))
|
||||
.filter((label): label is LabelRegistryEntry => label !== undefined)
|
||||
.sort((a, b) => stringCompare(a.name, b.name, language))
|
||||
.map((label) => ({
|
||||
...label,
|
||||
style: getLabelColorStyle(label.color),
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { HomeAssistantInternationalization } from "../types";
|
||||
class HaRelativeTime extends ReactiveElement {
|
||||
@property({ attribute: false }) public datetime?: string | Date;
|
||||
|
||||
@property() public format: Intl.RelativeTimeFormatStyle = "long";
|
||||
|
||||
@property({ type: Boolean }) public capitalize = false;
|
||||
|
||||
@state()
|
||||
@@ -36,13 +38,15 @@ class HaRelativeTime extends ReactiveElement {
|
||||
return this;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._updateRelative();
|
||||
}
|
||||
|
||||
protected update(changedProps: PropertyValues<this>) {
|
||||
super.update(changedProps);
|
||||
if (changedProps.has("datetime")) {
|
||||
if (this.datetime) {
|
||||
this._startInterval();
|
||||
} else {
|
||||
this._clearInterval();
|
||||
}
|
||||
}
|
||||
this._updateRelative();
|
||||
}
|
||||
|
||||
@@ -66,15 +70,23 @@ class HaRelativeTime extends ReactiveElement {
|
||||
}
|
||||
|
||||
if (!this.datetime) {
|
||||
this.innerHTML = this._i18n.localize("ui.components.relative_time.never");
|
||||
this.textContent = this._i18n.localize(
|
||||
"ui.components.relative_time.never"
|
||||
);
|
||||
} else {
|
||||
const date =
|
||||
typeof this.datetime === "string"
|
||||
? parseISO(this.datetime)
|
||||
: this.datetime;
|
||||
|
||||
const relTime = relativeTime(date, this._i18n.locale);
|
||||
this.innerHTML = this.capitalize
|
||||
const relTime = relativeTime(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
undefined,
|
||||
true,
|
||||
this.format
|
||||
);
|
||||
this.textContent = this.capitalize
|
||||
? capitalizeFirstLetter(relTime)
|
||||
: relTime;
|
||||
}
|
||||
|
||||
@@ -86,7 +86,10 @@ export class HaDateTimeSelector extends LitElement {
|
||||
static styles = css`
|
||||
.input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Align the input fields by their top edge so the date field's underline
|
||||
lines up with the time field, since ha-date-input reserves extra space
|
||||
below for its hint while ha-time-input does not. */
|
||||
align-items: flex-start;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../ha-time-format-picker";
|
||||
|
||||
@customElement("ha-selector-ui_time_format")
|
||||
export class HaSelectorUiTimeFormat extends LitElement {
|
||||
@property() public value?: string;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-time-format-picker
|
||||
.label=${this.label}
|
||||
.value=${this.value}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
</ha-time-format-picker>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-ui_time_format": HaSelectorUiTimeFormat;
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ const LOAD_ELEMENTS = {
|
||||
ui_action: () => import("./ha-selector-ui-action"),
|
||||
ui_color: () => import("./ha-selector-ui-color"),
|
||||
ui_state_content: () => import("./ha-selector-ui-state-content"),
|
||||
ui_time_format: () => import("./ha-selector-ui-time-format"),
|
||||
};
|
||||
|
||||
const LEGACY_UI_SELECTORS = new Set(["ui-action", "ui-color"]);
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import memoizeOne from "memoize-one";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "./ha-select";
|
||||
import type { TimestampRenderingFormat } from "../panels/lovelace/components/types";
|
||||
import { TIMESTAMP_RENDERING_FORMATS } from "../panels/lovelace/components/types";
|
||||
|
||||
@customElement("ha-time-format-picker")
|
||||
export class HaTimeFormatPicker extends LitElement {
|
||||
@property() public value?: TimestampRenderingFormat;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
private _options = memoizeOne((localize: LocalizeFunc) =>
|
||||
[{ label: localize("ui.common.auto"), value: "auto" }].concat(
|
||||
TIMESTAMP_RENDERING_FORMATS.map((format) => ({
|
||||
label:
|
||||
localize(`ui.components.time-format-picker.formats.${format}`) ||
|
||||
format,
|
||||
value: format,
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
private _styleOptions = memoizeOne((localize: LocalizeFunc) => [
|
||||
{ label: localize("ui.common.auto"), value: "auto" },
|
||||
{
|
||||
label: localize("ui.components.time-format-picker.styles.short"),
|
||||
value: "short",
|
||||
},
|
||||
{
|
||||
label: localize("ui.components.time-format-picker.styles.long"),
|
||||
value: "long",
|
||||
},
|
||||
]);
|
||||
|
||||
protected render() {
|
||||
const type = typeof this.value === "object" ? this.value.type : this.value;
|
||||
const style = typeof this.value === "object" ? this.value.style : undefined;
|
||||
return html`
|
||||
<div class="row">
|
||||
<ha-select
|
||||
.label=${this.label ?? ""}
|
||||
.value=${type || "auto"}
|
||||
.helper=${this.helper ?? ""}
|
||||
.disabled=${this.disabled}
|
||||
@selected=${this._selectChanged}
|
||||
.options=${this._options(this._localize)}
|
||||
>
|
||||
</ha-select>
|
||||
${this.value
|
||||
? html`
|
||||
<ha-select
|
||||
.label=${this._localize(
|
||||
"ui.components.time-format-picker.style"
|
||||
)}
|
||||
.value=${style || "auto"}
|
||||
.disabled=${this.disabled}
|
||||
@selected=${this._styleChanged}
|
||||
.options=${this._styleOptions(this._localize)}
|
||||
>
|
||||
</ha-select>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _selectChanged(ev) {
|
||||
ev.stopPropagation();
|
||||
if (ev.detail?.value === "auto" && this.value !== undefined) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.value && typeof this.value === "object" && this.value.style) {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
type: ev.detail.value,
|
||||
style: this.value.style,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value: ev.detail.value,
|
||||
});
|
||||
}
|
||||
|
||||
private _styleChanged(ev) {
|
||||
ev.stopPropagation();
|
||||
const type = typeof this.value === "object" ? this.value.type : this.value;
|
||||
if (ev.detail?.value === "auto") {
|
||||
fireEvent(this, "value-changed", {
|
||||
value: type,
|
||||
});
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
type: type,
|
||||
style: ev.detail.value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row > * {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-time-format-picker": HaTimeFormatPicker;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
webRtcOffer,
|
||||
type WebRtcOfferEvent,
|
||||
} from "../data/camera";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { apiContext, connectionContext } from "../data/context";
|
||||
import "./ha-alert";
|
||||
|
||||
/**
|
||||
@@ -23,7 +24,13 @@ import "./ha-alert";
|
||||
*/
|
||||
@customElement("ha-web-rtc-player")
|
||||
class HaWebRtcPlayer extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state()
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection!: ContextType<typeof connectionContext>;
|
||||
|
||||
@property() public entityid?: string;
|
||||
|
||||
@@ -130,7 +137,7 @@ class HaWebRtcPlayer extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.hass || !this.entityid) {
|
||||
if (!this._api || !this._connection || !this.entityid) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -141,7 +148,7 @@ class HaWebRtcPlayer extends LitElement {
|
||||
this._logEvent("start clientConfig");
|
||||
|
||||
this._clientConfig = await fetchWebRtcClientConfiguration(
|
||||
this.hass,
|
||||
this._api,
|
||||
this.entityid
|
||||
);
|
||||
|
||||
@@ -230,8 +237,11 @@ class HaWebRtcPlayer extends LitElement {
|
||||
this._logEvent("start webRtcOffer", offer_sdp);
|
||||
|
||||
try {
|
||||
this._unsub = webRtcOffer(this.hass, this.entityid, offer_sdp, (event) =>
|
||||
this._handleOfferEvent(event)
|
||||
this._unsub = webRtcOffer(
|
||||
this._connection,
|
||||
this.entityid,
|
||||
offer_sdp,
|
||||
(event) => this._handleOfferEvent(event)
|
||||
);
|
||||
} catch (err: any) {
|
||||
this._error = "Failed to start WebRTC stream: " + err.message;
|
||||
@@ -257,7 +267,7 @@ class HaWebRtcPlayer extends LitElement {
|
||||
this._sessionId = event.session_id;
|
||||
this._candidatesList.forEach((candidate) =>
|
||||
addWebRtcCandidate(
|
||||
this.hass,
|
||||
this._api,
|
||||
this.entityid!,
|
||||
event.session_id,
|
||||
// toJSON returns RTCIceCandidateInit
|
||||
@@ -310,7 +320,7 @@ class HaWebRtcPlayer extends LitElement {
|
||||
|
||||
if (this._sessionId) {
|
||||
addWebRtcCandidate(
|
||||
this.hass,
|
||||
this._api,
|
||||
this.entityid,
|
||||
this._sessionId,
|
||||
// toJSON returns RTCIceCandidateInit
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { LitElement, html, css } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../ha-state-icon";
|
||||
|
||||
@customElement("ha-entity-marker")
|
||||
class HaEntityMarker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: "entity-id", reflect: true }) public entityId?: string;
|
||||
|
||||
@state()
|
||||
@consumeEntityState({ entityIdPath: ["entityId"] })
|
||||
private _stateObj?: HassEntity;
|
||||
|
||||
@property({ attribute: "entity-name" }) public entityName?: string;
|
||||
|
||||
@property({ attribute: "entity-unit" }) public entityUnit?: string;
|
||||
@@ -36,9 +39,7 @@ class HaEntityMarker extends LitElement {
|
||||
})}
|
||||
></div>`
|
||||
: this.showIcon && this.entityId
|
||||
? html`<ha-state-icon
|
||||
.stateObj=${this.hass?.states[this.entityId]}
|
||||
></ha-state-icon>`
|
||||
? html`<ha-state-icon .stateObj=${this._stateObj}></ha-state-icon>`
|
||||
: !this.entityUnit
|
||||
? this.entityName
|
||||
: html`
|
||||
|
||||
@@ -128,7 +128,6 @@ export class HaLocationsEditor extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-map
|
||||
.hass=${this.hass}
|
||||
.layers=${this._getLayers(this._circles, this._locationMarkers)}
|
||||
.zoom=${this.zoom}
|
||||
.autoFit=${this.autoFit}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { isToday } from "date-fns";
|
||||
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
Circle,
|
||||
CircleMarker,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
formatTimeWeekday,
|
||||
formatTimeWithSeconds,
|
||||
} from "../../common/datetime/format_time";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
|
||||
import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
|
||||
@@ -26,7 +29,22 @@ import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { getEntityLocation } from "../../common/entity/get_entity_location";
|
||||
import { DecoratedMarker } from "../../common/map/decorated_marker";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import type { HomeAssistant, ThemeMode } from "../../types";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
formattersContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
uiContext,
|
||||
} from "../../data/context";
|
||||
import type {
|
||||
HomeAssistantConfig,
|
||||
HomeAssistantConnection,
|
||||
HomeAssistantFormatters,
|
||||
HomeAssistantInternationalization,
|
||||
HomeAssistantUI,
|
||||
ThemeMode,
|
||||
} from "../../types";
|
||||
import { isTouch } from "../../util/is_touch";
|
||||
import "../ha-icon-button";
|
||||
import "./ha-entity-marker";
|
||||
@@ -76,7 +94,32 @@ export interface HaMapEntity {
|
||||
|
||||
@customElement("ha-map")
|
||||
export class HaMap extends ReactiveElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: HassEntities;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
@transform<HomeAssistantConfig, HassConfig>({
|
||||
transformer: ({ config }) => config,
|
||||
})
|
||||
private _config!: HassConfig;
|
||||
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
private _ui!: HomeAssistantUI;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: HomeAssistantInternationalization;
|
||||
|
||||
@state()
|
||||
@consume({ context: formattersContext, subscribe: true })
|
||||
private _formatters!: HomeAssistantFormatters;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
private _connection!: HomeAssistantConnection;
|
||||
|
||||
@property({ attribute: false }) public entities?: string[] | HaMapEntity[];
|
||||
|
||||
@@ -175,17 +218,16 @@ export class HaMap extends ReactiveElement {
|
||||
return;
|
||||
}
|
||||
let autoFitRequired = false;
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
const oldStates = changedProps.get("_states") as HassEntities | undefined;
|
||||
|
||||
if (changedProps.has("_loaded") || changedProps.has("entities")) {
|
||||
this._drawEntities();
|
||||
autoFitRequired = !this._pauseAutoFit;
|
||||
} else if (this._loaded && oldHass && this.entities) {
|
||||
} else if (this._loaded && oldStates && this.entities) {
|
||||
// Check if any state has changed
|
||||
for (const entity of this.entities) {
|
||||
if (
|
||||
oldHass.states[getEntityId(entity)] !==
|
||||
this.hass!.states[getEntityId(entity)]
|
||||
oldStates[getEntityId(entity)] !== this._states[getEntityId(entity)]
|
||||
) {
|
||||
this._drawEntities();
|
||||
autoFitRequired = !this._pauseAutoFit;
|
||||
@@ -219,10 +261,11 @@ export class HaMap extends ReactiveElement {
|
||||
}, PROGRAMMITIC_FIT_DELAY);
|
||||
}
|
||||
|
||||
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
|
||||
if (
|
||||
!changedProps.has("themeMode") &&
|
||||
(!changedProps.has("hass") ||
|
||||
(oldHass && oldHass.themes?.darkMode === this.hass.themes?.darkMode))
|
||||
(!changedProps.has("_ui") ||
|
||||
(oldUi && oldUi.themes?.darkMode === this._ui.themes?.darkMode))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -233,7 +276,7 @@ export class HaMap extends ReactiveElement {
|
||||
private get _darkMode() {
|
||||
return (
|
||||
this.themeMode === "dark" ||
|
||||
(this.themeMode === "auto" && Boolean(this.hass.themes.darkMode))
|
||||
(this.themeMode === "auto" && Boolean(this._ui?.themes.darkMode))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -258,8 +301,8 @@ export class HaMap extends ReactiveElement {
|
||||
this._loading = true;
|
||||
try {
|
||||
[this.leafletMap, this.Leaflet] = await setupLeafletMap(map, {
|
||||
latitude: this.hass?.config.latitude ?? 52.3731339,
|
||||
longitude: this.hass?.config.longitude ?? 4.8903147,
|
||||
latitude: this._config?.latitude ?? 52.3731339,
|
||||
longitude: this._config?.longitude ?? 4.8903147,
|
||||
zoom: this.zoom,
|
||||
});
|
||||
this._updateMapStyle();
|
||||
@@ -300,7 +343,7 @@ export class HaMap extends ReactiveElement {
|
||||
if (options?.unpause_autofit) {
|
||||
this._pauseAutoFit = false;
|
||||
}
|
||||
if (!this.leafletMap || !this.Leaflet || !this.hass) {
|
||||
if (!this.leafletMap || !this.Leaflet || !this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -311,10 +354,7 @@ export class HaMap extends ReactiveElement {
|
||||
) {
|
||||
this._isProgrammaticFit = true;
|
||||
this.leafletMap.setView(
|
||||
new this.Leaflet.LatLng(
|
||||
this.hass.config.latitude,
|
||||
this.hass.config.longitude
|
||||
),
|
||||
new this.Leaflet.LatLng(this._config.latitude, this._config.longitude),
|
||||
options?.zoom || this.zoom
|
||||
);
|
||||
setTimeout(() => {
|
||||
@@ -351,7 +391,7 @@ export class HaMap extends ReactiveElement {
|
||||
boundingbox: LatLngExpression[],
|
||||
options?: { zoom?: number; pad?: number }
|
||||
) {
|
||||
if (!this.leafletMap || !this.Leaflet || !this.hass) {
|
||||
if (!this.leafletMap || !this.Leaflet) {
|
||||
return;
|
||||
}
|
||||
const bounds = this.Leaflet.latLngBounds(boundingbox).pad(
|
||||
@@ -382,32 +422,31 @@ export class HaMap extends ReactiveElement {
|
||||
if (path.fullDatetime) {
|
||||
formattedTime = formatDateTime(
|
||||
point.timestamp,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
this._i18n.locale,
|
||||
this._config
|
||||
);
|
||||
} else if (isToday(point.timestamp)) {
|
||||
formattedTime = formatTimeWithSeconds(
|
||||
point.timestamp,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
this._i18n.locale,
|
||||
this._config
|
||||
);
|
||||
} else {
|
||||
formattedTime = formatTimeWeekday(
|
||||
point.timestamp,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
this._i18n.locale,
|
||||
this._config
|
||||
);
|
||||
}
|
||||
return `${filterXSS(path.name ?? "")}<br>${formattedTime}`;
|
||||
}
|
||||
|
||||
private _drawPaths(): void {
|
||||
const hass = this.hass;
|
||||
const map = this.leafletMap;
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const Leaflet = this.Leaflet;
|
||||
|
||||
if (!hass || !map || !Leaflet) {
|
||||
if (!this._i18n || !this._config || !map || !Leaflet) {
|
||||
return;
|
||||
}
|
||||
if (this._mapPaths.length) {
|
||||
@@ -535,12 +574,12 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
|
||||
private _drawEntities(): void {
|
||||
const hass = this.hass;
|
||||
const states = this._states;
|
||||
const map = this.leafletMap;
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const Leaflet = this.Leaflet;
|
||||
|
||||
if (!hass || !map || !Leaflet) {
|
||||
if (!states || !map || !Leaflet) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -578,7 +617,7 @@ export class HaMap extends ReactiveElement {
|
||||
const className = this._darkMode ? "dark" : "light";
|
||||
|
||||
for (const entity of this.entities) {
|
||||
const stateObj = hass.states[getEntityId(entity)];
|
||||
const stateObj = states[getEntityId(entity)];
|
||||
if (!stateObj) {
|
||||
continue;
|
||||
}
|
||||
@@ -591,7 +630,7 @@ export class HaMap extends ReactiveElement {
|
||||
entity_picture: entityPicture,
|
||||
} = stateObj.attributes;
|
||||
|
||||
const location = getEntityLocation(stateObj, hass.states);
|
||||
const location = getEntityLocation(stateObj, states);
|
||||
if (!location) {
|
||||
continue;
|
||||
}
|
||||
@@ -648,11 +687,14 @@ export class HaMap extends ReactiveElement {
|
||||
// create icon
|
||||
const entityName =
|
||||
typeof entity !== "string" && entity.label_mode === "state"
|
||||
? this.hass.formatEntityState(stateObj)
|
||||
? this._formatters.formatEntityState(stateObj)
|
||||
: typeof entity !== "string" &&
|
||||
entity.label_mode === "attribute" &&
|
||||
entity.attribute !== undefined
|
||||
? this.hass.formatEntityAttributeValue(stateObj, entity.attribute)
|
||||
? this._formatters.formatEntityAttributeValue(
|
||||
stateObj,
|
||||
entity.attribute
|
||||
)
|
||||
: (customTitle ??
|
||||
title
|
||||
.split(" ")
|
||||
@@ -661,7 +703,6 @@ export class HaMap extends ReactiveElement {
|
||||
.substr(0, 3));
|
||||
|
||||
const entityMarker = document.createElement("ha-entity-marker");
|
||||
entityMarker.hass = this.hass;
|
||||
entityMarker.showIcon =
|
||||
typeof entity !== "string" && entity.label_mode === "icon";
|
||||
entityMarker.entityId = getEntityId(entity);
|
||||
@@ -674,7 +715,7 @@ export class HaMap extends ReactiveElement {
|
||||
: "";
|
||||
entityMarker.entityPicture =
|
||||
entityPicture && (typeof entity === "string" || !entity.label_mode)
|
||||
? this.hass.hassUrl(entityPicture)
|
||||
? this._connection.hassUrl(entityPicture)
|
||||
: "";
|
||||
if (typeof entity !== "string") {
|
||||
entityMarker.entityColor = entity.color;
|
||||
|
||||
@@ -26,7 +26,6 @@ export class HaTraceLogbook extends LitElement {
|
||||
return this.logbookEntries.length
|
||||
? html`
|
||||
<ha-logbook-renderer
|
||||
relative-time
|
||||
.hass=${this.hass}
|
||||
.entries=${this.logbookEntries}
|
||||
.narrow=${this.narrow}
|
||||
|
||||
@@ -388,7 +388,6 @@ export class HaTracePathDetails extends LitElement {
|
||||
return entries.length
|
||||
? html`
|
||||
<ha-logbook-renderer
|
||||
relative-time
|
||||
.hass=${this.hass}
|
||||
.entries=${entries}
|
||||
.narrow=${this.narrow}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { toggleAttribute } from "../../common/dom/toggle_attribute";
|
||||
import { fullEntitiesContext } from "../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { LogbookEntry } from "../../data/logbook";
|
||||
import { localizeTriggerDescription } from "../../data/logbook";
|
||||
import { localizeTriggerSource } from "../../data/logbook";
|
||||
import type {
|
||||
ChooseAction,
|
||||
IfAction,
|
||||
@@ -333,7 +333,7 @@ class ActionRenderer {
|
||||
: "other",
|
||||
alias: triggerStep.changed_variables.trigger?.alias,
|
||||
triggeredPath: triggerStep.path === "trigger" ? "manual" : "trigger",
|
||||
trigger: localizeTriggerDescription(
|
||||
trigger: localizeTriggerSource(
|
||||
this.hass.localize,
|
||||
this.trace.trigger
|
||||
),
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { Selector } from "./selector";
|
||||
|
||||
export const enum AITaskEntityFeature {
|
||||
export enum AITaskEntityFeature {
|
||||
GENERATE_DATA = 1,
|
||||
SUPPORT_ATTACHMENTS = 2,
|
||||
GENERATE_IMAGE = 4,
|
||||
|
||||
@@ -18,7 +18,7 @@ import { getExtendedEntityRegistryEntry } from "./entity/entity_registry";
|
||||
export const FORMAT_TEXT = "text";
|
||||
export const FORMAT_NUMBER = "number";
|
||||
|
||||
export const enum AlarmControlPanelEntityFeature {
|
||||
export enum AlarmControlPanelEntityFeature {
|
||||
ARM_HOME = 1,
|
||||
ARM_AWAY = 2,
|
||||
ARM_NIGHT = 4,
|
||||
@@ -108,7 +108,7 @@ export const supportedAlarmModes = (stateObj: AlarmControlPanelEntity) =>
|
||||
|
||||
export const setProtectedAlarmControlPanelMode = async (
|
||||
element: HTMLElement,
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "callService" | "localize" | "callWS">,
|
||||
stateObj: AlarmControlPanelEntity,
|
||||
mode: AlarmMode
|
||||
) => {
|
||||
|
||||
@@ -338,7 +338,7 @@ export const runDebugAssistPipeline = (
|
||||
};
|
||||
|
||||
export const runAssistPipeline = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "connection">,
|
||||
callback: (event: PipelineRunEvent) => void,
|
||||
options: PipelineRunOptions
|
||||
) =>
|
||||
@@ -379,7 +379,10 @@ export const listAssistPipelines = (hass: HomeAssistant) =>
|
||||
type: "assist_pipeline/pipeline/list",
|
||||
});
|
||||
|
||||
export const getAssistPipeline = (hass: HomeAssistant, pipeline_id?: string) =>
|
||||
export const getAssistPipeline = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
pipeline_id?: string
|
||||
) =>
|
||||
hass.callWS<AssistPipeline>({
|
||||
type: "assist_pipeline/pipeline/get",
|
||||
pipeline_id,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { UNAVAILABLE } from "./entity/entity";
|
||||
|
||||
export const enum AssistSatelliteEntityFeature {
|
||||
export enum AssistSatelliteEntityFeature {
|
||||
ANNOUNCE = 1,
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ export const autocompleteLoginFields = (schema: HaFormSchema[]) =>
|
||||
});
|
||||
|
||||
export const getSignedPath = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
path: string
|
||||
): Promise<SignedPath> => hass.callWS({ type: "auth/sign_path", path });
|
||||
|
||||
|
||||
@@ -377,7 +377,7 @@ export const expandConditionWithShorthand = (
|
||||
};
|
||||
|
||||
export const triggerAutomationActions = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "callService">,
|
||||
entityId: string
|
||||
) => {
|
||||
hass.callService("automation", "trigger", {
|
||||
|
||||
+1
-1
@@ -181,7 +181,7 @@ export interface RestoreBackupParams {
|
||||
restore_homeassistant?: boolean;
|
||||
}
|
||||
|
||||
export const fetchBackupConfig = (hass: HomeAssistant) =>
|
||||
export const fetchBackupConfig = (hass: Pick<HomeAssistant, "callWS">) =>
|
||||
hass.callWS<{ config: BackupConfig }>({ type: "backup/config/info" });
|
||||
|
||||
export const updateBackupConfig = (
|
||||
|
||||
@@ -54,7 +54,7 @@ export enum RecurrenceRange {
|
||||
THISANDFUTURE = "THISANDFUTURE",
|
||||
}
|
||||
|
||||
export const enum CalendarEntityFeature {
|
||||
export enum CalendarEntityFeature {
|
||||
CREATE_EVENT = 1,
|
||||
DELETE_EVENT = 2,
|
||||
UPDATE_EVENT = 4,
|
||||
|
||||
+12
-9
@@ -7,14 +7,17 @@ import type { HomeAssistant } from "../types";
|
||||
import { getSignedPath } from "./auth";
|
||||
|
||||
export const CAMERA_ORIENTATIONS = [1, 2, 3, 4, 6, 8];
|
||||
export const CAMERA_SUPPORT_ON_OFF = 1;
|
||||
export const CAMERA_SUPPORT_STREAM = 2;
|
||||
|
||||
export const STREAM_TYPE_HLS = "hls";
|
||||
export const STREAM_TYPE_WEB_RTC = "web_rtc";
|
||||
|
||||
export type StreamType = typeof STREAM_TYPE_HLS | typeof STREAM_TYPE_WEB_RTC;
|
||||
|
||||
export enum CameraEntityFeature {
|
||||
ON_OFF = 1,
|
||||
STREAM = 2,
|
||||
}
|
||||
|
||||
interface CameraEntityAttributes extends HassEntityAttributeBase {
|
||||
model_name: string;
|
||||
access_token?: string;
|
||||
@@ -86,7 +89,7 @@ export const computeMJPEGStreamUrl = (
|
||||
: undefined;
|
||||
|
||||
export const fetchThumbnailUrlWithCache = async (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
|
||||
entityId: string,
|
||||
width: number,
|
||||
height: number
|
||||
@@ -102,7 +105,7 @@ export const fetchThumbnailUrlWithCache = async (
|
||||
};
|
||||
|
||||
export const fetchThumbnailUrl = async (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
|
||||
entityId: string
|
||||
) => {
|
||||
const path = await getSignedPath(hass, `/api/camera_proxy/${entityId}`);
|
||||
@@ -110,7 +113,7 @@ export const fetchThumbnailUrl = async (
|
||||
};
|
||||
|
||||
export const fetchStreamUrl = async (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "callWS" | "hassUrl">,
|
||||
entityId: string,
|
||||
format?: "hls"
|
||||
) => {
|
||||
@@ -128,7 +131,7 @@ export const fetchStreamUrl = async (
|
||||
};
|
||||
|
||||
export const webRtcOffer = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "connection">,
|
||||
entity_id: string,
|
||||
offer: string,
|
||||
callback: (event: WebRtcOfferEvent) => void
|
||||
@@ -140,7 +143,7 @@ export const webRtcOffer = (
|
||||
});
|
||||
|
||||
export const addWebRtcCandidate = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
entity_id: string,
|
||||
session_id: string,
|
||||
candidate: RTCIceCandidateInit
|
||||
@@ -186,7 +189,7 @@ export interface CameraCapabilities {
|
||||
}
|
||||
|
||||
export const fetchCameraCapabilities = async (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
entity_id: string
|
||||
) =>
|
||||
hass.callWS<CameraCapabilities>({ type: "camera/capabilities", entity_id });
|
||||
@@ -197,7 +200,7 @@ export interface WebRTCClientConfiguration {
|
||||
}
|
||||
|
||||
export const fetchWebRtcClientConfiguration = async (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
entityId: string
|
||||
) =>
|
||||
hass.callWS<WebRTCClientConfiguration>({
|
||||
|
||||
+1
-1
@@ -68,7 +68,7 @@ export type ClimateEntity = HassEntityBase & {
|
||||
};
|
||||
};
|
||||
|
||||
export const enum ClimateEntityFeature {
|
||||
export enum ClimateEntityFeature {
|
||||
TARGET_TEMPERATURE = 1,
|
||||
TARGET_TEMPERATURE_RANGE = 2,
|
||||
TARGET_HUMIDITY = 4,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export const enum ConversationEntityFeature {
|
||||
export enum ConversationEntityFeature {
|
||||
CONTROL = 1,
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -4,10 +4,10 @@ import type {
|
||||
} from "home-assistant-js-websocket";
|
||||
import { stateActive } from "../common/entity/state_active";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistantFormatters } from "../types";
|
||||
import { UNAVAILABLE } from "./entity/entity";
|
||||
|
||||
export const enum CoverEntityFeature {
|
||||
export enum CoverEntityFeature {
|
||||
OPEN = 1,
|
||||
CLOSE = 2,
|
||||
SET_POSITION = 4,
|
||||
@@ -122,7 +122,7 @@ export interface CoverEntity extends HassEntityBase {
|
||||
|
||||
export function computeCoverPositionStateDisplay(
|
||||
stateObj: CoverEntity,
|
||||
hass: HomeAssistant,
|
||||
formatEntityAttributeValue: HomeAssistantFormatters["formatEntityAttributeValue"],
|
||||
position?: number
|
||||
) {
|
||||
const statePosition = stateActive(stateObj)
|
||||
@@ -133,7 +133,7 @@ export function computeCoverPositionStateDisplay(
|
||||
const currentPosition = position ?? statePosition;
|
||||
|
||||
return currentPosition && currentPosition !== 100
|
||||
? hass.formatEntityAttributeValue(
|
||||
? formatEntityAttributeValue(
|
||||
stateObj,
|
||||
// Always use position as it's the same formatting as tilt position
|
||||
"current_position",
|
||||
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
import type { HassEntityBase } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistantApi } from "../types";
|
||||
|
||||
export const stateToIsoDateString = (entityState: HassEntityBase) =>
|
||||
`${entityState}T00:00:00`;
|
||||
|
||||
export const setDateValue = (
|
||||
hass: HomeAssistant,
|
||||
callService: HomeAssistantApi["callService"],
|
||||
entityId: string,
|
||||
date: string | undefined = undefined
|
||||
) => {
|
||||
const param = { entity_id: entityId, date };
|
||||
hass.callService("date", "set_value", param);
|
||||
callService("date", "set_value", param);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistantApi } from "../types";
|
||||
|
||||
export const setDateTimeValue = (
|
||||
hass: HomeAssistant,
|
||||
callService: HomeAssistantApi["callService"],
|
||||
entityId: string,
|
||||
datetime: Date
|
||||
) => {
|
||||
hass.callService("datetime", "set_value", {
|
||||
callService("datetime", "set_value", {
|
||||
entity_id: entityId,
|
||||
datetime: datetime.toISOString(),
|
||||
});
|
||||
|
||||
+20
-16
@@ -1121,14 +1121,12 @@ const getSummedDataPartial = (
|
||||
const timestamps = new Set<number>();
|
||||
Object.entries(statIds).forEach(([key, subStatIds]) => {
|
||||
const totalStats: Record<number, number> = {};
|
||||
const sets: Record<string, Record<number, number>> = {};
|
||||
let sum = 0;
|
||||
subStatIds!.forEach((id) => {
|
||||
const stats = compare ? data.statsCompare[id] : data.stats[id];
|
||||
if (!stats) {
|
||||
return;
|
||||
}
|
||||
const set = {};
|
||||
stats.forEach((stat) => {
|
||||
if (stat.change === null || stat.change === undefined) {
|
||||
return;
|
||||
@@ -1139,7 +1137,6 @@ const getSummedDataPartial = (
|
||||
stat.start in totalStats ? totalStats[stat.start] + val : val;
|
||||
timestamps.add(stat.start);
|
||||
});
|
||||
sets[id] = set;
|
||||
});
|
||||
summedData[key] = totalStats;
|
||||
summedData.total[key] = sum;
|
||||
@@ -1190,6 +1187,13 @@ const computeConsumptionDataPartial = (
|
||||
},
|
||||
};
|
||||
|
||||
const fromGrid = data.from_grid;
|
||||
const toGrid = data.to_grid;
|
||||
const solarData = data.solar;
|
||||
const toBattery = data.to_battery;
|
||||
const fromBattery = data.from_battery;
|
||||
const total = outData.total;
|
||||
|
||||
data.timestamps.forEach((t) => {
|
||||
const {
|
||||
grid_to_battery,
|
||||
@@ -1201,29 +1205,29 @@ const computeConsumptionDataPartial = (
|
||||
solar_to_battery,
|
||||
solar_to_grid,
|
||||
} = computeConsumptionSingle({
|
||||
from_grid: data.from_grid && (data.from_grid[t] ?? 0),
|
||||
to_grid: data.to_grid && (data.to_grid[t] ?? 0),
|
||||
solar: data.solar && (data.solar[t] ?? 0),
|
||||
to_battery: data.to_battery && (data.to_battery[t] ?? 0),
|
||||
from_battery: data.from_battery && (data.from_battery[t] ?? 0),
|
||||
from_grid: fromGrid && (fromGrid[t] ?? 0),
|
||||
to_grid: toGrid && (toGrid[t] ?? 0),
|
||||
solar: solarData && (solarData[t] ?? 0),
|
||||
to_battery: toBattery && (toBattery[t] ?? 0),
|
||||
from_battery: fromBattery && (fromBattery[t] ?? 0),
|
||||
});
|
||||
|
||||
outData.used_total[t] = used_total;
|
||||
outData.total.used_total += used_total;
|
||||
total.used_total += used_total;
|
||||
outData.grid_to_battery[t] = grid_to_battery;
|
||||
outData.total.grid_to_battery += grid_to_battery;
|
||||
total.grid_to_battery += grid_to_battery;
|
||||
outData.battery_to_grid![t] = battery_to_grid;
|
||||
outData.total.battery_to_grid += battery_to_grid;
|
||||
total.battery_to_grid += battery_to_grid;
|
||||
outData.used_battery![t] = used_battery;
|
||||
outData.total.used_battery += used_battery;
|
||||
total.used_battery += used_battery;
|
||||
outData.used_grid![t] = used_grid;
|
||||
outData.total.used_grid += used_grid;
|
||||
total.used_grid += used_grid;
|
||||
outData.used_solar![t] = used_solar;
|
||||
outData.total.used_solar += used_solar;
|
||||
total.used_solar += used_solar;
|
||||
outData.solar_to_battery[t] = solar_to_battery;
|
||||
outData.total.solar_to_battery += solar_to_battery;
|
||||
total.solar_to_battery += solar_to_battery;
|
||||
outData.solar_to_grid[t] = solar_to_grid;
|
||||
outData.total.solar_to_grid += solar_to_grid;
|
||||
total.solar_to_grid += solar_to_grid;
|
||||
});
|
||||
|
||||
return outData;
|
||||
|
||||
@@ -211,14 +211,14 @@ export interface EntityRegistryEntryUpdateParams {
|
||||
|
||||
const batteryPriorities = ["sensor", "binary_sensor"];
|
||||
export const findBatteryEntity = <T extends { entity_id: string }>(
|
||||
hass: HomeAssistant,
|
||||
states: HomeAssistant["states"],
|
||||
entities: T[]
|
||||
): T | undefined => {
|
||||
const batteryEntities = entities
|
||||
.filter(
|
||||
(entity) =>
|
||||
hass.states[entity.entity_id] &&
|
||||
hass.states[entity.entity_id].attributes.device_class === "battery" &&
|
||||
states[entity.entity_id] &&
|
||||
states[entity.entity_id].attributes.device_class === "battery" &&
|
||||
batteryPriorities.includes(computeDomain(entity.entity_id))
|
||||
)
|
||||
.sort(
|
||||
@@ -234,14 +234,13 @@ export const findBatteryEntity = <T extends { entity_id: string }>(
|
||||
};
|
||||
|
||||
export const findBatteryChargingEntity = <T extends { entity_id: string }>(
|
||||
hass: HomeAssistant,
|
||||
states: HomeAssistant["states"],
|
||||
entities: T[]
|
||||
): T | undefined =>
|
||||
entities.find(
|
||||
(entity) =>
|
||||
hass.states[entity.entity_id] &&
|
||||
hass.states[entity.entity_id].attributes.device_class ===
|
||||
"battery_charging"
|
||||
states[entity.entity_id] &&
|
||||
states[entity.entity_id].attributes.device_class === "battery_charging"
|
||||
);
|
||||
|
||||
export const computeEntityRegistryName = (
|
||||
@@ -259,7 +258,7 @@ export const computeEntityRegistryName = (
|
||||
};
|
||||
|
||||
export const getExtendedEntityRegistryEntry = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
entityId: string
|
||||
): Promise<ExtEntityRegistryEntry> =>
|
||||
hass.callWS({
|
||||
@@ -277,7 +276,7 @@ export const getExtendedEntityRegistryEntries = (
|
||||
});
|
||||
|
||||
export const updateEntityRegistryEntry = (
|
||||
hass: HomeAssistant,
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
entityId: string,
|
||||
updates: Partial<EntityRegistryEntryUpdateParams>
|
||||
): Promise<UpdateEntityRegistryEntryResult> =>
|
||||
|
||||
@@ -7,11 +7,12 @@ interface EntitySource {
|
||||
|
||||
export type EntitySources = Record<string, EntitySource>;
|
||||
|
||||
const fetchEntitySources = (hass: HomeAssistant): Promise<EntitySources> =>
|
||||
hass.callWS({ type: "entity/source" });
|
||||
const fetchEntitySources = (
|
||||
hass: Pick<HomeAssistant, "callWS">
|
||||
): Promise<EntitySources> => hass.callWS({ type: "entity/source" });
|
||||
|
||||
export const fetchEntitySourcesWithCache = (
|
||||
hass: HomeAssistant
|
||||
hass: Pick<HomeAssistant, "callWS" | "states">
|
||||
): Promise<EntitySources> =>
|
||||
timeCachePromiseFunc(
|
||||
"_entitySources",
|
||||
|
||||
+3
-3
@@ -12,7 +12,7 @@ import type {
|
||||
import { stateActive } from "../common/entity/state_active";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export const enum FanEntityFeature {
|
||||
export enum FanEntityFeature {
|
||||
SET_SPEED = 1,
|
||||
OSCILLATE = 2,
|
||||
DIRECTION = 4,
|
||||
@@ -100,7 +100,7 @@ export const FAN_SPEED_COUNT_MAX_FOR_BUTTONS = 4;
|
||||
|
||||
export function computeFanSpeedStateDisplay(
|
||||
stateObj: FanEntity,
|
||||
hass: HomeAssistant,
|
||||
formatters: Pick<HomeAssistant, "formatEntityAttributeValue">,
|
||||
speed?: number
|
||||
) {
|
||||
const percentage = stateActive(stateObj)
|
||||
@@ -109,7 +109,7 @@ export function computeFanSpeedStateDisplay(
|
||||
const currentSpeed = speed ?? percentage;
|
||||
|
||||
return currentSpeed
|
||||
? hass.formatEntityAttributeValue(
|
||||
? formatters.formatEntityAttributeValue(
|
||||
stateObj,
|
||||
"percentage",
|
||||
Math.round(currentSpeed)
|
||||
|
||||
+62
-64
@@ -10,6 +10,7 @@ import { computeStateDisplayFromEntityAttributes } from "../common/entity/comput
|
||||
import { computeStateNameFromEntityAttributes } from "../common/entity/compute_state_name";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { isNumericSensorDeviceClass } from "./sensor";
|
||||
import type { FrontendLocaleData } from "./translation";
|
||||
import type { Statistics } from "./recorder";
|
||||
|
||||
@@ -164,60 +165,70 @@ export class HistoryStream {
|
||||
? (new Date().getTime() - 60 * 60 * this.hoursToShow * 1000) / 1000
|
||||
: undefined;
|
||||
const newHistory: HistoryStates = {};
|
||||
for (const entityId of Object.keys(this.combinedHistory)) {
|
||||
newHistory[entityId] = [];
|
||||
}
|
||||
for (const entityId of Object.keys(streamMessage.states)) {
|
||||
newHistory[entityId] = [];
|
||||
}
|
||||
for (const entityId of Object.keys(newHistory)) {
|
||||
if (
|
||||
entityId in this.combinedHistory &&
|
||||
entityId in streamMessage.states
|
||||
) {
|
||||
// Build the union of entity ids (existing first, then new ones) in a
|
||||
// single pass and process each entity inline. The per-entity slot is
|
||||
// always assigned below before being read, so there is no need to
|
||||
// pre-seed every key with an empty array first.
|
||||
const streamStates = streamMessage.states;
|
||||
const processEntity = (entityId: string) => {
|
||||
const inCombined = entityId in this.combinedHistory;
|
||||
const inStream = entityId in streamStates;
|
||||
if (inCombined && inStream) {
|
||||
const entityCombinedHistory = this.combinedHistory[entityId];
|
||||
const lastEntityCombinedHistory =
|
||||
entityCombinedHistory[entityCombinedHistory.length - 1];
|
||||
newHistory[entityId] = entityCombinedHistory.concat(
|
||||
streamMessage.states[entityId]
|
||||
streamStates[entityId]
|
||||
);
|
||||
if (
|
||||
streamMessage.states[entityId][0].lu < lastEntityCombinedHistory.lu
|
||||
) {
|
||||
if (streamStates[entityId][0].lu < lastEntityCombinedHistory.lu) {
|
||||
// If the history is out of order we have to sort it.
|
||||
newHistory[entityId] = newHistory[entityId].sort(
|
||||
(a, b) => a.lu - b.lu
|
||||
);
|
||||
}
|
||||
} else if (entityId in this.combinedHistory) {
|
||||
} else if (inCombined) {
|
||||
newHistory[entityId] = this.combinedHistory[entityId];
|
||||
} else {
|
||||
newHistory[entityId] = streamMessage.states[entityId];
|
||||
newHistory[entityId] = streamStates[entityId];
|
||||
return;
|
||||
}
|
||||
// Remove old history
|
||||
if (purgeBeforePythonTime && entityId in this.combinedHistory) {
|
||||
const expiredStates = newHistory[entityId].filter(
|
||||
(state) => state.lu < purgeBeforePythonTime
|
||||
);
|
||||
if (!expiredStates.length) {
|
||||
continue;
|
||||
// Remove old history (only entities present in combinedHistory reach
|
||||
// here without an early return).
|
||||
if (purgeBeforePythonTime) {
|
||||
// Single pass: split into kept (lu >= cutoff, preserving order) and
|
||||
// track the last expired state (lu < cutoff) without allocating a
|
||||
// second array.
|
||||
const states = newHistory[entityId];
|
||||
const kept: EntityHistoryState[] = [];
|
||||
let lastExpiredState: EntityHistoryState | undefined;
|
||||
for (const state of states) {
|
||||
if (state.lu < purgeBeforePythonTime) {
|
||||
lastExpiredState = state;
|
||||
} else {
|
||||
kept.push(state);
|
||||
}
|
||||
}
|
||||
newHistory[entityId] = newHistory[entityId].filter(
|
||||
(state) => state.lu >= purgeBeforePythonTime
|
||||
);
|
||||
if (
|
||||
newHistory[entityId].length &&
|
||||
newHistory[entityId][0].lu === purgeBeforePythonTime
|
||||
) {
|
||||
continue;
|
||||
if (!lastExpiredState) {
|
||||
return;
|
||||
}
|
||||
newHistory[entityId] = kept;
|
||||
if (kept.length && kept[0].lu === purgeBeforePythonTime) {
|
||||
return;
|
||||
}
|
||||
// Update the first entry to the start time state
|
||||
// as we need to preserve the start time state and
|
||||
// only expire the rest of the history as it ages.
|
||||
const lastExpiredState = expiredStates[expiredStates.length - 1];
|
||||
lastExpiredState.lu = purgeBeforePythonTime;
|
||||
delete lastExpiredState.lc;
|
||||
newHistory[entityId].unshift(lastExpiredState);
|
||||
kept.unshift(lastExpiredState);
|
||||
}
|
||||
};
|
||||
for (const entityId of Object.keys(this.combinedHistory)) {
|
||||
processEntity(entityId);
|
||||
}
|
||||
for (const entityId of Object.keys(streamStates)) {
|
||||
if (!(entityId in this.combinedHistory)) {
|
||||
processEntity(entityId);
|
||||
}
|
||||
}
|
||||
this.combinedHistory = newHistory;
|
||||
@@ -346,7 +357,6 @@ const processTimelineEntity = (
|
||||
state_localize: computeStateDisplayFromEntityAttributes(
|
||||
localize,
|
||||
locale,
|
||||
[], // numeric device classes not used for Timeline
|
||||
config,
|
||||
entities[entityId],
|
||||
entityId,
|
||||
@@ -381,16 +391,18 @@ const processLineChartEntities = (
|
||||
): LineChartUnit => {
|
||||
const data: LineChartEntity[] = [];
|
||||
|
||||
Object.keys(entities).forEach((entityId) => {
|
||||
const entityIds = Object.keys(entities);
|
||||
entityIds.forEach((entityId) => {
|
||||
const states = entities[entityId];
|
||||
const first: EntityHistoryState = states[0];
|
||||
const domain = computeDomain(entityId);
|
||||
const useLastUpdated = DOMAINS_USE_LAST_UPDATED.includes(domain);
|
||||
const processedStates: LineChartState[] = [];
|
||||
|
||||
for (const state of states) {
|
||||
let processedState: LineChartState;
|
||||
|
||||
if (DOMAINS_USE_LAST_UPDATED.includes(domain)) {
|
||||
if (useLastUpdated) {
|
||||
processedState = {
|
||||
state: state.s,
|
||||
last_changed: state.lu * 1000,
|
||||
@@ -412,13 +424,11 @@ const processLineChartEntities = (
|
||||
};
|
||||
}
|
||||
|
||||
const len = processedStates.length;
|
||||
if (
|
||||
processedStates.length > 1 &&
|
||||
equalState(
|
||||
processedState,
|
||||
processedStates[processedStates.length - 1]
|
||||
) &&
|
||||
equalState(processedState, processedStates[processedStates.length - 2])
|
||||
len > 1 &&
|
||||
equalState(processedState, processedStates[len - 1]) &&
|
||||
equalState(processedState, processedStates[len - 2])
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
@@ -444,11 +454,17 @@ const processLineChartEntities = (
|
||||
return {
|
||||
unit,
|
||||
device_class,
|
||||
identifier: Object.keys(entities).join(""),
|
||||
identifier: entityIds.join(""),
|
||||
data,
|
||||
};
|
||||
};
|
||||
|
||||
const SPECIAL_DOMAIN_CLASSES: Record<string, string | undefined> = {
|
||||
climate: "temperature",
|
||||
humidifier: "humidity",
|
||||
water_heater: "temperature",
|
||||
};
|
||||
|
||||
const NUMERICAL_DOMAINS = ["counter", "input_number", "number"];
|
||||
|
||||
const isNumericFromDomain = (domain: string) =>
|
||||
@@ -457,20 +473,12 @@ const isNumericFromDomain = (domain: string) =>
|
||||
const isNumericFromAttributes = (attributes: Record<string, any>) =>
|
||||
"unit_of_measurement" in attributes || "state_class" in attributes;
|
||||
|
||||
const isNumericSensorEntity = (
|
||||
stateObj: HassEntity,
|
||||
sensorNumericalDeviceClasses: string[]
|
||||
) =>
|
||||
stateObj.attributes.device_class != null &&
|
||||
sensorNumericalDeviceClasses.includes(stateObj.attributes.device_class);
|
||||
|
||||
const BLANK_UNIT = " ";
|
||||
|
||||
export const convertStatisticsToHistory = (
|
||||
hass: HomeAssistant,
|
||||
statistics: Statistics,
|
||||
statisticIds: string[],
|
||||
sensorNumericDeviceClasses: string[],
|
||||
splitDeviceClasses = false
|
||||
): HistoryResult => {
|
||||
// Maintain the statistic id ordering
|
||||
@@ -498,7 +506,6 @@ export const convertStatisticsToHistory = (
|
||||
statsHistoryStates,
|
||||
[],
|
||||
hass.localize,
|
||||
sensorNumericDeviceClasses,
|
||||
splitDeviceClasses,
|
||||
true
|
||||
);
|
||||
@@ -528,7 +535,6 @@ export const computeHistory = (
|
||||
stateHistory: HistoryStates,
|
||||
entityIds: string[],
|
||||
localize: LocalizeFunc,
|
||||
sensorNumericalDeviceClasses: string[],
|
||||
splitDeviceClasses = false,
|
||||
forceNumeric = false
|
||||
): HistoryResult => {
|
||||
@@ -575,7 +581,6 @@ export const computeHistory = (
|
||||
domain,
|
||||
currentState,
|
||||
numericStateFromHistory,
|
||||
sensorNumericalDeviceClasses,
|
||||
forceNumeric
|
||||
);
|
||||
|
||||
@@ -593,14 +598,8 @@ export const computeHistory = (
|
||||
}[domain];
|
||||
}
|
||||
|
||||
const specialDomainClasses = {
|
||||
climate: "temperature",
|
||||
humidifier: "humidity",
|
||||
water_heater: "temperature",
|
||||
};
|
||||
|
||||
const deviceClass: string | undefined =
|
||||
specialDomainClasses[domain] ||
|
||||
SPECIAL_DOMAIN_CLASSES[domain] ||
|
||||
(currentState?.attributes || numericStateFromHistory?.a)?.device_class;
|
||||
|
||||
const key = computeGroupKey(unit, deviceClass, splitDeviceClasses);
|
||||
@@ -656,7 +655,6 @@ export const isNumericEntity = (
|
||||
domain: string,
|
||||
currentState: HassEntity | undefined,
|
||||
numericStateFromHistory: EntityHistoryState | undefined,
|
||||
sensorNumericalDeviceClasses: string[],
|
||||
forceNumeric = false
|
||||
): boolean =>
|
||||
forceNumeric ||
|
||||
@@ -664,7 +662,7 @@ export const isNumericEntity = (
|
||||
(currentState != null && isNumericFromAttributes(currentState.attributes)) ||
|
||||
(currentState != null &&
|
||||
domain === "sensor" &&
|
||||
isNumericSensorEntity(currentState, sensorNumericalDeviceClasses)) ||
|
||||
isNumericSensorDeviceClass(currentState.attributes.device_class)) ||
|
||||
numericStateFromHistory != null;
|
||||
|
||||
export const mergeHistoryResults = (
|
||||
|
||||
@@ -20,7 +20,7 @@ export type HumidifierEntity = HassEntityBase & {
|
||||
};
|
||||
};
|
||||
|
||||
export const enum HumidifierEntityFeature {
|
||||
export enum HumidifierEntityFeature {
|
||||
MODES = 1,
|
||||
}
|
||||
|
||||
|
||||
+10
-8
@@ -39,6 +39,7 @@ import {
|
||||
mdiMicrophoneMessage,
|
||||
mdiMotionSensor,
|
||||
mdiPalette,
|
||||
mdiRadioTower,
|
||||
mdiRayVertex,
|
||||
mdiRemote,
|
||||
mdiRobot,
|
||||
@@ -52,7 +53,6 @@ import {
|
||||
mdiThermostat,
|
||||
mdiTimerOutline,
|
||||
mdiToggleSwitch,
|
||||
mdiVideoInputAntenna,
|
||||
mdiWater,
|
||||
mdiWaterPercent,
|
||||
mdiWeatherPartlyCloudy,
|
||||
@@ -129,7 +129,7 @@ export const FALLBACK_DOMAIN_ICONS = {
|
||||
plant: mdiFlower,
|
||||
power: mdiFlash,
|
||||
proximity: mdiAppleSafari,
|
||||
radio_frequency: mdiVideoInputAntenna,
|
||||
radio_frequency: mdiRadioTower,
|
||||
remote: mdiRemote,
|
||||
scene: mdiPalette,
|
||||
schedule: mdiCalendarClock,
|
||||
@@ -548,7 +548,9 @@ const getEntityIcon = async (
|
||||
};
|
||||
|
||||
export const attributeIcon = async (
|
||||
hass: HomeAssistant,
|
||||
hassConfig: HomeAssistant["config"],
|
||||
hassConnection: HomeAssistant["connection"],
|
||||
entities: HomeAssistant["entities"],
|
||||
state: HassEntity,
|
||||
attribute: string,
|
||||
attributeValue?: string
|
||||
@@ -556,7 +558,7 @@ export const attributeIcon = async (
|
||||
let icon: string | undefined;
|
||||
const domain = computeStateDomain(state);
|
||||
const deviceClass = state.attributes.device_class;
|
||||
const entity = hass.entities?.[state.entity_id] as
|
||||
const entity = entities[state.entity_id] as
|
||||
| EntityRegistryDisplayEntry
|
||||
| undefined;
|
||||
const platform = entity?.platform;
|
||||
@@ -567,8 +569,8 @@ export const attributeIcon = async (
|
||||
|
||||
if (translation_key && platform) {
|
||||
const platformIcons = await getPlatformIcons(
|
||||
hass.config,
|
||||
hass.connection,
|
||||
hassConfig,
|
||||
hassConnection,
|
||||
platform
|
||||
);
|
||||
if (platformIcons) {
|
||||
@@ -580,8 +582,8 @@ export const attributeIcon = async (
|
||||
}
|
||||
if (!icon) {
|
||||
const entityComponentIcons = await getComponentIcons(
|
||||
hass.connection,
|
||||
hass.config,
|
||||
hassConnection,
|
||||
hassConfig,
|
||||
domain
|
||||
);
|
||||
if (entityComponentIcons) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user