Compare commits

..
Author SHA1 Message Date
Bram KragtenandClaude Opus 5.5 fbb5cca794 Split the grouped Settings sections into individual entries
Devices & services, Automations & scenes and Areas, labels & zones are
replaced by one Settings entry per page, grouped in three cards. These pages
now pass only their own page as tab.

Settings cards without any visible entries are hidden, the Apps entry now
uses the mdiApps icon, every Settings entry has a distinct colour, and
"blueprint" is added to the demo config.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:37:38 +02:00
Bram KragtenandClaude Opus 5.5 6ddbb68444 Show page tabs in a row under the toolbar on narrow screens
On narrow screens tabs now render as a scrollable tab row below the toolbar
instead of a bottom bar, and the bottom bar code is removed. Wide screens are
unchanged.

Narrow pages without a FAB now pad their content for the bottom safe area.
Pages that already clear it themselves, like data tables, opt out by setting
--tabs-subpage-content-padding-bottom.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:37:38 +02:00
Bram KragtenandClaude Opus 5.5 906cd7bbec Use the active tab name as the page title on narrow screens
Tabbed subpages without an explicit header show the active tab name in the
toolbar on narrow screens. Which tabs are shown is now computed in willUpdate
instead of during render.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:37:38 +02:00
Bram KragtenandClaude Opus 5.5 30467c3a51 Add a bottom bar height variable for bottom offsets
Introduce --ha-bottom-bar-height and express the bottom offsets of subpage
content, FABs, data table empty rows and toasts with it. Remove the unused
#fab[is-wide] and #fab.tabs selectors. No visible change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:37:38 +02:00
Bram KragtenandClaude Opus 5.5 fc760b9325 Move PageNavigation type to its own data module
PageNavigation is used far beyond the tabs subpage layout, so it now lives in
src/data/page_navigation.ts. No behavior change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 12:37:38 +02:00
589 changed files with 6863 additions and 21308 deletions

No files matched your search

+1 -1
View File
@@ -58,7 +58,7 @@ Readiness and invalidation:
Repository-owned contracts:
- Consult the public [frontend contributor documentation](https://contributors.home-assistant.io/frontend/) for documented architecture, data flow, design, and development workflows.
- Consult the public [frontend developer documentation](https://developers.home-assistant.io/docs/frontend/) for documented architecture, data flow, design, and development workflows.
- For new leaf components, load `ha-frontend-contexts` and verify they consume narrow contexts instead of introducing a broad `hass` property; containers and external APIs may still require `hass`.
- Verify backend assumptions against the owning Core, Supervisor, or WebSocket implementation, and component assumptions against the exported component contract.
- Prefer canonical repository helpers and test setup over duplicate local implementations.
+1 -1
View File
@@ -105,4 +105,4 @@ To help with the load of incoming pull requests:
Below, some useful links you could explore:
-->
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr
+1 -1
View File
@@ -34,7 +34,7 @@ runs:
hashFiles('.nvmrc', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'patches/**') }}
- name: Setup Node
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: ".nvmrc"
@@ -192,8 +192,8 @@ export default async function checkPullRequestStandards({
`\n\n` +
`Please complete the ` +
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
`and see the [contributor docs](https://contributors.home-assistant.io/start/pull-requests/#fill-out-the-template) ` +
`for more on creating a great pull request.`;
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
`for more on creating a great pull request (see point 6).`;
if (existing) {
await withRetry("comment update", () =>
+2 -2
View File
@@ -134,7 +134,7 @@ jobs:
is-test: true
rspack-cache: readonly
- name: Upload bundle stats
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-bundle-stats
path: build/stats/*.json
@@ -142,7 +142,7 @@ jobs:
- name: Check entrypoint bundle size budget
run: pnpm run check-bundlesize
- name: Upload frontend build
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: frontend-build
path: hass_frontend/
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
category: "/language:javascript-typescript"
+17 -17
View File
@@ -38,7 +38,7 @@ jobs:
name: Prepare container dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.63.0-noble
options: --user 1001
defaults:
run:
@@ -78,7 +78,7 @@ jobs:
is-test: true
- name: Upload demo build
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: demo-dist
path: demo/dist/
@@ -109,7 +109,7 @@ jobs:
is-test: true
- name: Upload e2e test app build
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
@@ -140,7 +140,7 @@ jobs:
is-test: true
- name: Upload gallery build
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gallery-dist
path: gallery/dist/
@@ -155,7 +155,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.63.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -182,7 +182,7 @@ jobs:
node-modules-cache-key: node-modules-container-v1
- name: Download demo build
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: demo-dist
path: demo/dist/
@@ -192,7 +192,7 @@ jobs:
timeout-minutes: 15
- name: Upload demo blob report
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-demo-${{ matrix.shardIndex }}
@@ -207,7 +207,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.63.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -236,7 +236,7 @@ jobs:
node-modules-cache-key: node-modules-container-v1
- name: Download e2e test app build
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
@@ -246,7 +246,7 @@ jobs:
timeout-minutes: 15
- name: Upload app blob report
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-app-${{ matrix.shardIndex }}
@@ -261,7 +261,7 @@ jobs:
- prepare-container-dependencies
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.64.1-noble
image: mcr.microsoft.com/playwright:v1.63.0-noble
options: --user 1001 --ipc=host
defaults:
run:
@@ -290,7 +290,7 @@ jobs:
node-modules-cache-key: node-modules-container-v1
- name: Download gallery build
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: gallery-dist
path: gallery/dist/
@@ -300,7 +300,7 @@ jobs:
timeout-minutes: 15
- name: Upload gallery blob report
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-gallery-${{ matrix.shardIndex }}
@@ -331,21 +331,21 @@ jobs:
node-modules-cache: true
- name: Download demo blob reports
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
pattern: blob-report-demo-*
path: test/e2e/reports/demo/
- name: Download app blob reports
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
pattern: blob-report-app-*
path: test/e2e/reports/app/
- name: Download gallery blob reports
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
continue-on-error: true
with:
pattern: blob-report-gallery-*
@@ -359,7 +359,7 @@ jobs:
- name: Upload merged HTML report
id: upload-report
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: test/e2e/reports/combined/
+2 -2
View File
@@ -104,7 +104,7 @@ jobs:
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
- name: Upload build artifacts
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: wheels
path: dist/home_assistant_frontend*.whl
@@ -137,7 +137,7 @@ jobs:
run: tar -czvf translations.tar.gz translations
- name: Upload translations
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: translations
path: translations.tar.gz
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60 # v3.2.4
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
with:
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
token: ${{ github.token }}
@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@711f3a8fa1b928e6afde280011110c9b0a224a60 # v3.2.4
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
with:
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
token: ${{ github.token }}
+1 -1
View File
@@ -19,6 +19,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@72967cdc98ddd3160f1fc3dff619de365e137dd0 # v7.9.0
- uses: release-drafter/release-drafter@34d80673e067bdc0c24568d3af899c216adcfaa9 # v7.7.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@cd6f3635a740c84d8a14f6121a90068b8ece6bf6 # master
uses: home-assistant/actions/helpers/verify-version@06749dd8c0b54f350bc69c8752456cee498808a3 # master
- name: Setup Node and install
uses: ./.github/actions/setup
+2 -3
View File
@@ -15,12 +15,11 @@ jobs:
if: github.repository == 'home-assistant/frontend'
runs-on: ubuntu-latest
steps:
- name: Stale policy
- name: 90 days stale policy
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-issue-stale: 90
days-before-pr-stale: 60
days-before-stale: 90
days-before-close: 7
operations-per-run: 25
remove-stale-when-updated: true
+1 -1
View File
@@ -1 +1 @@
24.22.0
24.21.0
+1 -1
View File
@@ -41,5 +41,5 @@ Contributions that do not follow this policy will be closed. Repeated violations
---
The canonical version of this policy is published at
<https://contributors.home-assistant.io/start/ai-policy/>. In case of differences,
<https://developers.home-assistant.io/docs/ai_policy>. In case of differences,
the published version applies.
+2 -2
View File
@@ -6,12 +6,12 @@ This is the repository for the official [Home Assistant](https://home-assistant.
- [View demo of Home Assistant](https://demo.home-assistant.io/)
- [More information about Home Assistant](https://home-assistant.io)
- [Frontend development instructions](https://contributors.home-assistant.io/frontend/development/)
- [Frontend development instructions](https://developers.home-assistant.io/docs/frontend/development/)
## Development
- Initial setup: `script/setup`
- Development: [Instructions](https://contributors.home-assistant.io/frontend/development/)
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
- Production build: `pnpm build`
- Gallery: `cd gallery && script/develop_gallery`
+1 -4
View File
@@ -115,10 +115,7 @@ const createRspackConfig = ({
dontHash = new Set();
}
const ignorePackages = bundle.ignorePackages({ latestBuild });
// pnpm links node_modules/lit-html into its store; rspack compares real paths
const litHtmlRoot = fs.realpathSync(
path.resolve(__dirname, "../node_modules/lit-html")
);
const litHtmlRoot = path.resolve(__dirname, "../node_modules/lit-html");
const litHtmlDevelopmentRoot = path.join(litHtmlRoot, "development");
const litDisableDevModeLoader = path.join(
__dirname,
+1 -1
View File
@@ -4,7 +4,7 @@ import { castContext } from "../cast_context";
export const castDemoLovelace: () => LovelaceConfig = () => {
const touchSupported =
castContext.getDeviceCapabilities()?.touch_input_supported;
castContext.getDeviceCapabilities().touch_input_supported;
return {
views: [
{
+2 -2
View File
@@ -19,7 +19,7 @@ const playerStylesAdded = false;
let controls: HTMLElement | null;
const setTouchControlsVisibility = (visible: boolean) => {
if (!castContext.getDeviceCapabilities()?.touch_input_supported) {
if (!castContext.getDeviceCapabilities().touch_input_supported) {
return;
}
controls =
@@ -51,7 +51,7 @@ const playDummyMedia = (viewTitle?: string) => {
clearTimeout(timeOut);
timeOut = undefined;
}
if (castContext.getDeviceCapabilities()?.touch_input_supported) {
if (castContext.getDeviceCapabilities().touch_input_supported) {
timeOut = window.setTimeout(() => playDummyMedia(viewTitle), 540000); // repeat every 9 minutes to keep it active (gets deactivated after 10 minutes)
}
};
+2 -2
View File
@@ -27,7 +27,6 @@ import type {
LovelaceConfig,
LovelaceDashboardStrategyConfig,
} from "../../../../src/data/lovelace/config/types";
import { normalizeLovelaceConfig } from "../../../../src/data/lovelace/config/normalize";
import { isStrategyDashboard } from "../../../../src/data/lovelace/config/types";
import { fetchResources } from "../../../../src/data/lovelace/resource";
import { loadLovelaceResources } from "../../../../src/panels/lovelace/common/load-resources";
@@ -35,6 +34,7 @@ import { HassElement } from "../../../../src/state/hass-element";
import { castContext } from "../cast_context";
import "./hc-launch-screen";
import { getPanelTitleFromUrlPath } from "../../../../src/data/panel";
import { checkLovelaceConfig } from "../../../../src/panels/lovelace/common/check-lovelace-config";
const DEFAULT_CONFIG: LovelaceDashboardStrategyConfig = {
strategy: {
@@ -359,7 +359,7 @@ export class HcMain extends HassElement {
this._urlPath || "lovelace"
);
castContext.setApplicationState(title || "");
this._lovelaceConfig = normalizeLovelaceConfig(
this._lovelaceConfig = checkLovelaceConfig(
lovelaceConfig
) as LovelaceConfig;
}
+2 -68
View File
@@ -142,45 +142,7 @@ export class HaDemo extends HomeAssistantAppEl {
(localize?: LocalizeFunc) => localize ?? this.hass!.localize
);
mockLovelace(hass, localizePromise, [
{
id: "music",
url_path: "music",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:music",
title: "Music",
mode: "storage",
},
{
id: "cameras",
url_path: "cameras",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:cctv",
title: "Cameras",
mode: "storage",
},
{
id: "garden",
url_path: "garden",
require_admin: false,
show_in_sidebar: true,
icon: "mdi:flower",
title: "Garden",
mode: "storage",
},
{
id: "lovelace-advanced",
url_path: "lovelace-advanced",
require_admin: false,
show_in_sidebar: false,
icon: "mdi:cog",
title: "Advanced",
mode: "yaml",
filename: "lovelace-advanced.yaml",
},
]);
mockLovelace(hass, localizePromise);
mockAuth(hass);
mockTranslations(hass, localizePromise);
mockHistory(hass);
@@ -210,35 +172,7 @@ export class HaDemo extends HomeAssistantAppEl {
mockAreaRegistry(hass);
mockDeviceRegistry(hass, demoDevices);
mockFloorRegistry(hass);
mockLabelRegistry(hass, [
{
label_id: "kitchen",
name: "Kitchen",
icon: null,
color: "red",
description: "Everything in the kitchen",
created_at: 1735689600,
modified_at: 1735689600,
},
{
label_id: "garden",
name: "Garden",
icon: "mdi:flower",
color: "green",
description: null,
created_at: 1735689600,
modified_at: 1735689600,
},
{
label_id: "battery-powered",
name: "Battery powered",
icon: null,
color: "orange",
description: "Runs on batteries",
created_at: 1735689600,
modified_at: 1735689600,
},
]);
mockLabelRegistry(hass);
mockUsagePrediction(hass);
mockEntityRegistryDisplay(hass);
mockEntityRegistry(hass, [
-3
View File
@@ -39,9 +39,6 @@
html {
background-color: var(--primary-background-color, #fafafa);
color: var(--primary-text-color, #212121);
/* Prevent iOS Safari from inflating text when rotating to landscape */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
@media (prefers-color-scheme: dark) {
html {
-1
View File
@@ -39,7 +39,6 @@ const backupInfo: BackupInfo = {
},
],
agent_errors: {},
agent_error_translations: {},
last_attempted_automatic_backup: recent,
last_completed_automatic_backup: recent,
last_action_event: { manager_state: "idle" },
-8
View File
@@ -42,12 +42,4 @@ export const mockBlueprint = (hass: MockHomeAssistant) => {
hass.mockWS("blueprint/list", (msg: { domain: BlueprintDomain }) =>
msg.domain === "script" ? scriptBlueprints : automationBlueprints
);
hass.mockWS(
"blueprint/delete",
(msg: { domain: BlueprintDomain; path: string }) => {
const blueprints =
msg.domain === "script" ? scriptBlueprints : automationBlueprints;
delete blueprints[msg.path];
}
);
};
-2
View File
@@ -1,4 +1,3 @@
import { lorawanFixtures } from "./lorawan/fixtures";
import { bluetoothFixtures } from "./bluetooth/fixtures";
import { infraredFixtures } from "./infrared/fixtures";
import { matterFixtures } from "./matter/fixtures";
@@ -16,7 +15,6 @@ import { zwaveJsFixtures } from "./zwave_js/fixtures";
// data to mock. Each owns its own fixtures, so they can be added and removed
// one at a time.
const INTEGRATIONS: ConnectivityFixtures[] = [
lorawanFixtures,
bluetoothFixtures,
serialFixtures,
modbusFixtures,
-2
View File
@@ -1,4 +1,3 @@
import { mockLoRaWAN } from "./lorawan/mock";
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { mockBluetooth } from "./bluetooth/mock";
import { mockMatter } from "./matter/mock";
@@ -12,7 +11,6 @@ import { mockZwaveJs } from "./zwave_js/mock";
// The WebSocket mocks, code-split into the config panel chunk.
const MOCKS = [
mockLoRaWAN,
mockBluetooth,
mockSerial,
mockModbus,
@@ -1,135 +0,0 @@
import { manifest } from "../../manifest";
import {
configEntry,
device,
registryEntry,
withRegistryLinks,
} from "../helpers";
import type { ConnectivityFixtures } from "../types";
export const CHIRPSTACK_ENTRY = "mock-lorawan-chirpstack";
export const TTN_ENTRY = "mock-lorawan-ttn";
export const SENSECAP_ENTRY = "mock-lorawan-sensecap";
const REGISTRY_ENTRIES = [
registryEntry(
"sensor.lorawan_greenhouse_temperature",
"lorawan-greenhouse",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Temperature"
),
registryEntry(
"sensor.lorawan_greenhouse_humidity",
"lorawan-greenhouse",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Humidity"
),
registryEntry(
"sensor.lorawan_study_carbon_dioxide",
"lorawan-study",
SENSECAP_ENTRY,
"sensecap_lorawan",
"Carbon dioxide"
),
];
export const lorawanFixtures: ConnectivityFixtures = {
components: [
"lorawan",
"sensecap_lorawan",
"chirpstack",
"thethingsnetwork_lorawan",
],
commands: ["lorawan/"],
manifests: [
manifest("lorawan", "LoRaWAN", { integration_type: "helper" }),
manifest("sensecap_lorawan", "SenseCAP", {
integration_type: "device",
dependencies: ["lorawan"],
}),
manifest("chirpstack", "ChirpStack", { integration_type: "hub" }),
manifest("thethingsnetwork_lorawan", "The Things Network", {
integration_type: "hub",
}),
],
configEntries: [
{
type: "hub",
entry: configEntry(CHIRPSTACK_ENTRY, "chirpstack", "Home ChirpStack"),
},
{
type: "hub",
entry: configEntry(
TTN_ENTRY,
"thethingsnetwork_lorawan",
"The Things Network"
),
},
{
type: "device",
entry: configEntry(SENSECAP_ENTRY, "sensecap_lorawan", "SenseCAP"),
},
],
devices: [
device(
"lorawan-greenhouse",
"Greenhouse",
"Seeed Studio",
"S2101",
SENSECAP_ENTRY,
{
config_entries: [SENSECAP_ENTRY, CHIRPSTACK_ENTRY],
identifiers: [
["sensecap_lorawan", `${CHIRPSTACK_ENTRY}:0000000000000001`],
],
}
),
device(
"lorawan-study",
"Study air quality",
"Seeed Studio",
"S2103",
SENSECAP_ENTRY,
{
config_entries: [SENSECAP_ENTRY, TTN_ENTRY],
identifiers: [["sensecap_lorawan", `${TTN_ENTRY}:0000000000000002`]],
}
),
],
entityRegistryEntries: REGISTRY_ENTRIES,
entities: () =>
withRegistryLinks(REGISTRY_ENTRIES, {
"sensor.lorawan_greenhouse_temperature": {
entity_id: "sensor.lorawan_greenhouse_temperature",
state: "24.3",
attributes: {
friendly_name: "Greenhouse temperature",
device_class: "temperature",
state_class: "measurement",
unit_of_measurement: "°C",
},
},
"sensor.lorawan_greenhouse_humidity": {
entity_id: "sensor.lorawan_greenhouse_humidity",
state: "58",
attributes: {
friendly_name: "Greenhouse humidity",
device_class: "humidity",
state_class: "measurement",
unit_of_measurement: "%",
},
},
"sensor.lorawan_study_carbon_dioxide": {
entity_id: "sensor.lorawan_study_carbon_dioxide",
state: "652",
attributes: {
friendly_name: "Study carbon dioxide",
device_class: "carbon_dioxide",
state_class: "measurement",
unit_of_measurement: "ppm",
},
},
}),
};
-124
View File
@@ -1,124 +0,0 @@
import type { LoRaWANDevice } from "../../../../../src/data/lorawan";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import { minutesAgo } from "../helpers";
import { emitInitial } from "../subscription";
import { CHIRPSTACK_ENTRY, TTN_ENTRY } from "./fixtures";
const devices = (): LoRaWANDevice[] => {
const base: LoRaWANDevice = {
dev_eui: "0000000000000001",
name: "Greenhouse",
manufacturer: "Seeed Studio",
model: "S2101",
application_id: "home-sensors",
device_id: "lorawan-greenhouse",
connection: {
entry_id: CHIRPSTACK_ENTRY,
domain: "chirpstack",
title: "Home ChirpStack",
configuration_url: "https://www.chirpstack.io/",
},
support: "supported",
last_uplink: minutesAgo(2),
last_join: minutesAgo(2400),
last_status: minutesAgo(64),
receptions: [{ gateway_id: "home-indoor", rssi: -67, snr: 9.5 }],
battery_level: 87,
external_power_source: false,
downlink_margin: 12,
};
return [
base,
{
...base,
dev_eui: "0000000000000002",
name: "Study air quality",
model: "S2103",
device_id: "lorawan-study",
application_id: "demo-home",
connection: {
entry_id: TTN_ENTRY,
domain: "thethingsnetwork_lorawan",
title: "The Things Network",
configuration_url: "https://eu1.cloud.thethings.network/console",
},
receptions: [
{ gateway_id: "brooklyn-rooftop", rssi: -103, snr: -2.5 },
{ gateway_id: "brooklyn-community", rssi: -110, snr: -5.2 },
],
battery_level: 64,
last_uplink: minutesAgo(4),
last_status: minutesAgo(140),
},
{
...base,
dev_eui: "24E124128E123456",
name: "Irrigation controller",
manufacturer: "Milesight",
model: "UC511",
device_id: null,
support: "integration_not_configured",
battery_level: 93,
last_uplink: minutesAgo(7),
receptions: [{ gateway_id: "home-indoor", rssi: -83, snr: 6.8 }],
},
{
...base,
dev_eui: "A84041FFFF123456",
name: "Garage power meter",
manufacturer: "",
model: "",
device_id: null,
support: "missing_identity",
battery_level: null,
external_power_source: true,
last_uplink: minutesAgo(1),
receptions: [{ gateway_id: "home-indoor", rssi: -75, snr: 8.1 }],
},
{
...base,
dev_eui: "2CF7F1C071500199",
name: "New soil sensor",
model: "S2105",
device_id: null,
support: "unsupported_model",
last_uplink: null,
last_join: null,
last_status: null,
receptions: [],
battery_level: null,
external_power_source: null,
downlink_margin: null,
},
{
...base,
dev_eui: "2CF7F1C071500166",
name: "Garden shed",
device_id: null,
support: "integration_not_configured",
battery_level: 18,
last_uplink: minutesAgo(180),
last_status: minutesAgo(600),
receptions: [{ gateway_id: "home-indoor", rssi: -112, snr: -7.5 }],
},
];
};
export const mockLoRaWAN = (hass: MockHomeAssistant) => {
const deviceList = devices();
const snapshot = {
devices: deviceList,
connections: [
...new Map(
deviceList.map((device) => [
device.connection.entry_id,
device.connection,
])
).values(),
],
};
hass.mockWS("lorawan/devices/list", () => snapshot);
hass.mockWS("lorawan/devices/subscribe", (_msg, _hass, onChange) =>
emitInitial(() => onChange?.(snapshot))
);
};
-33
View File
@@ -190,39 +190,6 @@ export const removeEntityRegistryEntry = (
hass.mockEvent("entity_registry_updated");
};
export const removeLabelFromEntityRegistry = (
hass: MockHomeAssistant,
labelId: string
) => {
const withoutLabel = (labels: string[]) =>
labels.filter((id) => id !== labelId);
for (const [key, entry] of retiredEntries) {
if (entry.labels.includes(labelId)) {
retiredEntries.set(key, { ...entry, labels: withoutLabel(entry.labels) });
}
}
if (!entries.some((entry) => entry.labels.includes(labelId))) {
return;
}
entries = entries.map((entry) =>
entry.labels.includes(labelId)
? { ...entry, labels: withoutLabel(entry.labels) }
: entry
);
const entities = { ...hass.entities };
for (const entry of entries) {
const entity = entities[entry.entity_id];
if (entity?.labels.includes(labelId)) {
entities[entry.entity_id] = {
...entity,
labels: withoutLabel(entity.labels),
};
}
}
hass.updateHass({ entities });
hass.mockEvent("entity_registry_updated");
};
// Adds an entry like core does when an integration creates an entity
export const addEntityRegistryEntry = (
hass: MockHomeAssistant,
+2 -19
View File
@@ -1,6 +1,5 @@
import type { LabelRegistryEntry } from "../../../src/data/label/label_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import { removeLabelFromEntityRegistry } from "./entity_registry";
let labels: LabelRegistryEntry[] = [];
@@ -10,22 +9,6 @@ export const mockLabelRegistry = (
hass: MockHomeAssistant,
data: LabelRegistryEntry[] = []
) => {
labels = [...data];
hass.mockWS("config/label_registry/list", () =>
labels.map((label) => ({ ...label }))
);
hass.mockWS(
"config/label_registry/delete",
(msg: { label_id: string }, currentHass: MockHomeAssistant) => {
const index = labels.findIndex(
(label) => label.label_id === msg.label_id
);
if (index === -1) {
throw new Error(`Label ${msg.label_id} not found`);
}
labels.splice(index, 1);
removeLabelFromEntityRegistry(currentHass, msg.label_id);
hass.mockEvent("label_registry_updated");
}
);
labels = data;
hass.mockWS("config/label_registry/list", () => data);
};
+2 -16
View File
@@ -1,5 +1,4 @@
import type { LocalizeFunc } from "../../../src/common/translations/localize";
import type { LovelaceDashboard } from "../../../src/data/lovelace/dashboard";
import type { LovelaceInfo } from "../../../src/data/lovelace/resource";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
import {
@@ -12,14 +11,10 @@ import "../custom-cards/ha-demo-card";
import "../custom-cards/ha-demo-next-card";
import { mockMap } from "./map";
let dashboards: LovelaceDashboard[] = [];
export const mockLovelace = (
hass: MockHomeAssistant,
localizePromise: Promise<LocalizeFunc>,
data: LovelaceDashboard[] = []
localizePromise: Promise<LocalizeFunc>
) => {
dashboards = data;
hass.mockWS("lovelace/config", ({ url_path }) => {
if (url_path === "map") {
mockMap(hass);
@@ -39,16 +34,7 @@ export const mockLovelace = (
);
hass.mockWS("lovelace/config/save", () => Promise.resolve());
hass.mockWS("lovelace/resources", () => Promise.resolve([]));
hass.mockWS("lovelace/dashboards/list", () =>
dashboards.map((dashboard) => ({ ...dashboard }))
);
hass.mockWS("lovelace/dashboards/delete", (msg: { dashboard_id: string }) => {
const index = dashboards.findIndex((d) => d.id === msg.dashboard_id);
if (index === -1) {
throw new Error(`Dashboard ${msg.dashboard_id} not found`);
}
dashboards.splice(index, 1);
});
hass.mockWS("lovelace/dashboards/list", () => Promise.resolve([]));
};
customElements.whenDefined("hui-root").then(() => {
+1 -8
View File
@@ -221,14 +221,7 @@ const personEntity = (person: DemoPerson, now: number): EntityInput => {
const personHistory = (person: DemoPerson, now: number): EntityHistoryState[] =>
person.timeline.map(([hoursAgo, state]) => {
const time = (now - hoursAgo * HOUR) / 1000;
const zoneId =
state === "home" ? "home" : zones.find((zone) => zone.name === state)?.id;
return {
s: state,
a: { in_zones: zoneId ? [`zone.${zoneId}`] : [] },
lu: time,
lc: time,
};
return { s: state, a: {}, lu: time, lc: time };
});
export const mockMap = (hass: MockHomeAssistant) => {
+22 -69
View File
@@ -1,10 +1,8 @@
import { TZDate } from "@date-fns/tz";
import {
addDays,
addHours,
addMinutes,
addMonths,
constructFrom,
differenceInHours,
endOfDay,
} from "date-fns";
@@ -15,18 +13,6 @@ import type {
} from "../../../src/data/recorder";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const getFirstDate = (
start: Date,
period: "5minute" | "hour" | "day" | "month"
): Date => {
if (period === "day" || period === "month") {
return constructFrom(start, start);
}
// Core returns UTC-aligned buckets whose start is at or after the request.
const interval = (period === "hour" ? 60 : 5) * 60 * 1000;
return constructFrom(start, Math.ceil(start.getTime() / interval) * interval);
};
const getNextDate = (
currentDate: Date,
period: "5minute" | "hour" | "day" | "month"
@@ -47,7 +33,8 @@ const generateMeanStatistics = (
maxDiff: number
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = getFirstDate(start, period);
let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0);
const now = new Date();
while (end > currentDate && currentDate < now) {
const delta = Math.random() * maxDiff;
@@ -73,17 +60,16 @@ const generateSumStatistics = (
end: Date,
period: "5minute" | "hour" | "day" | "month" = "hour",
initValue: number,
maxDiff: number | ((start: Date, end: Date) => number)
maxDiff: number
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = getFirstDate(start, period);
let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0);
let sum = initValue;
const now = new Date();
while (end > currentDate && currentDate < now) {
const nextDate = getNextDate(currentDate, period);
const bound =
typeof maxDiff === "function" ? maxDiff(currentDate, nextDate) : maxDiff;
const add = Math.random() * bound;
const add = Math.random() * maxDiff;
sum += add;
statistics.push({
start: currentDate.getTime(),
@@ -110,7 +96,8 @@ const generateCurvedStatistics = (
metered: boolean
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = getFirstDate(start, period);
let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0);
let sum = initValue;
const hours = differenceInHours(end, start) - 1;
let i = 0;
@@ -149,15 +136,6 @@ const statisticsFunctions: Record<
period: "5minute" | "hour" | "day" | "month"
) => StatisticValue[]
> = {
"sensor.energy_water": (_id, start, end, period = "hour") =>
generateSumStatistics(
start,
end,
period,
4000,
(bucketStart, bucketEnd) =>
(20 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
),
"sensor.energy_consumption_tarif_1": (
_id: string,
start: Date,
@@ -173,9 +151,9 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const morningEnd = addHours(start, 10);
const morningEnd = new Date(start.getTime() + 10 * 60 * 60 * 1000);
const morningLow = generateSumStatistics(start, morningEnd, period, 0, 0.7);
const eveningStart = addHours(start, 20);
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
const morningFinalVal = morningLow.length
? morningLow[morningLow.length - 1].sum!
: 0;
@@ -210,8 +188,8 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const morningEnd = addHours(start, 9);
const eveningStart = addHours(start, 20);
const morningEnd = new Date(start.getTime() + 9 * 60 * 60 * 1000);
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
const highTarif = generateSumStatistics(
morningEnd,
eveningStart,
@@ -250,12 +228,9 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const productionStart = addHours(start, 9);
const productionEnd = addHours(start, 21);
const dayEnd = constructFrom(
end,
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
);
const productionStart = new Date(start.getTime() + 9 * 60 * 60 * 1000);
const productionEnd = new Date(start.getTime() + 21 * 60 * 60 * 1000);
const dayEnd = new Date(endOfDay(productionEnd));
const production = generateCurvedStatistics(
productionStart,
productionEnd,
@@ -294,12 +269,9 @@ const statisticsFunctions: Record<
period === "day" ? 17 : 504
);
}
const productionStart = addHours(start, 7);
const productionEnd = addHours(start, 23);
const dayEnd = constructFrom(
end,
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
);
const productionStart = new Date(start.getTime() + 7 * 60 * 60 * 1000);
const productionEnd = new Date(start.getTime() + 23 * 60 * 60 * 1000);
const dayEnd = new Date(endOfDay(productionEnd));
const production = generateCurvedStatistics(
productionStart,
productionEnd,
@@ -329,11 +301,6 @@ const statisticsFunctions: Record<
return [...morning, ...production, ...evening, ...rest];
},
};
// Prices are per source unit; water uses litres (4 currency units per m³).
const derivedCosts: Record<string, { source: string; price: number }> = {
"sensor.energy_water_cost": { source: "sensor.energy_water", price: 0.004 },
};
export const mockRecorder = (mockHass: MockHomeAssistant) => {
mockHass.mockWS(
"recorder/get_statistics_metadata",
@@ -346,28 +313,14 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
mockHass.mockWS(
"recorder/statistics_during_period",
({ statistic_ids, start_time, end_time, period }, hass): Statistics => {
// Calendar buckets follow the server timezone, independent of the browser.
const start = new TZDate(start_time, hass.config.time_zone);
const end = new TZDate(end_time ?? Date.now(), hass.config.time_zone);
const start = new Date(start_time);
const end = end_time ? new Date(end_time) : new Date();
const statistics: Record<string, StatisticValue[]> = {};
const cache: Record<string, StatisticValue[]> = {};
const getStats = (id: string) => {
cache[id] ??= statisticsFunctions[id](id, start, end, period);
return cache[id];
};
statistic_ids.forEach((id: string) => {
if (id in derivedCosts) {
const { source, price } = derivedCosts[id];
let cost = 0;
statistics[id] = getStats(source).map((statistic) => {
const change = statistic.change! * price;
cost += change;
return { ...statistic, change, state: cost, sum: cost };
});
} else if (id in statisticsFunctions) {
statistics[id] = getStats(id);
if (id in statisticsFunctions) {
statistics[id] = statisticsFunctions[id](id, start, end, period);
} else {
const entityState = hass.states[id];
const state = entityState ? Number(entityState.state) : 1;
+4 -1
View File
@@ -6,10 +6,12 @@ import "../../../src/components/ha-settings-row";
import "../../../src/components/ha-switch";
import type { HaSwitch } from "../../../src/components/ha-switch";
import "../../../src/components/ha-theme-settings";
import type { ThemeSettings } from "../../../src/types";
import type { HomeAssistant, ThemeSettings } from "../../../src/types";
@customElement("gallery-settings")
class GallerySettings extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public themeSettings!: ThemeSettings;
@property({ type: Boolean }) public narrow = false;
@@ -24,6 +26,7 @@ class GallerySettings extends LitElement {
Configure how the gallery renders component previews and examples.
</div>
<ha-theme-settings
.hass=${this.hass}
.selectedTheme=${this.themeSettings}
.narrow=${this.narrow}
.heading=${"Theme"}
-10
View File
@@ -151,16 +151,6 @@ export const createMediaPlayerEntities = () => [
supported_features: 289,
},
},
{
entity_id: "media_player.theater_off_assumed",
state: "off",
attributes: {
friendly_name: "TV Off (assumed state, cannot be switched on)",
assumed_state: true,
// Off + Next + Pause
supported_features: 289,
},
},
{
entity_id: "media_player.theater_on_static",
state: "on",
+1
View File
@@ -242,6 +242,7 @@ class HaGallery extends LitElement {
${
isSettingsPage
? html`<gallery-settings
.hass=${this._galleryHass}
.themeSettings=${this._themeSettings}
.narrow=${this._narrow}
.rtl=${this._rtl}
-5
View File
@@ -12,11 +12,6 @@
<script type="module" src="<%= entry %>"></script>
<% } %>
<style>
html {
/* Prevent iOS Safari from inflating text when rotating to landscape */
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
font-family: Roboto, Noto, sans-serif;
margin: 0;
@@ -9,6 +9,7 @@ import "../../../../src/components/ha-form/ha-form";
import "../../../../src/components/ha-icon-button";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
const SCHEMA: HaFormSchema[] = [
{ type: "string", name: "Name", default: "", autofocus: true },
@@ -30,8 +31,11 @@ type DialogType =
export class DemoHaAdaptiveDialog extends LitElement {
@state() private _openDialog: DialogType = false;
@state() private _hass?: HomeAssistant;
protected firstUpdated() {
provideHass(this);
const hass = provideHass(this);
this._hass = hass;
}
protected render() {
@@ -85,6 +89,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-card>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic"}
header-title="Basic adaptive dialog"
@closed=${this._handleClosed}
@@ -93,6 +98,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic-subtitle-below"}
header-title="Adaptive dialog with subtitle"
header-subtitle="This is an adaptive dialog with a subtitle below"
@@ -102,6 +108,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic-subtitle-above"}
header-title="Adaptive dialog with subtitle above"
header-subtitle="This is an adaptive dialog with a subtitle above"
@@ -112,6 +119,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "small"}
width="small"
header-title="Small adaptive dialog"
@@ -121,6 +129,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "large"}
width="large"
header-title="Large adaptive dialog"
@@ -130,6 +139,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "form"}
header-title="Adaptive dialog with form"
header-subtitle="This is an adaptive dialog with a form"
@@ -153,6 +163,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.allowModeChange=${this._openDialog === "allow-mode-change"}
header-title="Adaptive dialog with allow mode change"
header-subtitle="Resize the window while this dialog is open"
@@ -165,6 +176,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "actions"}
header-title="Adaptive dialog with actions"
header-subtitle="This is an adaptive dialog with header actions"
@@ -383,6 +395,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
<h3>Example usage</h3>
<pre><code>&lt;ha-adaptive-dialog
.hass=\${this.hass}
open
header-title="Dialog title"
header-subtitle="Dialog subtitle"
@@ -594,7 +607,7 @@ export class DemoHaAdaptiveDialog extends LitElement {
<p>Example:</p>
<pre><code>&lt;ha-adaptive-dialog open&gt;
<pre><code>&lt;ha-adaptive-dialog .hass=\${this.hass} open&gt;
&lt;ha-form autofocus .schema=\${schema}&gt;&lt;/ha-form&gt;
&lt;/ha-adaptive-dialog&gt;</code></pre>
</div>
@@ -9,6 +9,7 @@ import "../../../../src/components/ha-form/ha-form";
import "../../../../src/components/ha-icon-button";
import type { HASSDomCurrentTargetEvent } from "../../../../src/common/dom/fire_event";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
const SCHEMA: HaFormSchema[] = [
@@ -22,10 +23,12 @@ type PopoverType = false | "basic" | "small" | "large" | "form" | "actions";
export class DemoHaAdaptivePopover extends LitElement {
@state() private _openPopover: PopoverType = false;
@state() private _hass?: HomeAssistant;
@state() private _dialogAnchor?: HTMLElement;
protected firstUpdated() {
provideHass(this);
this._hass = provideHass(this);
}
protected render() {
@@ -69,6 +72,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-card>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "basic"}
.dialogAnchor=${this._dialogAnchor}
header-title="Basic adaptive popover"
@@ -82,6 +86,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "small"}
.dialogAnchor=${this._dialogAnchor}
width="small"
@@ -92,6 +97,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "large"}
.dialogAnchor=${this._dialogAnchor}
width="large"
@@ -102,6 +108,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "form"}
.dialogAnchor=${this._dialogAnchor}
header-title="Adaptive popover with form"
@@ -126,6 +133,7 @@ export class DemoHaAdaptivePopover extends LitElement {
</ha-adaptive-popover>
<ha-adaptive-popover
.hass=${this._hass}
.open=${this._openPopover === "actions"}
.dialogAnchor=${this._dialogAnchor}
header-title="Adaptive popover with actions"
@@ -51,12 +51,11 @@ The badge component is a small component that displays a number or status inform
**Properties/Attributes**
| Name | Type | Default | Description |
| --------------- | ----------------------- | ----------- | ------------------------------------------------------------- |
| type | `"badge"` or `"button"` | `"badge"` | If it's button it shows a ripple effect |
| label | string | `undefined` | Text label for the badge, only visible if `iconOnly = false` |
| iconOnly | boolean | `false` | Only show label |
| accessibleLabel | string | `undefined` | Accessible name of the button, only used if `iconOnly = true` |
| Name | Type | Default | Description |
| -------- | ----------------------- | ----------- | ------------------------------------------------------------ |
| type | `"badge"` or `"button"` | `"badge"` | If it's button it shows a ripple effect |
| label | string | `undefined` | Text label for the badge, only visible if `iconOnly = false` |
| iconOnly | boolean | `false` | Only show label |
**CSS Custom Properties**
@@ -63,25 +63,26 @@ means supplying one.
### Properties/Attributes
| Name | Type | Default | Description |
| ------------------ | ------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| getItems | Function | - | Required. Returns the items, optionally filtered by search string and section. |
| getAdditionalItems | Function | - | Extra items appended to search results, for example "add new". |
| value | String | - | Id of the current item. Marks its row `selected`, and the virtualized list opens with that row in view. |
| label | String | - | Placeholder for the search field. Falls back to a localized "Search". |
| mode | "popover"/"dialog" | "popover" | Adjusts padding for the surface the list sits in. |
| shown | Boolean | true | Whether the surface finished animating. `ha-generic-picker` sets it so the virtualizer does not measure rows mid-animation. |
| sections | Array | - | Filter chips. Section headers are plain strings returned by `getItems`, and the sticky title shows the one above the first visible row. |
| selectedSection | String | - | Section filter selected when the list is initialized. |
| searchKeys | Array | - | Fuse weighted keys for fuzzy search. |
| searchFn | Function | - | Post-processes filtered results. |
| rowRenderer | Function | - | Replaces the default row template. |
| allowCustomValue | Boolean | false | Offers the typed string as an item. |
| customValueLabel | String | - | Label for that custom item. |
| notFoundLabel | String/Function | - | Shown when a search matches nothing. |
| emptyLabel | String | - | Shown when there are no items at all. |
| noSort | Boolean | false | Keeps the order `getItems` returned. |
| clearable | Boolean | false | Adjusts search field padding for a clear affordance. |
| Name | Type | Default | Description |
| -------------------- | ------------------ | --------- | --------------------------------------------------------------------------------------------------------------------------- |
| getItems | Function | - | Required. Returns the items, optionally filtered by search string and section. |
| getAdditionalItems | Function | - | Extra items appended to search results, for example "add new". |
| value | String | - | Id of the current item. Marks its row `selected`, and the virtualized list opens with that row in view. |
| label | String | - | Placeholder for the search field. Falls back to a localized "Search". |
| mode | "popover"/"dialog" | "popover" | Adjusts padding for the surface the list sits in. |
| shown | Boolean | true | Whether the surface finished animating. `ha-generic-picker` sets it so the virtualizer does not measure rows mid-animation. |
| sections | Array | - | Filter chips. Section headers are plain strings returned by `getItems`. |
| selectedSection | String | - | Section filter selected when the list is initialized. |
| sectionTitleFunction | Function | - | Builds the sticky section title from the visible range. |
| searchKeys | Array | - | Fuse weighted keys for fuzzy search. |
| searchFn | Function | - | Post-processes filtered results. |
| rowRenderer | Function | - | Replaces the default row template. |
| allowCustomValue | Boolean | false | Offers the typed string as an item. |
| customValueLabel | String | - | Label for that custom item. |
| notFoundLabel | String/Function | - | Shown when a search matches nothing. |
| emptyLabel | String | - | Shown when there are no items at all. |
| noSort | Boolean | false | Keeps the order `getItems` returned. |
| clearable | Boolean | false | Adjusts search field padding for a clear affordance. |
### Events
+1 -1
View File
@@ -33,4 +33,4 @@ We want to make it as easy for designers to contribute as it is for developers.
## Developers
Everything you need to get started developing can be found in our <a href="https://contributors.home-assistant.io/frontend/" rel="noopener noreferrer" target="_blank">Home Assistant contributor docs</a>.
Everything you need to get started developing can be found in our <a href="https://developers.home-assistant.io" rel="noopener noreferrer" target="_blank">Home Assistant Developer Docs</a>.
+1 -1
View File
@@ -271,7 +271,7 @@ const CONFIGS = [
### ha-alert
You can use our [\`ha-alert\`](https://contributors.home-assistant.io/design/components/alert/) component in markdown content rendered in the Home Assistant Frontend.
You can use our [\`ha-alert\`](https://design.home-assistant.io/#components/ha-alert) component in markdown content rendered in the Home Assistant Frontend.
<ha-alert alert-type="error">This is an error alert — check it out!</ha-alert>
<ha-alert alert-type="warning">This is a warning alert — check it out!</ha-alert>
@@ -102,13 +102,6 @@ const CONFIGS = [
entity: "media_player.theater_off_static",
},
},
{
heading: "Player Off (assumed state, cannot be switched on)",
config: {
type: "media-control",
entity: "media_player.theater_off_assumed",
},
},
{
heading: "Player On (cannot be switched off)",
config: {
@@ -290,6 +290,7 @@ export class DemoIntegrationCard extends LitElement {
class=${classMap({
highlight: info.highlight !== undefined,
})}
.hass=${this.hass}
domain="esphome"
.items=${info.items}
.manifest=${createManifest(
@@ -312,6 +313,7 @@ export class DemoIntegrationCard extends LitElement {
<!-- One that is standalone to see how it increases height if height
not defined by other cards. -->
<ha-integration-card
.hass=${this.hass}
domain="esphome"
.items=${[
loadedEntry,
+1 -1
View File
@@ -32,7 +32,7 @@ It is similar to the core frontend dev.
- `"mounts": ["source=/path/to/hass/frontend,target=/workspaces/frontend,type=bind,consistency=cached"]`
- use the dev container
- start the dev server with following optional env vars:
- `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://contributors.home-assistant.io/os/supervisor/core-connection/#get-access-to-the-supervisor-api) and set the host of your supervisor. e.g.: `SUPERVISOR_HOST=192.168.0.20:8888`
- `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://developers.home-assistant.io/docs/supervisor/development/#supervisor-api-access) and set the host of your supervisor. e.g.: `SUPERVISOR_HOST=192.168.0.20:8888`
- `SUPERVISOR_TOKEN` the supervisor api token you get from the Remote API proxy Addon Logs
- `FRONTEND_PATH` the path inside your container should be `/workspaces/frontend`
- example: `SUPERVISOR_TOKEN=abc123 SUPERVISOR_HOST=192.168.0.20:8888 FRONTEND_PATH=/workspaces/frontend go run main.go http.go mdns.go`
+1 -1
View File
@@ -6,7 +6,7 @@ export default {
],
"*.{json,css,md,markdown,html,ya?ml}": "prettier --cache --write",
"translations/*/*.json": (files) =>
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://contributors.home-assistant.io/translate/ for details." ' +
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://developers.home-assistant.io/docs/translations/ for details." ' +
files.join(" ") +
" >&2 && exit 1",
"pnpm-lock.yaml": () => "pnpm dedupe",
+34 -35
View File
@@ -40,18 +40,17 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "8.0.7",
"@babel/runtime": "8.0.5",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.11.1",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-python": "6.2.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.13.1",
"@codemirror/language": "6.12.4",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.6",
"@codemirror/view": "6.43.14",
"@codemirror/view": "6.43.13",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.8.1",
@@ -63,12 +62,12 @@
"@formatjs/intl-numberformat": "9.4.3",
"@formatjs/intl-pluralrules": "6.3.15",
"@formatjs/intl-relativetimeformat": "12.3.15",
"@fullcalendar/core": "6.1.22",
"@fullcalendar/daygrid": "6.1.22",
"@fullcalendar/interaction": "6.1.22",
"@fullcalendar/list": "6.1.22",
"@fullcalendar/luxon3": "6.1.22",
"@fullcalendar/timegrid": "6.1.22",
"@fullcalendar/core": "6.1.21",
"@fullcalendar/daygrid": "6.1.21",
"@fullcalendar/interaction": "6.1.21",
"@fullcalendar/list": "6.1.21",
"@fullcalendar/luxon3": "6.1.21",
"@fullcalendar/timegrid": "6.1.21",
"@home-assistant/webawesome": "3.14.0-ha.0",
"@lezer/common": "1.5.3",
"@lezer/highlight": "1.2.5",
@@ -86,16 +85,16 @@
"@swc/helpers": "0.5.23",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@versatiles/style": "6.4.1",
"@versatiles/style": "6.1.1",
"@vibrant/color": "4.0.4",
"@vvo/tzdb": "6.201.1",
"@vvo/tzdb": "6.198.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"barcode-detector": "3.2.2",
"cally": "0.9.2",
"color-name": "2.1.1",
"comlink": "4.4.2",
"core-js": "3.50.0",
"cropperjs": "1.7.0",
"cropperjs": "1.6.3",
"culori": "4.0.2",
"date-fns": "4.4.0",
"deep-clone-simple": "1.1.1",
@@ -108,14 +107,14 @@
"home-assistant-js-websocket": "9.7.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.3",
"js-yaml": "5.5.0",
"js-yaml": "5.4.3",
"leaflet": "1.9.4",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"maplibre-gl": "6.14.0",
"marked": "18.1.1",
"maplibre-gl": "6.12.0",
"marked": "18.1.0",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
@@ -142,24 +141,24 @@
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.0.7",
"@babel/helper-compilation-targets": "8.0.7",
"@babel/core": "8.0.6",
"@babel/helper-compilation-targets": "8.0.6",
"@babel/helper-define-polyfill-provider": "1.0.0",
"@babel/plugin-transform-runtime": "8.0.6",
"@babel/preset-env": "8.0.7",
"@babel/preset-env": "8.0.6",
"@bundle-stats/plugin-webpack-filter": "4.22.3",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.67.0",
"@html-eslint/eslint-plugin": "0.66.1",
"@lokalise/node-api": "16.6.0",
"@octokit/auth-oauth-device": "8.0.5",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.64.1",
"@rsdoctor/rspack-plugin": "1.6.5",
"@rspack/core": "2.2.9",
"@playwright/test": "1.63.0",
"@rsdoctor/rspack-plugin": "1.6.4",
"@rspack/core": "2.2.8",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.27",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/culori": "4.0.1",
@@ -169,24 +168,24 @@
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.6",
"@types/node": "24.19.2",
"@types/node": "24.19.1",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "5.0.5",
"@vitest/coverage-v8": "5.0.3",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.29.3",
"browserslist-useragent-regexp": "4.1.4",
"core-js-compat": "3.50.0",
"del": "9.0.1",
"del": "8.0.1",
"eslint": "10.12.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.17.1",
"eslint-plugin-lit": "2.3.1",
"eslint-plugin-lit-a11y": "5.1.3",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-unused-imports": "4.4.1",
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
@@ -199,7 +198,7 @@
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "30.1.3",
"jsdom": "30.1.2",
"jszip": "3.10.2",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
@@ -210,7 +209,7 @@
"map-stream": "0.0.7",
"minify-literals": "2.2.0",
"pinst": "3.0.0",
"prettier": "3.9.10",
"prettier": "3.9.9",
"rspack-manifest-plugin": "5.2.2",
"serve": "14.2.6",
"sinon": "22.1.0",
@@ -218,16 +217,16 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.71.2",
"vite": "8.3.4",
"typescript-eslint": "8.71.1",
"vite": "8.3.3",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.5",
"vitest": "5.0.3",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "7.4.1"
},
"packageManager": "[email protected]4.0",
"packageManager": "[email protected]0.0",
"volta": {
"node": "24.22.0"
"node": "24.21.0"
}
}
+1008 -1044
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -26,7 +26,7 @@ overrides:
lit-html: 3.3.3
clean-css: 5.3.3
"@lit/reactive-element": 2.1.2
"@fullcalendar/daygrid": 6.1.22
"@fullcalendar/daygrid": 6.1.21
tslib: 2.8.1
patchedDependencies:
@@ -1,9 +0,0 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="12" y="13.25" width="6" height="1.5" rx="0.75" fill="black" fill-opacity="0.48"/>
<rect x="37" y="12.5" width="20" height="3" rx="1.5" fill="black" fill-opacity="0.32"/>
<rect x="76" y="13.25" width="6" height="1.5" rx="0.75" fill="black" fill-opacity="0.48"/>
<rect x="78.25" y="11" width="1.5" height="6" rx="0.75" fill="black" fill-opacity="0.48"/>
</svg>

Before

Width:  |  Height:  |  Size: 691 B

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

Before

Width:  |  Height:  |  Size: 693 B

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

Before

Width:  |  Height:  |  Size: 478 B

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

Before

Width:  |  Height:  |  Size: 482 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 223 KiB

+1 -1
View File
@@ -29,7 +29,7 @@ const ALLOWED_LICENSES = new Set([
"(MIT OR CC0-1.0)",
"(MIT OR Apache-2.0)",
"(MIT AND Zlib)",
"PSF-2.0", // argparse - Python Software Foundation License (permissive)
"Python-2.0", // argparse - Python Software Foundation License (permissive)
"Public Domain",
"W3C-20150513", // wicg-inert - W3C Software and Document License (permissive)
"Unlicense",
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../data/page_navigation";
import type { HomeAssistant, HomeAssistantConfig } from "../../types";
import { ensureArray } from "../array/ensure-array";
import { isComponentLoaded } from "./is_component_loaded";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { PageNavigation } from "../../data/page_navigation";
import type { HomeAssistant } from "../../types";
import { canShowPage } from "./can_show_page";
@@ -1,141 +0,0 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import { mainWindow } from "../dom/get_main_window";
import { replaceCurrentUrl } from "../navigate";
import { constructUrlCurrentPath } from "../url/construct-url";
import { currentPath } from "../url/current-path";
import {
updateQueryString,
type QueryParamConfig,
type QueryParamValues,
} from "../url/query-params";
const DEBOUNCE_WAIT = 300;
export interface QueryParamsControllerOptions {
/** Whether the host keeps its state in the URL. */
enabled: () => boolean;
/**
* Called when the page of the host is navigated to while the host is
* connected, like a second link to an open page or coming back to it. Read
* the URL with `searchParams()` and apply it, then `write()` the state back.
*/
navigated: () => void;
}
/**
* Keeps part of a page's state in the query string of the URL, so it can be
* shared, bookmarked and reloaded. Writes replace the current history entry,
* so Back leaves the page instead of undoing each change, and only touch the
* config's params. The controller only acts while the URL is still the
* page the host was connected on.
*/
export class QueryParamsController<
C extends QueryParamConfig,
> implements ReactiveController {
private _path?: string;
private _pending?: QueryParamValues<C>;
private _pendingTimeout?: number;
private _hostUpdated = false;
constructor(
private _host: ReactiveControllerHost,
private _config: C,
private _options: QueryParamsControllerOptions
) {
this._host.addController(this);
}
public hostConnected() {
this._path = currentPath();
mainWindow.addEventListener("location-changed", this._navigated);
mainWindow.addEventListener("popstate", this._navigated);
// Before the first update the host reads the URL itself, after its
// initial state is set
if (this._hostUpdated) {
this._navigated();
}
}
public hostUpdated() {
this._hostUpdated = true;
}
public hostDisconnected() {
mainWindow.removeEventListener("location-changed", this._navigated);
mainWindow.removeEventListener("popstate", this._navigated);
this._cancelPending();
}
/** The current search params, or undefined when not on the host's page. */
public searchParams(): URLSearchParams | undefined {
if (!this._isActive()) {
return undefined;
}
return new URLSearchParams(mainWindow.location.search);
}
/**
* Write values to the URL. Pass `debounced` for values that change while
* typing; any later write replaces a pending one.
*/
public write(values: QueryParamValues<NoInfer<C>>, debounced = false) {
this._cancelPending();
if (debounced) {
this._pending = values;
this._pendingTimeout = window.setTimeout(
() => this.flush(),
DEBOUNCE_WAIT
);
return;
}
this._write(values);
}
/**
* Write a pending debounced write now, like when typing ends, before
* something like a dialog adds a history entry it would land on.
*/
public flush() {
const values = this._pending;
this._cancelPending();
if (values) {
this._write(values);
}
}
private _cancelPending() {
clearTimeout(this._pendingTimeout);
this._pendingTimeout = undefined;
this._pending = undefined;
}
private _isActive(): boolean {
return this._options.enabled() && currentPath() === this._path;
}
private _navigated = () => {
this._cancelPending();
// Leaving the page gets here too, before the router swaps the page
if (this._isActive()) {
this._options.navigated();
}
};
private _write(values: QueryParamValues<C>) {
if (!this._isActive()) {
return;
}
const current = mainWindow.location.search;
const queryString = updateQueryString(current, values, this._config);
if (current !== (queryString ? `?${queryString}` : "")) {
replaceCurrentUrl(constructUrlCurrentPath(queryString));
}
}
}
-1
View File
@@ -13,7 +13,6 @@ export const canOverrideAlphanumericInput = (composedPath: EventTarget[]) => {
if (
el.tagName === "TEXTAREA" ||
(el as HTMLElement).isContentEditable ||
el.parentElement?.tagName === "HA-SELECT" ||
el.parentElement?.tagName === "HA-DROPDOWN"
) {
-27
View File
@@ -1,27 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { computeDomain } from "./compute_domain";
export const ALERT_DOMAINS = [
"alarm_control_panel",
"binary_sensor",
"cover",
"lock",
];
export const DEFAULT_ALERT_COLOR = "red";
export const computeAlertActiveStates = (entityId: string): string[] => {
switch (computeDomain(entityId)) {
case "alarm_control_panel":
return ["triggered"];
case "cover":
return ["open"];
case "lock":
return ["jammed", "unlocked", "open"];
default:
return ["on"];
}
};
export const isAlertActive = (stateObj: HassEntity): boolean =>
computeAlertActiveStates(stateObj.entity_id).includes(stateObj.state);
+23 -1
View File
@@ -1,7 +1,12 @@
import memoizeOne from "memoize-one";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
import { getDuplicates } from "../string/get_duplicates";
import { computeStateName } from "./compute_state_name";
export const computeDeviceName = (
device: DeviceRegistryEntry
@@ -9,15 +14,32 @@ export const computeDeviceName = (
export const computeDeviceNameDisplay = (
device: DeviceRegistryEntry,
localize: HomeAssistant["localize"]
localize: HomeAssistant["localize"],
hassStates: HomeAssistant["states"],
entities?: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
) =>
computeDeviceName(device) ||
(entities && fallbackDeviceName(hassStates, entities)) ||
localize("ui.panel.config.devices.unnamed_device", {
type: localize(
`ui.panel.config.devices.type.${device.entry_type || "device"}`
),
});
export const fallbackDeviceName = (
hassStates: HomeAssistant["states"],
entities: EntityRegistryEntry[] | EntityRegistryDisplayEntry[] | string[]
) => {
for (const entity of entities || []) {
const entityId = typeof entity === "string" ? entity : entity.entity_id;
const stateObj = hassStates[entityId];
if (stateObj) {
return computeStateName(stateObj);
}
}
return undefined;
};
export const getDuplicatedDeviceNames = memoizeOne(
(devices: HomeAssistant["devices"]): Set<string> => {
const names = Object.values(devices)
+75
View File
@@ -0,0 +1,75 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
import { computeDeviceName } from "./compute_device_name";
import { computeStateName } from "./compute_state_name";
import {
isDeviceName,
stripPrefixFromEntityName,
} from "./strip_prefix_from_entity_name";
export const computeEntityName = (
stateObj: HassEntity,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): string | undefined => {
const entry = entities[stateObj.entity_id] as
EntityRegistryDisplayEntry | undefined;
if (!entry) {
// Fall back to state name if not in the entity registry (friendly name)
return computeStateName(stateObj);
}
return computeEntityEntryName(entry, devices);
};
export const computeEntityEntryName = (
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
devices: HomeAssistant["devices"],
fallbackStateObj?: HassEntity
): string | undefined => {
const name =
entry.name ??
("original_name" in entry && entry.original_name != null
? String(entry.original_name)
: undefined);
const device = entry.device_id ? devices[entry.device_id] : undefined;
if (!device) {
if (name) {
return name;
}
if (fallbackStateObj) {
return computeStateName(fallbackStateObj);
}
return undefined;
}
const deviceName = computeDeviceName(device);
if (entry.name == null || entry.next_name_part !== "device") {
return name || undefined;
}
// An empty entity name or one that is only the device name means the entity uses the device name
if (!name || (deviceName && isDeviceName(name, deviceName))) {
return undefined;
}
// Remove the device name from the entity name if it starts with it
if (deviceName && name) {
return stripPrefixFromEntityName(name, deviceName) || name;
}
return name;
};
export const entityUseDeviceName = (
stateObj: HassEntity,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): boolean => !computeEntityName(stateObj, entities, devices);
+180 -124
View File
@@ -3,20 +3,19 @@ import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { HomeAssistant, HomeAssistantRegistries } from "../../types";
import type { HomeAssistant } from "../../types";
import { ensureArray } from "../array/ensure-array";
import { computeRTL } from "../util/compute_rtl";
import { computeAreaName } from "./compute_area_name";
import { computeDeviceName } from "./compute_device_name";
import {
computeEntityEntryName,
entityUseDeviceName,
} from "./compute_entity_name";
import { computeFloorName } from "./compute_floor_name";
import { computeStateName } from "./compute_state_name";
import { getEntityEntryContext } from "./context/get_entity_context";
import {
DEFAULT_ENTITY_NAME,
type EntityNameItem,
type EntityNameOptions,
type EntityNameType,
} from "./entity_name_config";
import type { EntityNameItem, EntityNameOptions } from "./entity_name_config";
const DEFAULT_SEPARATOR = " ";
@@ -27,12 +26,6 @@ export type {
EntityNameType,
} from "./entity_name_config";
export type EntityNameParts = Partial<Record<EntityNameType, string>>;
export interface EntityNamePartsOptions {
followContext?: boolean;
}
// Joins items that need no entity context. Returns undefined as soon as one
// item references the registries (device, area, ...).
const computeTextOnlyName = (
@@ -69,140 +62,183 @@ export const computeEntityNameDisplayWithoutContext = (
);
};
export const computeEntityEntryName = (
entry: EntityRegistryDisplayEntry | EntityRegistryEntry
): string | undefined => {
const name =
entry.name ??
("original_name" in entry && entry.original_name != null
? String(entry.original_name)
: undefined);
return name || undefined;
export const computeEntityNameDisplay = (
stateObj: HassEntity,
name: string | EntityNameItem | EntityNameItem[] | undefined,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
options?: EntityNameOptions
) => {
if (typeof name === "string") {
return name;
}
// If no name config is provided, fall back to the friendly name
if (!name) {
return computeStateName(stateObj);
}
let items = ensureArray(name);
const separator = options?.separator ?? DEFAULT_SEPARATOR;
// If all items are text, just join them
const textOnlyName = computeTextOnlyName(items, options);
if (textOnlyName !== undefined) {
return textOnlyName;
}
const useDeviceName = entityUseDeviceName(stateObj, entities, devices);
// If entity uses device name, and device is not already included, replace it with device name
if (useDeviceName) {
const hasDevice = items.some((n) => n.type === "device");
if (!hasDevice) {
items = items.map((n) => (n.type === "entity" ? { type: "device" } : n));
}
}
const names = computeEntityNameList(
stateObj,
items,
entities,
devices,
areas,
floors,
{ keepAllParts: true }
);
// If after processing there is only one name, return that
if (names.length === 1) {
return names[0] || "";
}
return names.filter((n) => n).join(separator);
};
export const computeEntityEntryNameParts = (
export interface EntityNameListOptions {
keepAllParts?: boolean;
}
export const computeEntityNameList = (
stateObj: HassEntity,
name: EntityNameItem[],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
options?: EntityNameListOptions
): (string | undefined)[] => {
const entry = entities[stateObj.entity_id] as
EntityRegistryDisplayEntry | undefined;
if (!entry) {
return name.map((item) =>
item.type === "entity"
? computeStateName(stateObj)
: item.type === "text"
? item.text
: undefined
);
}
return computeEntityEntryNameList(
entry,
name,
entities,
devices,
areas,
floors,
options
);
};
export const computeEntityEntryNameList = (
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
registries: HomeAssistantRegistries,
options?: EntityNamePartsOptions
): EntityNameParts => {
name: EntityNameItem[],
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
options?: EntityNameListOptions
): (string | undefined)[] => {
const { device, parentDevice, area, floor } = getEntityEntryContext(
entry,
registries.entities,
registries.devices,
registries.areas,
registries.floors
entities,
devices,
areas,
floors
);
const entityName = computeEntityEntryName(entry);
const followContext = options?.followContext ?? true;
const entityName = computeEntityEntryName(entry, devices);
const keepAllParts = options?.keepAllParts ?? false;
// Same rule as the backend: an owner only adds its name part while the
// next_name_part links reach it, so owners above the first node with its own
// area are left out. An entity without a name of its own is still named
// after its device.
const nameDevice =
!followContext || entry.next_name_part === "device" || !entityName
keepAllParts || entry.next_name_part === "device" || !entityName
? device
: null;
const nameParentDevice =
!followContext ||
keepAllParts ||
(entry.next_name_part === "device" &&
device?.next_name_part === "parent_device")
? parentDevice
: null;
return {
entity: entityName,
device: nameDevice ? computeDeviceName(nameDevice) : undefined,
parent_device: nameParentDevice
? computeDeviceName(nameParentDevice)
: undefined,
area: area ? computeAreaName(area) : undefined,
floor: floor ? computeFloorName(floor) : undefined,
};
};
export const computeEntityNameParts = (
stateObj: HassEntity,
registries: HomeAssistantRegistries,
options?: EntityNamePartsOptions
): EntityNameParts => {
const entry = registries.entities[stateObj.entity_id] as
EntityRegistryDisplayEntry | undefined;
if (!entry) {
return { entity: computeStateName(stateObj) };
}
return computeEntityEntryNameParts(entry, registries, options);
};
export const computeDefaultEntityNameItems = (
stateObj: HassEntity,
registries: HomeAssistantRegistries
): EntityNameItem[] => {
const parts = computeEntityNameParts(stateObj, registries);
return DEFAULT_ENTITY_NAME.filter((item) => parts[item.type]);
};
export const computeEntityNameDisplay = (
stateObj: HassEntity,
name: string | EntityNameItem | EntityNameItem[] | undefined,
registries: HomeAssistantRegistries,
options?: EntityNameOptions
): string => {
if (typeof name === "string") {
return name;
}
const separator = options?.separator ?? DEFAULT_SEPARATOR;
if (!name) {
const parts = computeEntityNameParts(stateObj, registries);
return (
DEFAULT_ENTITY_NAME.map((item) => parts[item.type])
.filter((n) => n)
.join(separator) || computeStateName(stateObj)
);
}
const items = ensureArray(name);
const textOnlyName = computeTextOnlyName(items, options);
if (textOnlyName !== undefined) {
return textOnlyName;
}
const parts = computeEntityNameParts(stateObj, registries, {
followContext: false,
return name.map((item) => {
switch (item.type) {
case "entity":
return entityName;
case "device":
return nameDevice ? computeDeviceName(nameDevice) : undefined;
case "parent_device":
return nameParentDevice
? computeDeviceName(nameParentDevice)
: undefined;
case "area":
return area ? computeAreaName(area) : undefined;
case "floor":
return floor ? computeFloorName(floor) : undefined;
case "text":
return item.text;
default:
return "";
}
});
// An entity without a name of its own is named after its device, unless the
// device is already part of the name
const useDeviceName =
!parts.entity && !items.some((item) => item.type === "device");
return items
.map((item) =>
item.type === "text"
? item.text
: parts[item.type === "entity" && useDeviceName ? "device" : item.type]
)
.filter((n) => n)
.join(separator);
};
export const computeEntitySearchLabels = (
stateObj: HassEntity,
registries: HomeAssistantRegistries
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
) => {
const parts = computeEntityNameParts(stateObj, registries, {
followContext: false,
});
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
entities,
devices,
areas,
floors,
{ keepAllParts: true }
);
return {
entityName: parts.entity || null,
entityName: entityName || null,
friendlyName: computeStateName(stateObj) || null,
deviceName: parts.device || null,
parentDeviceName: parts.parent_device || null,
areaName: parts.area || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
};
};
@@ -212,20 +248,40 @@ export interface EntityPickerDisplay {
}
export const computeEntityPickerDisplay = (
hass: HomeAssistantRegistries &
Pick<HomeAssistant, "language" | "translationMetadata">,
hass: Pick<
HomeAssistant,
| "entities"
| "devices"
| "areas"
| "floors"
| "language"
| "translationMetadata"
>,
stateObj: HassEntity
): EntityPickerDisplay => {
const parts = computeEntityNameParts(stateObj, hass);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const primary = parts.entity || parts.device || stateObj.entity_id;
const primary = entityName || deviceName || stateObj.entity_id;
const secondary =
[parts.area, parts.parent_device, parts.entity ? parts.device : undefined]
[areaName, parentDeviceName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
+1 -1
View File
@@ -7,7 +7,7 @@ export interface EntityLocation {
gpsAccuracy?: number;
}
export const findFirstActiveZone = (
const findFirstActiveZone = (
inZones: readonly string[],
states: HassEntities
): HassEntity | undefined => {
+5 -4
View File
@@ -4,7 +4,6 @@ import type { HomeAssistant } from "../../types";
import { stringCompare } from "../string/compare";
import { computeDomain } from "./compute_domain";
import { computeStateDomain } from "./compute_state_domain";
import { computeZoneTrackerState } from "../../data/zone";
export const FIXED_DOMAIN_STATES = {
alarm_control_panel: [
@@ -276,10 +275,12 @@ export const getStatesDomain = (
result.push(
...Object.entries(hass.states)
.filter(
([entityId]) =>
computeDomain(entityId) === "zone" && entityId !== "zone.home"
([entityId, stateObj]) =>
computeDomain(entityId) === "zone" &&
entityId !== "zone.home" &&
stateObj.attributes.friendly_name
)
.map(([_entityId, stateObj]) => computeZoneTrackerState(stateObj))
.map(([_entityId, stateObj]) => stateObj.attributes.friendly_name!)
.sort((zone1, zone2) =>
stringCompare(zone1, zone2, hass.locale.language)
)
+21
View File
@@ -0,0 +1,21 @@
/**
* Sort function to help sort states by name
*
* Usage:
* const states = [state1, state2]
* states.sort(statessortStatesByName);
*/
import type { HassEntity } from "home-assistant-js-websocket";
import { computeStateName } from "./compute_state_name";
export const sortStatesByName = (entityA: HassEntity, entityB: HassEntity) => {
const nameA = computeStateName(entityA);
const nameB = computeStateName(entityB);
if (nameA < nameB) {
return -1;
}
if (nameA > nameB) {
return 1;
}
return 0;
};
@@ -0,0 +1,48 @@
const SUFFIXES = [" ", ": ", " - "];
/**
* Strips a device name from an entity name.
* @param entityName the entity name
* @param prefix the prefix to strip
* @returns
*/
export const stripPrefixFromEntityName = (
entityName: string,
prefix: string
) => {
const lowerCasedEntityName = entityName.toLowerCase();
const lowerCasedPrefix = prefix.toLowerCase();
for (const suffix of SUFFIXES) {
const lowerCasedPrefixWithSuffix = `${lowerCasedPrefix}${suffix}`;
if (lowerCasedEntityName.startsWith(lowerCasedPrefixWithSuffix)) {
const newName = entityName.substring(lowerCasedPrefixWithSuffix.length);
if (newName.length) {
// If first word already has an upper case letter (e.g. from brand name)
// leave as-is, otherwise capitalize the first word.
return hasUpperCase(newName.substr(0, newName.indexOf(" ")))
? newName
: newName[0].toUpperCase() + newName.slice(1);
}
}
}
return undefined;
};
/**
* Whether an entity name is only the device name, which core treats as no name of its own.
*/
export const isDeviceName = (entityName: string, deviceName: string) => {
if (!deviceName) {
return false;
}
const lowerCasedEntityName = entityName.toLowerCase();
const lowerCasedDeviceName = deviceName.toLowerCase();
return (
lowerCasedEntityName.startsWith(lowerCasedDeviceName) &&
/^[ :-]*$/.test(lowerCasedEntityName.slice(lowerCasedDeviceName.length))
);
};
const hasUpperCase = (str: string): boolean => str.toLowerCase() !== str;
+1 -3
View File
@@ -4,8 +4,6 @@ import { theme2hex } from "../color/convert-color";
/** The zone marker: a colored circle with the zone's icon or initials, shared by the map and the zone editor */
export const ZONE_CIRCLE_SIZE = 36;
// Same as an entity marker's icon
const ZONE_ICON_SIZE = 24;
// WCAG minimum contrast for icons and other graphics
const MIN_CONTENT_CONTRAST = 3;
@@ -70,6 +68,6 @@ export const zoneMarkerStyles = `
overflow: hidden;
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
--mdc-icon-size: ${ZONE_ICON_SIZE}px;
--mdc-icon-size: ${ZONE_CIRCLE_SIZE / 2}px;
}
`;
+4 -1
View File
@@ -92,7 +92,10 @@ export const computeFormatFunctions = async (
computeEntityNameDisplay(
stateObj,
name,
{ entities, devices, areas, floors },
entities,
devices,
areas,
floors,
options
),
};
-106
View File
@@ -1,106 +0,0 @@
import type {
DataTableColumnContainer,
SortingDirection,
} from "../../components/data-table/ha-data-table";
import {
decodeQueryParams,
hasQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
export const dataTableQueryParamConfig = {
string: ["search", "sort", "direction", "group"],
} as const satisfies QueryParamConfig;
export type DataTableQueryParams = QueryParamValues<
typeof dataTableQueryParamConfig
>;
// How a data table is searched, sorted and grouped
export interface DataTableQueryState {
search: string;
sorting?: { column: string; direction: "asc" | "desc" };
groupColumn?: string;
}
// Sorting uses the column id unless the column has a name for the URL
const sortName = (columns: DataTableColumnContainer, columnId: string) =>
columns[columnId].urlName ?? columnId;
/**
* Params for the table's state. Empty and default values are left out, and
* grouping is only written for columns that have a name for the URL, so
* internal column ids don't end up in links.
*/
export const dataTableQueryParamsFromState = (
columns: DataTableColumnContainer,
search: string,
sortColumn: string | undefined,
sortDirection: SortingDirection,
groupColumn: string | undefined
): DataTableQueryParams => {
const params: DataTableQueryParams = {};
if (search) {
params.search = search;
}
if (sortColumn && sortDirection && columns[sortColumn]?.sortable) {
params.sort = sortName(columns, sortColumn);
if (sortDirection === "desc") {
params.direction = "desc";
}
}
if (groupColumn && columns[groupColumn]?.groupable) {
const group = columns[groupColumn].urlName;
if (group) {
params.group = group;
}
}
return params;
};
/**
* The table's state a link asks for, undefined when it has no table params so
* what was picked before stays. A link replaces the search, a search left
* behind would hide part of what it shows. Sorting and grouping it leaves out,
* or that don't match a column, keep what was picked before.
*/
export const dataTableQueryStateFromUrl = (
searchParams: SearchParamsSource,
columns: DataTableColumnContainer
): DataTableQueryState | undefined => {
if (!hasQueryParams(searchParams, dataTableQueryParamConfig)) {
return undefined;
}
const { search, sort, direction, group } = decodeQueryParams(
searchParams,
dataTableQueryParamConfig
);
const state: DataTableQueryState = { search: search ?? "" };
const sortColumn = sort
? Object.keys(columns).find(
(id) => columns[id].sortable && sortName(columns, id) === sort
)
: undefined;
if (sortColumn) {
state.sorting = {
column: sortColumn,
direction: direction === "desc" ? "desc" : "asc",
};
}
const groupColumn = group
? Object.keys(columns).find(
(id) => columns[id].groupable && columns[id].urlName === group
)
: undefined;
if (groupColumn) {
state.groupColumn = groupColumn;
}
return state;
};
@@ -1,26 +0,0 @@
import {
createQueryString,
decodeQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
// The filters of the Marketplace tables. Search, sorting and grouping are kept
// by the data table, see data-table-query-params.
export const marketplaceFilterQueryParamConfig = {
list: ["status", "type", "source"],
} as const satisfies QueryParamConfig;
export type MarketplaceFilterQueryParams = QueryParamValues<
typeof marketplaceFilterQueryParamConfig
>;
export const decodeMarketplaceFilterQueryParams = (
searchParams: SearchParamsSource
): MarketplaceFilterQueryParams =>
decodeQueryParams(searchParams, marketplaceFilterQueryParamConfig);
export const createMarketplaceFilterQueryString = (
values: MarketplaceFilterQueryParams
): string => createQueryString(values, marketplaceFilterQueryParamConfig);
-45
View File
@@ -134,51 +134,6 @@ export function createQueryString(
return searchParams.toString();
}
const queryParamKeys = (config: QueryParamConfig): string[] => [
...(config.list ?? []),
...(config.date ?? []),
...(config.boolean ?? []).map(({ key }) => key),
...(config.string ?? []),
];
/** Whether the search params hold any of the config's params. */
export const hasQueryParams = (
searchParams: SearchParamsSource,
config: QueryParamConfig
): boolean =>
queryParamKeys(config).some(
(key) => getSearchParam(searchParams, key) !== null
);
/**
* Replace the config's params in a query string with the given values,
* keeping every other param exactly as it is written.
*/
export function updateQueryString<C extends QueryParamConfig>(
current: string,
values: QueryParamValues<NoInfer<C>>,
config: C
): string;
export function updateQueryString(
current: string,
values: Record<string, QueryParamValue | undefined>,
config: QueryParamConfig
): string {
const keys = queryParamKeys(config);
const kept = current
.replace(/^\?/, "")
.split("&")
.filter((part) => {
if (!part) {
return false;
}
const [key] = new URLSearchParams(part).keys();
return !keys.includes(key);
});
const updated = createQueryString(values, config);
return [...kept, ...(updated ? [updated] : [])].join("&");
}
export const serviceTargetFromQueryParams = <
Key extends keyof HassServiceTarget & string,
>(
-68
View File
@@ -1,68 +0,0 @@
/**
* Keeps a locally edited value from being overwritten by entity state updates
* while the change is waiting to be sent or the service call is in flight.
*
* Call `schedule()` when the user changes the value and a (debounced) service
* call is queued, wrap the service call in `run()`, and only copy entity state
* into the local value when `shouldSync()` returns true. After a successful
* call the local value is kept until the next state update syncs it. When the
* call fails, the `resync` callback restores the entity state.
*
* Only the latest call is tracked, as it supersedes any earlier ones.
*/
export class PendingValueSync {
private _scheduled = false;
private _latestCall?: symbol;
public get pending(): boolean {
return this._scheduled || this._latestCall !== undefined;
}
/** Mark that a service call has been scheduled for a local change. */
public schedule(): void {
this._scheduled = true;
}
/**
* Start over, e.g. when switching entities, so a pending change is not
* shown for (or sent to) a different entity. Calls already in flight are
* not aborted, but no longer block state updates or trigger `resync`.
*/
public reset(): void {
this._scheduled = false;
this._latestCall = undefined;
}
/** Whether an entity state update may be copied into the local value. */
public shouldSync(): boolean {
return !this.pending;
}
/**
* Run the service call for the scheduled change. If the latest call fails
* and no new change is scheduled, `resync` is called. Errors are not
* rethrown, as `callService` already notifies the user when the call fails.
*/
public async run(
call: () => Promise<unknown>,
resync: () => void
): Promise<void> {
const thisCall = Symbol("call");
this._scheduled = false;
this._latestCall = thisCall;
let failed = false;
try {
await call();
} catch (_err) {
failed = true;
}
if (this._latestCall !== thisCall) {
return;
}
this._latestCall = undefined;
if (failed && !this._scheduled) {
resync();
}
}
}
+3 -9
View File
@@ -5,20 +5,14 @@ export const subscribePollingCollection = (
updateData: (hass: HomeAssistant) => void,
interval: number
) => {
let timeout: number | undefined;
let cancelled = false;
let timeout;
const fetchData = async () => {
try {
await updateData(hass);
} finally {
if (!cancelled) {
timeout = window.setTimeout(() => fetchData(), interval);
}
timeout = setTimeout(() => fetchData(), interval);
}
};
fetchData();
return () => {
cancelled = true;
clearTimeout(timeout);
};
return () => clearTimeout(timeout);
};
@@ -1,25 +1,18 @@
import type { ContextType } from "@lit/context";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consume } from "../../common/decorators/consume";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../common/translations/localize";
import { debounce } from "../../common/util/debounce";
import type { Condition } from "../../data/automation";
import { subscribeCondition } from "../../data/automation";
import { connectionContext } from "../../data/context";
import type { HomeAssistant } from "../../types";
import "../ha-tooltip";
import "./ha-automation-row-live-test";
import type { LiveTestState } from "./ha-automation-row-live-test";
@customElement("ha-automation-condition-live-test")
export class HaAutomationConditionLiveTest extends LitElement {
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@consume({ context: connectionContext, subscribe: true })
private _connection!: ContextType<typeof connectionContext>;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public condition!: Condition;
@@ -58,7 +51,7 @@ export class HaAutomationConditionLiveTest extends LitElement {
<slot></slot>
<ha-automation-row-live-test
.state=${this._liveTestResult.state}
.label=${this._localize(
.label=${this.hass.localize(
`ui.panel.config.automation.editor.conditions.live_test_state.${this._liveTestResult.state}`
)}
></ha-automation-row-live-test>
@@ -76,7 +69,7 @@ export class HaAutomationConditionLiveTest extends LitElement {
private _resetSubscription() {
this._liveTestResult = {
state: "unknown",
message: this._localize(
message: this.hass.localize(
"ui.panel.config.automation.editor.conditions.live_test_state.unknown"
),
};
@@ -99,14 +92,14 @@ export class HaAutomationConditionLiveTest extends LitElement {
}
const conditionUnsub = subscribeCondition(
this._connection.connection,
this.hass.connection,
(result) => {
if (result.error) {
this._handleLiveTestError(result.error);
} else {
this._liveTestResult = {
state: result.result ? "pass" : "fail",
message: this._localize(
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.testing_${result.result ? "pass" : "error"}`
),
};
@@ -128,7 +121,7 @@ export class HaAutomationConditionLiveTest extends LitElement {
typeof error !== "string" && error.code === "invalid_format";
this._liveTestResult = {
state: invalid ? "invalid" : "unknown",
message: this._localize(
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.${invalid ? "invalid_condition" : "live_test_state.unknown"}`
),
};
@@ -7,7 +7,6 @@ import memoizeOne from "memoize-one";
import { consume } from "../../common/decorators/consume";
import { truncateWithEllipsis } from "../../common/string/truncate-with-ellipsis";
import type { Condition } from "../../data/automation";
import { getRowNote } from "../../data/automation";
import type { ConditionDescription } from "../../data/condition";
import { internationalizationContext } from "../../data/context";
import "../../panels/config/automation/ha-automation-row-options";
@@ -43,7 +42,7 @@ export class HaAutomationConditionSummary extends LitElement {
this.condition?.condition === "numeric_state";
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this.isNew;
const note = getRowNote(this.condition?.note);
const note = this.condition?.note?.trim();
let target: HassServiceTarget | undefined;
if (this.condition) {
@@ -1,82 +0,0 @@
import { parsePlainText } from "zrender/lib/graphic/helper/parseText.js";
const FONT_FAMILY = "Roboto, Noto, sans-serif";
// zrender's overflow "break" only wraps at these characters, see isWordBreakChar
const WORD_BREAK =
/[ ,&?/;\]]|[^\u0020-\u024F\u0370-\u10FF\u1200-\u13FF\u1E00-\u206F]/;
interface FitLabelOptions {
/** Font size the label is measured at, and the largest size returned */
fontSize: number;
width: number;
height: number;
/** Height of the label at `fontSize` before ECharts wraps it */
unwrappedHeight: number;
}
/**
* Largest font size at which an ECharts label with `overflow: "break"`,
* wrapped at `width`, fits in `height` without breaking a word.
*/
export function fitLabelFontSize(
text: string,
{ fontSize: baseFontSize, width, height, unwrappedHeight }: FitLabelOptions
) {
const font = `${baseFontSize}px ${FONT_FAMILY}`;
const measure = (label: string, wrapWidth?: number) =>
parsePlainText(
label,
{ font, width: wrapWidth, overflow: "break" },
undefined,
undefined
);
// ECharts measures at the final font size, leave room for rounding
const fitWidth = Math.max(width - 1, 1);
const unwrappedContentHeight = measure(text).contentHeight;
const words = text.split(WORD_BREAK).filter(Boolean);
// a label at a smaller font wraps like one at baseFontSize in a wider box
const wrapWidthAt = (fontSize: number) =>
(fitWidth * baseFontSize) / fontSize;
const maxFontSize = (height / unwrappedHeight) * baseFontSize;
const fits = (fontSize: number) => {
const wrapWidth = wrapWidthAt(fontSize);
if (words.some((word) => measure(word, wrapWidth).lines.length > 1)) {
return false;
}
const wrappedHeight =
unwrappedHeight +
(measure(text, wrapWidth).contentHeight - unwrappedContentHeight);
// exact at maxFontSize when the label doesn't wrap
return fontSize <= maxFontSize * (unwrappedHeight / wrappedHeight);
};
let low = 0;
let high = Math.min(baseFontSize, maxFontSize);
if (fits(high)) {
low = high;
} else {
for (let i = 0; i < 10; i++) {
const mid = (low + high) / 2;
if (fits(mid)) {
low = mid;
} else {
high = mid;
}
}
}
// zrender moves a space that doesn't fit to the next line, indenting it.
// Shrinking further only widens the wrap, so the label still fits.
const spaceWidth = measure(" ").contentWidth;
const indents = (fontSize: number) =>
text
.split("\n")
.some((paragraph) =>
measure(paragraph, wrapWidthAt(fontSize)).lines.some(
(line, i) => i > 0 && line.startsWith(" ")
)
);
let fontSize = low;
for (let i = 0; i < 3 && indents(fontSize); i++) {
fontSize = (fitWidth * baseFontSize) / (wrapWidthAt(fontSize) + spaceWidth);
}
return fontSize;
}
+47 -29
View File
@@ -280,7 +280,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _pendingUpdate?: Set<PropertyKey>;
private _legendItemCount?: number;
private _pendingOptions?: HaECOption;
private _pendingZoom?: [number, number, boolean];
@@ -292,6 +292,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._tooltipHiddenWhilePanning = false;
this._pendingSetup = false;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
this._pendingZoom = undefined;
// The observers are about to be torn down, so nothing would correct a stale
// value if this element is reattached inside a hidden container.
@@ -444,6 +445,11 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (!this._pendingUpdate) {
this._pendingUpdate = new Set();
}
if (prop === "options" && !this._pendingUpdate.has(prop)) {
// The one previous value a replay reads, and it has to stay the options
// the chart currently renders, not those of a later deferred change.
this._pendingOptions = changedProps.get(prop) as HaECOption | undefined;
}
this._pendingUpdate.add(prop);
}
}
@@ -460,8 +466,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
return;
}
const pending = this._pendingUpdate!;
const previousOptions = this._pendingOptions;
this._pendingUpdate = undefined;
this._applyChartUpdate(pending);
this._pendingOptions = undefined;
this._applyChartUpdate(pending, previousOptions);
}
public willUpdate(changedProps: PropertyValues): void {
@@ -477,6 +485,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (invisible) {
this._pendingSetup = true;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
} else {
this._setupChart();
}
@@ -494,10 +503,16 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
return;
}
this._applyChartUpdate(changedProps);
this._applyChartUpdate(
changedProps,
changedProps.get("options") as HaECOption | undefined
);
}
private _applyChartUpdate(changedProps: { has: (prop: string) => boolean }) {
private _applyChartUpdate(
changedProps: { has: (prop: string) => boolean },
previousOptions: HaECOption | undefined
) {
let chartOptions: ECOption = {};
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
chartOptions.series = this._getSeries();
@@ -517,28 +532,20 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
this._disposeSonification();
if (this._compareCustomLegendOptions(previousOptions, this.options)) {
// custom legend changes may require a resize to layout properly
this._shouldResizeChart = true;
this._resizeAnimationDuration = 250;
}
} else if (this._isTouchDevice && changedProps.has("_isZoomed")) {
chartOptions.dataZoom = this._getDataZoomConfig();
}
let legendChanged = false;
if (changedProps.has("options") || changedProps.has("data")) {
const legendItemCount = this._getLegendItems()?.length;
legendChanged = legendItemCount !== this._legendItemCount;
this._legendItemCount = legendItemCount;
}
if (Object.keys(chartOptions).length > 0) {
this._setChartOptions(chartOptions);
if (chartOptions.series || changedProps.has("_isZoomed")) {
this._updateSankeyRoam();
}
}
if (legendChanged) {
// Within a fixed height the host does not resize when the legend grows.
// Without animation, setOption has already fired "finished" by now.
this._shouldResizeChart = true;
this._resizeAnimationDuration = 250;
this.updateComplete.then(() => this._resizeChartIfNeeded());
}
}
protected render() {
@@ -825,6 +832,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._loading = true;
this._pendingSetup = false;
this._pendingUpdate = undefined;
this._pendingOptions = undefined;
try {
// The connection holds a reference to the chart instance, so it cannot
// outlive it. Focusing the chart again reconnects.
@@ -875,14 +883,13 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
const sankeySeries = series?.find((s: any) => s.type === "sankey");
const zoomed = Math.abs(sankeySeries.zoom - 1) > 1e-6;
this._isZoomed = zoomed;
// Before setOption, which lays the labels out again
fireEvent(this, "chart-sankeyroam", { zoom: sankeySeries.zoom });
if (!zoomed) {
// Reset center when fully zoomed out
this.chart!.setOption({
series: [{ id: sankeySeries.id, center: null }],
});
}
fireEvent(this, "chart-sankeyroam", { zoom: sankeySeries.zoom });
// Clear cached emphasis states so labels don't revert to pre-zoom sizes
this.chart!.dispatchAction({ type: "downplay" });
});
@@ -995,7 +1002,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
...this._createOptions(),
series: this._getSeries(),
});
this._legendItemCount = this._getLegendItems()?.length;
this._updateSankeyRoam();
if (this._pendingZoom) {
const [start, end, silent] = this._pendingZoom;
@@ -1452,7 +1458,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (this._isZoomed) {
this._handleZoomReset();
} else {
fireEvent(this, "chart-sankeyroam", { zoom: 2 });
this.chart.setOption({
series: sankeySeries.map((s: any) => ({
id: s.id,
@@ -1497,19 +1502,15 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
private _handleZoomReset() {
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
);
// Before the dataZoom action and setOption, which lay the labels out again
if (sankeySeries?.length) {
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
}
// A dataZoom action reruns the layout of every series, graphs included
if (this._isZoomed) {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
}
// Reset sankey roam zoom
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({
series: sankeySeries.map((s: any) => ({
@@ -1519,6 +1520,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
})),
});
this._isZoomed = false;
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
}
this._getGraphRoams().forEach(({ series, zoom, x, y }) => {
this.chart!.dispatchAction({
@@ -1870,6 +1872,22 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._resizeAnimationDuration = undefined;
}
private _compareCustomLegendOptions(
oldOptions: HaECOption | undefined,
newOptions: HaECOption | undefined
): boolean {
const oldLegends = ensureArray(
oldOptions?.legend || []
) as LegendComponentOption[];
const newLegends = ensureArray(
newOptions?.legend || []
) as LegendComponentOption[];
return (
oldLegends.some((l) => l.show && l.type === "custom") !==
newLegends.some((l) => l.show && l.type === "custom")
);
}
static styles = css`
:host {
display: block;
-2
View File
@@ -32,7 +32,6 @@ export interface NetworkNode {
borderColor?: string;
borderWidth?: number;
};
label?: { position: "left" | "right" | "top" | "bottom" };
fixed?: boolean;
/**
* Distance from the center, where 0 is the center and 1 is the edge
@@ -316,7 +315,6 @@ export class HaNetworkGraph extends LitElement {
symbolSize: node.symbolSize || 30,
symbol: node.symbol || "circle",
itemStyle: node.itemStyle || {},
label: node.label,
fixed: node.fixed,
x: node.x,
y: node.y,
+9 -16
View File
@@ -9,7 +9,6 @@ import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import SankeyChart from "../../resources/echarts/components/sankey/install";
import type { HaECOption } from "../../resources/echarts/echarts";
import { measureTextWidth } from "../../util/text";
import { fitLabelFontSize } from "./fit-label-font-size";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
import "../ha-alert";
@@ -238,10 +237,8 @@ export class HaSankeyChart extends LitElement {
distance: LABEL_DISTANCE,
minMargin: LABEL_MIN_MARGIN,
overflow: "break",
lineOverflow: this.vertical ? "truncate" : "none",
},
labelLayout: (params) => {
// params.rect is already zoomed, only the gap slack needs scaling
if (this.vertical) {
// reduce the label font size so the longest word fits on one line
const longestWord = params.text
@@ -260,7 +257,7 @@ export class HaSankeyChart extends LitElement {
: longest;
}, "");
const wordWidth = measureTextWidth(longestWord, FONT_SIZE) || 1;
const availableWidth = params.rect.width + 6 * this._currentZoom;
const availableWidth = (params.rect.width + 6) * this._currentZoom;
// minMargin is applied as padding on the label box, so words must
// fit in the inner wrap width or overflow:break splits them.
const wrapWidth = Math.max(availableWidth - LABEL_MIN_MARGIN, 1);
@@ -269,25 +266,21 @@ export class HaSankeyChart extends LitElement {
(wrapWidth / wordWidth) * FONT_SIZE
);
return {
fontSize,
// Safari draws a 0px canvas font at its default size, so hide
// unreadable labels by dropping all of their lines instead.
height: fontSize > 1 ? undefined : 0,
fontSize: fontSize > 1 ? fontSize : 0,
width: availableWidth,
align: "center",
dy: -2, // shift up or the lowest row labels may be cut off
};
}
const availableHeight = params.rect.height + 8 * this._currentZoom; // account for the margin
const availableHeight = (params.rect.height + 8) * this._currentZoom; // account for the margin
const fontSize = Math.min(
(availableHeight / params.labelRect.height) * FONT_SIZE,
FONT_SIZE
);
return {
// labelRect is measured before the width is applied, so unwrapped
fontSize: fitLabelFontSize(params.text, {
fontSize: FONT_SIZE,
width: labelSpace,
height: availableHeight,
unwrappedHeight: params.labelRect.height,
}),
fontSize,
lineHeight: fontSize,
width: labelSpace,
height: params.rect.height,
};
@@ -66,15 +66,15 @@ export class DialogDataTableSettings extends LitElement {
}
private _preserveLastFixed(columnOrder) {
let strippedColumnOrder;
const lastFixedCount = this._lastFixedCount();
if (!lastFixedCount || !columnOrder) {
return columnOrder;
if (lastFixedCount && columnOrder) {
strippedColumnOrder = [...columnOrder];
strippedColumnOrder.splice(
columnOrder.length - lastFixedCount,
lastFixedCount
);
}
const strippedColumnOrder = [...columnOrder];
strippedColumnOrder.splice(
columnOrder.length - lastFixedCount,
lastFixedCount
);
return strippedColumnOrder;
}
+36 -307
View File
@@ -1,7 +1,3 @@
import type {
LitVirtualizer,
VisibilityChangedEvent,
} from "@lit-labs/virtualizer";
import type { ContextType } from "@lit/context";
import { mdiArrowDown, mdiArrowUp, mdiChevronUp } from "@mdi/js";
import deepClone from "deep-clone-simple";
@@ -79,8 +75,6 @@ export interface DataTableSortColumnData {
valueColumn?: string;
direction?: SortingDirection;
groupable?: boolean;
// Name in the `sort` and `group` URL params, grouping is only kept with one
urlName?: string;
moveable?: boolean;
hideable?: boolean;
defaultHidden?: boolean;
@@ -197,29 +191,7 @@ export class HaDataTable extends LitElement {
@query(".mdc-data-table__header-row") private _headerRow?: HTMLDivElement;
@query("lit-virtualizer")
private _scroller?: LitVirtualizer<DataTableRowData>;
@query(".sticky-group-header") private _stickyGroupHeader?: HTMLElement;
@query(".sticky-group-header .group-header")
private _stickyGroupHeaderCell?: HTMLElement;
// Group pinned to the top of the scroller while its own header is scrolled out of view
@state() private _stickyGroup?: string;
// Position and width of the sticky group header, applied directly on scroll
// to avoid re-rendering the table on every frame
private _stickyGroupPosition = { top: 0, width: 0, scrollLeft: 0 };
private _stickyGroupFrame?: number;
// Resizing doesn't scroll, but changes the width of the sticky group header
private _resizeObserver = new ResizeObserver(() =>
this._scheduleStickyGroupUpdate()
);
private _firstVisibleIndex = 0;
@query("lit-virtualizer") private _scroller?: HTMLElement;
@state() private _collapsedGroups: string[] = [];
@@ -265,7 +237,6 @@ export class HaDataTable extends LitElement {
public connectedCallback() {
super.connectedCallback();
this._resizeObserver.observe(this);
if (this._filteredData?.length) {
// Force update of location of rows
this._filteredData = [...this._filteredData];
@@ -276,21 +247,7 @@ export class HaDataTable extends LitElement {
this.updateComplete.then(() => this._calcTableHeight());
}
public disconnectedCallback() {
super.disconnectedCallback();
this._resizeObserver.disconnect();
if (this._stickyGroupFrame !== undefined) {
cancelAnimationFrame(this._stickyGroupFrame);
this._stickyGroupFrame = undefined;
}
}
protected updated(changedProps: PropertyValues<this>) {
// The sticky group header may have just been rendered, and the items may
// have changed (filter, sort, collapse) without the visible range changing
this._positionStickyGroupHeader();
this._scheduleStickyGroupUpdate();
if (!this._headerRow) {
return;
}
@@ -386,7 +343,6 @@ export class HaDataTable extends LitElement {
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
} else if (properties.has("groupColumn")) {
this._collapsedGroups = [];
this._stickyGroup = undefined;
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
}
@@ -417,12 +373,6 @@ export class HaDataTable extends LitElement {
}
}
private _memRenderRow = memoizeOne(
(columns: DataTableColumnContainer, narrow: boolean) =>
(row: DataTableRowData, index: number) =>
this._renderRow(columns, narrow, row, index)
);
private _sortedColumns = memoizeOne(
(columns: DataTableColumnContainer, columnOrder?: string[]) => {
if (!columnOrder || !columnOrder.length) {
@@ -458,7 +408,8 @@ export class HaDataTable extends LitElement {
protected render() {
const columns = this._sortedColumns(this.columns, this.columnOrder);
const renderRow = this._memRenderRow(columns, this.narrow);
const renderRow = (row: DataTableRowData, index: number) =>
this._renderRow(columns, this.narrow, row, index);
const filteredDataLength = this._filteredData?.length || 0;
@@ -704,12 +655,9 @@ export class HaDataTable extends LitElement {
: html`
<lit-virtualizer
scroller
class="mdc-data-table__content scroller ha-scrollbar ${classMap(
{ grouped: Boolean(this.groupColumn) && !this.autoHeight }
)}"
class="mdc-data-table__content scroller ha-scrollbar"
tabindex=${ifDefined(!this.autoHeight ? "0" : undefined)}
@scroll=${this._saveScrollPos}
@visibilityChanged=${this._handleVisibilityChanged}
.items=${this._groupData(
this._filteredData,
this._i18n?.localize,
@@ -724,25 +672,6 @@ export class HaDataTable extends LitElement {
.keyFunction=${this._keyFunction}
.renderItem=${renderRow}
></lit-virtualizer>
${
this.groupColumn &&
!this.autoHeight &&
this._stickyGroup !== undefined
? html`<div
class="mdc-data-table__row sticky-group-header"
aria-hidden="true"
.group=${this._stickyGroup}
@click=${this._collapseStickyGroup}
>
${this._renderGroupHeader(
this._stickyGroup,
this._collapsedGroups.includes(this._stickyGroup),
this._i18n?.localize,
true
)}
</div>`
: nothing
}
`
}
</div>
@@ -763,14 +692,7 @@ export class HaDataTable extends LitElement {
return nothing;
}
if (row.append) {
return html`<div
class="mdc-data-table__row ${classMap({
"group-header-row": row.groupHeader !== undefined,
})}"
.group=${row.groupHeader}
>
${row.content}
</div>`;
return html`<div class="mdc-data-table__row">${row.content}</div>`;
}
if (row.empty) {
return html`<div class="mdc-data-table__row empty-row"></div>`;
@@ -878,13 +800,7 @@ export class HaDataTable extends LitElement {
key: string,
column: DataTableColumnData
): boolean {
// The grouped value is already shown in the sticky group header, which
// isn't available for auto-height tables
if (
column.main ||
column.showNarrow ||
(!this.autoHeight && key === this.groupColumn)
) {
if (column.main || column.showNarrow) {
return false;
}
return this._isColumnVisible(key, column);
@@ -1032,15 +948,33 @@ export class HaDataTable extends LitElement {
const groupedItems: DataTableRowData[] = [];
sortedEntries.forEach(([groupName, rows]) => {
const collapsed = collapsedGroups.includes(groupName);
groupedItems.push({
append: true,
selectable: false,
groupHeader: groupName,
content: this._renderGroupHeader(
groupName,
collapsedGroups.includes(groupName),
localize
),
content: html`<div
class="mdc-data-table__cell group-header"
role="cell"
.group=${groupName}
@click=${this._collapseGroup}
>
<ha-icon-button
.path=${mdiChevronUp}
.label=${
localize?.(
`ui.components.data-table.${collapsed ? "expand" : "collapse"}`
) || (collapsed ? "Expand" : "Collapse")
}
class=${collapsed ? "collapsed" : ""}
>
</ha-icon-button>
${
groupName === UNDEFINED_GROUP_KEY
? localize?.("ui.components.data-table.ungrouped") ||
"Ungrouped"
: groupName || ""
}
</div>`,
});
if (!collapsedGroups.includes(groupName)) {
groupedItems.push(...rows);
@@ -1061,40 +995,6 @@ export class HaDataTable extends LitElement {
}
);
private _renderGroupHeader(
groupName: string,
collapsed: boolean,
localize: LocalizeFunc | undefined,
sticky = false
) {
// The sticky copy is hidden from assistive technology, so its button is
// made inert. It handles clicks on its whole row instead, as its cell
// shifts with horizontal scrolling.
return html`<div
class="mdc-data-table__cell group-header"
role="cell"
.group=${groupName}
@click=${sticky ? nothing : this._collapseGroup}
>
<ha-icon-button
.path=${mdiChevronUp}
.label=${
localize?.(
`ui.components.data-table.${collapsed ? "expand" : "collapse"}`
) || (collapsed ? "Expand" : "Collapse")
}
class=${collapsed ? "collapsed" : ""}
?inert=${sticky}
>
</ha-icon-button>
${
groupName === UNDEFINED_GROUP_KEY
? localize?.("ui.components.data-table.ungrouped") || "Ungrouped"
: groupName || ""
}
</div>`;
}
private _memFilterData = memoizeOne(
(
data: DataTableRowData[],
@@ -1297,101 +1197,6 @@ export class HaDataTable extends LitElement {
if (this._headerRow) {
this._headerRow.scrollLeft = e.target.scrollLeft;
}
this._scheduleStickyGroupUpdate();
}
private _handleVisibilityChanged(ev: VisibilityChangedEvent) {
this._firstVisibleIndex = ev.first;
// Fired before the virtualizer positions its children, so measure later
this._scheduleStickyGroupUpdate();
}
private _scheduleStickyGroupUpdate() {
if (!this.groupColumn) {
this._stickyGroup = undefined;
return;
}
if (this._stickyGroupFrame !== undefined) {
return;
}
this._stickyGroupFrame = requestAnimationFrame(() => {
this._stickyGroupFrame = undefined;
this._updateStickyGroup();
});
}
/**
* The virtualizer positions rows absolutely, so `position: sticky` can't be
* used. Instead, an overlay copy of the current group header is shown at the
* top of the scroller, and the next group header slides under it. The
* overlay doesn't move with the scroll, as browsers scroll off the main
* thread and it would lag behind the rows.
*/
private _updateStickyGroup() {
const scroller = this._scroller;
if (
!scroller ||
!this.groupColumn ||
this.autoHeight ||
scroller.scrollTop <= 0
) {
this._stickyGroup = undefined;
return;
}
const scrollerTop = scroller.getBoundingClientRect().top;
let group: string | undefined;
let currentTop = -Infinity;
scroller
.querySelectorAll<HTMLElement & { group: string }>(".group-header-row")
.forEach((row) => {
const top = row.getBoundingClientRect().top - scrollerTop;
// Rows can sit at fractional positions, so a header less than a
// pixel below the top counts as reached
if (top < 1 && top > currentTop) {
currentTop = top;
group = row.group;
}
});
if (group === undefined) {
// The current group header is not rendered, find it from the first visible row
const items = scroller.items;
for (
let i = Math.min(this._firstVisibleIndex, items.length - 1);
i >= 0;
i--
) {
if (items[i]?.append && items[i].groupHeader !== undefined) {
group = items[i].groupHeader;
break;
}
}
}
this._stickyGroup = group;
this._stickyGroupPosition = {
top: scroller.offsetTop,
width: scroller.clientWidth,
scrollLeft: scroller.scrollLeft,
};
this._positionStickyGroupHeader();
}
private _positionStickyGroupHeader() {
const header = this._stickyGroupHeader;
if (!header) {
return;
}
const { top, width, scrollLeft } = this._stickyGroupPosition;
header.style.width = `${width}px`;
header.style.transform = `translateY(${top}px)`;
// Follow the horizontal scroll of the group header rows it covers
if (this._stickyGroupHeaderCell) {
this._stickyGroupHeaderCell.style.transform = `translateX(${-scrollLeft}px)`;
}
}
@eventOptions({ passive: true })
@@ -1403,40 +1208,6 @@ export class HaDataTable extends LitElement {
this._scroller.scrollLeft = e.target.scrollLeft;
}
private _collapseStickyGroup = async (
ev: HASSDomCurrentTargetEvent<HTMLElement & { group: string }>
) => {
const groupName = ev.currentTarget.group;
this._collapseGroup(ev);
await this.updateComplete;
const scroller = this._scroller;
if (!scroller) {
return;
}
// Keep the toggled group in view, its rows may have been above the viewport.
// Scroll a rendered header to the very top ourselves, as scrollIntoView
// would leave room for the sticky header (scroll-padding-top).
const row = Array.from(
scroller.querySelectorAll<HTMLElement & { group: string }>(
".group-header-row"
)
).find((headerRow) => headerRow.group === groupName);
if (row) {
scroller.scrollTop +=
row.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
return;
}
const index = scroller.items.findIndex(
(item) => item?.append && item.groupHeader === groupName
);
if (index !== -1) {
// The header isn't rendered, but element() still returns a proxy for
// any index in the items: its scrollIntoView() pins that index in the
// virtualizer's layout, which renders it and aligns it to the top
scroller.element(index)?.scrollIntoView({ block: "start" });
}
};
private _collapseGroup = (
ev: HASSDomCurrentTargetEvent<HTMLElement & { group: string }>
) => {
@@ -1762,6 +1533,10 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell--numeric {
text-align: var(--float-end);
}
.mdc-data-table__header-cell--numeric.sortable:hover,
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted) {
text-align: var(--float-start);
}
/* custom from here */
@@ -1787,28 +1562,6 @@ export class HaDataTable extends LitElement {
transform: rotate(180deg);
}
.sticky-group-header {
position: absolute;
top: 0;
inset-inline-start: 0;
z-index: 1;
/* Match the top border of the group header rows it covers */
border-top: 1px solid transparent;
border-bottom: 1px solid var(--divider-color);
background-color: var(--primary-background-color);
will-change: transform;
}
.sticky-group-header .group-header {
/* Don't paint over the bottom divider */
background-color: transparent;
}
/* Keep focused rows clear of the sticky group header */
lit-virtualizer.grouped {
scroll-padding-top: var(--data-table-row-height, 52px);
}
:host {
display: block;
}
@@ -1835,8 +1588,8 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell > * {
transition: var(--float-start) 0.2s ease;
}
.mdc-data-table__header-cell--numeric ha-svg-icon {
transition: var(--float-end) 0.2s ease;
.mdc-data-table__header-cell--numeric > span {
transition: none;
}
.mdc-data-table__header-cell ha-svg-icon {
top: -3px;
@@ -1859,25 +1612,6 @@ export class HaDataTable extends LitElement {
inset-inline-start: 12px;
inset-inline-end: initial;
}
.mdc-data-table__header-cell--numeric.not-sorted ha-svg-icon {
right: -20px;
inset-inline-end: -20px;
inset-inline-start: initial;
}
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted)
ha-svg-icon,
.mdc-data-table__header-cell--numeric.sortable:hover.not-sorted
ha-svg-icon {
right: 12px;
inset-inline-end: 12px;
inset-inline-start: initial;
}
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted) span,
.mdc-data-table__header-cell--numeric.sortable:hover.not-sorted span {
left: -24px;
inset-inline-start: -24px;
inset-inline-end: initial;
}
.table-header {
border-bottom: 1px solid var(--divider-color);
}
@@ -1909,11 +1643,6 @@ export class HaDataTable extends LitElement {
text-overflow: ellipsis;
margin-top: 2px;
}
.secondary ha-svg-icon {
--mdc-icon-size: 16px;
vertical-align: text-bottom;
margin-inline-end: 2px;
}
.scroller {
flex: 1;
min-height: 0;
@@ -188,8 +188,8 @@ const sortData = (
: b[column.valueColumn || sortColumn];
if (column.type === "numeric") {
valA = valA == null || isNaN(valA) ? undefined : Number(valA);
valB = valB == null || isNaN(valB) ? undefined : Number(valB);
valA = isNaN(valA) ? undefined : Number(valA);
valB = isNaN(valB) ? undefined : Number(valB);
} else if (column.type === "ip") {
return sort * ipCompare(valA, valB);
} else if (typeof valA === "string" && typeof valB === "string") {
@@ -56,7 +56,6 @@ export abstract class HaDeviceAutomationPicker<
private _localizeDeviceAutomation: (
localize: LocalizeFunc,
states: HassEntities,
formatEntityName: HomeAssistant["formatEntityName"],
entityRegistry: EntityRegistryEntry[],
automation: T
) => string;
@@ -106,6 +105,7 @@ export abstract class HaDeviceAutomationPicker<
const value = this._value;
return html`<ha-generic-picker
.hass=${this.hass}
.label=${this.label}
.value=${value}
.disabled=${!this._automations || this._automations.length === 0}
@@ -139,7 +139,6 @@ export abstract class HaDeviceAutomationPicker<
const primary = this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this.hass.formatEntityName,
this._entityReg,
automation
);
@@ -187,7 +186,6 @@ export abstract class HaDeviceAutomationPicker<
? this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this.hass.formatEntityName,
this._entityReg,
described
)
@@ -429,6 +429,7 @@ export class HaDevicePicker extends LitElement {
return html`
<ha-generic-picker
.noUnknownState=${canReplace}
.hass=${this.hass}
.autofocus=${this.autofocus}
.disabled=${this.disabled}
.helper=${this.helper}
@@ -84,6 +84,7 @@ class HaEntityAttributePicker extends LitElement {
return html`
<ha-generic-picker
.hass=${this.hass}
.value=${this.value}
.label=${
this.label ??
+29 -122
View File
@@ -1,16 +1,13 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiDragHorizontalVariant, mdiPlus, mdiRestore } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import {
computeDefaultEntityNameItems,
ENTITY_NAME_TYPES,
type EntityNameItem,
type EntityNameType,
@@ -25,14 +22,13 @@ import "../ha-button-toggle-group";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-icon-button";
import "../ha-input-helper-text";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import "../ha-sortable";
import "../input/ha-input";
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
<ha-combo-box-item .disabled=${!!item.disabled}>
<ha-combo-box-item>
<span slot="headline">${item.primary}</span>
${
item.secondary
@@ -44,11 +40,6 @@ const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
const KNOWN_TYPES = new Set<string>(ENTITY_NAME_TYPES);
const DEFAULT_NAME_WITHOUT_ENTITY: EntityNameItem[] = [
{ type: "device" },
{ type: "entity" },
];
const formatOptionValue = (item: EntityNameItem) => {
if (item.type === "text" && item.text) {
return item.text;
@@ -75,9 +66,6 @@ export class HaEntityNamePicker extends LitElement {
@property({ attribute: false }) public value?:
string | EntityNameItem | EntityNameItem[];
@property({ attribute: false }) public defaultValue?:
string | EntityNameItem | EntityNameItem[];
@property() public label?: string;
@property() public helper?: string;
@@ -95,7 +83,7 @@ export class HaEntityNamePicker extends LitElement {
connectedCallback(): void {
super.connectedCallback();
if (this.hasUpdated) {
const items = this._toItems(this.value ?? this.defaultValue);
const items = this._toItems(this.value);
this._mode =
items.length === 1 && items[0].type === "text" ? "custom" : "composed";
}
@@ -103,7 +91,7 @@ export class HaEntityNamePicker extends LitElement {
protected willUpdate(_changedProperties: PropertyValues<this>): void {
if (this._mode === undefined) {
const items = this._toItems(this.value ?? this.defaultValue);
const items = this._toItems(this.value);
this._mode =
items.length === 1 && items[0].type === "text" ? "custom" : "composed";
}
@@ -159,43 +147,13 @@ export class HaEntityNamePicker extends LitElement {
const items = this._items;
const value =
items.length === 1 && items[0].type === "text" ? items[0].text || "" : "";
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
return html`
<ha-input
.disabled=${this.disabled}
.required=${this.required}
.value=${value}
.placeholder=${
stateObj
? this.hass.formatEntityName(
stateObj,
this.value ?? this.defaultValue
)
: undefined
}
@input=${this._textInputChanged}
>
${this._renderRestoreButton("end")}
</ha-input>
`;
}
private _renderRestoreButton(slot?: string) {
if (this.value === undefined || this.disabled) {
return nothing;
}
return html`
<ha-icon-button
class="restore"
slot=${ifDefined(slot)}
.path=${mdiRestore}
.label=${this.hass.localize(
"ui.components.entity.entity-name-picker.restore"
)}
@click=${this._restore}
></ha-icon-button>
></ha-input>
`;
}
@@ -205,6 +163,7 @@ export class HaEntityNamePicker extends LitElement {
return html`
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.getItems=${this._getFilteredItems}
@@ -215,9 +174,6 @@ export class HaEntityNamePicker extends LitElement {
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
)}
.emptyLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.no_more_parts"
)}
@value-changed=${this._pickerValueChanged}
.searchFn=${this._searchFn}
.searchLabel=${this.hass.localize(
@@ -276,7 +232,6 @@ export class HaEntityNamePicker extends LitElement {
}
</ha-chip-set>
</ha-sortable>
${this._renderRestoreButton()}
</div>
</ha-generic-picker>
`;
@@ -287,13 +242,6 @@ export class HaEntityNamePicker extends LitElement {
this._mode = ev.detail.value as "composed" | "custom";
}
private _restore(ev: Event) {
ev.stopPropagation();
this._mode = undefined;
this.value = undefined;
fireEvent(this, "value-changed", { value: undefined });
}
private _textInputChanged(ev: Event) {
const value = (ev.target as HTMLInputElement).value;
const newValue: EntityNameItem[] = value
@@ -378,41 +326,9 @@ export class HaEntityNamePicker extends LitElement {
}
private get _items(): EntityNameItem[] {
if (this.value !== undefined) {
return this._toItems(this.value);
}
if (this.defaultValue !== undefined) {
return this._toItems(this.defaultValue);
}
return this._defaultItems(
this.entityId ? this.hass.states[this.entityId] : undefined,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
return this._toItems(this.value);
}
private _defaultItems = memoizeOne(
(
stateObj: HassEntity | undefined,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"]
): EntityNameItem[] => {
if (!stateObj) {
return DEFAULT_NAME_WITHOUT_ENTITY;
}
return computeDefaultEntityNameItems(stateObj, {
entities,
devices,
areas,
floors,
});
}
);
private _toItems = memoizeOne((value?: typeof this.value) => {
if (typeof value === "string") {
if (value === "") {
@@ -426,7 +342,7 @@ export class HaEntityNamePicker extends LitElement {
private _toValue = memoizeOne(
(items: EntityNameItem[]): typeof this.value => {
if (items.length === 0) {
return "";
return undefined;
}
if (items.length === 1) {
const item = items[0];
@@ -449,13 +365,18 @@ export class HaEntityNamePicker extends LitElement {
};
private _validTypes = memoizeOne((entityId?: string) => {
const stateObj = entityId ? this.hass.states[entityId] : undefined;
if (!stateObj) {
return new Set<string>(["text", ...ENTITY_NAME_TYPES]);
const options = new Set<string>(["text"]);
if (!entityId) {
return options;
}
const options = new Set<string>(["text", "entity"]);
const stateObj = this.hass.states[entityId];
if (!stateObj) {
return options;
}
options.add("entity");
const context = getEntityContext(
stateObj,
@@ -473,23 +394,26 @@ export class HaEntityNamePicker extends LitElement {
});
private _getItems = memoizeOne((entityId?: string) => {
if (!entityId) {
return [];
}
const types = this._validTypes(entityId);
const stateObj = entityId ? this.hass.states[entityId] : undefined;
const items = ENTITY_NAME_TYPES.filter(
(name) => name !== "parent_device" || types.has(name)
).map<PickerComboBoxItem>((name) => {
const stateObj = this.hass.states[entityId];
const isValid = types.has(name);
const primary = this.hass.localize(
`ui.components.entity.entity-name-picker.types.${name}`
);
const secondary = stateObj
? (isValid
? this.hass.formatEntityName(stateObj, { type: name })
: this.hass.localize(
`ui.components.entity.entity-name-picker.types.${name}_missing` as LocalizeKeys
)) || "-"
: undefined;
const secondary =
(stateObj && isValid
? this.hass.formatEntityName(stateObj, { type: name })
: this.hass.localize(
`ui.components.entity.entity-name-picker.types.${name}_missing` as LocalizeKeys
)) || "-";
const id = formatOptionValue({ type: name });
@@ -497,7 +421,6 @@ export class HaEntityNamePicker extends LitElement {
id,
primary,
secondary,
disabled: !isValid,
search_labels: {
primary,
secondary: secondary || null,
@@ -613,8 +536,6 @@ export class HaEntityNamePicker extends LitElement {
.field {
position: relative;
display: flex;
align-items: center;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
@@ -649,24 +570,10 @@ export class HaEntityNamePicker extends LitElement {
background-color: var(--mdc-theme-primary);
}
.field ha-sortable {
flex: 1;
min-width: 0;
}
ha-chip-set {
padding: var(--ha-space-3);
}
.restore {
--ha-icon-button-size: 36px;
--mdc-icon-size: 20px;
}
.field .restore {
margin-inline-end: var(--ha-space-2);
}
.add {
order: 1;
}
@@ -77,9 +77,6 @@ export class HaEntityPicker extends LitElement {
@property() public label?: string;
@property({ attribute: "aria-label" })
public ariaLabel: string | null = null;
@property() public value?: string;
@property() public helper?: string;
@@ -452,7 +449,6 @@ export class HaEntityPicker extends LitElement {
.allowCustomValue=${this.allowCustomEntity}
.required=${this.required}
.label=${this.label}
.ariaLabel=${this.ariaLabel}
.placeholder=${placeholder}
.helper=${this.helper}
.value=${this.addButton ? undefined : this.value}
@@ -261,6 +261,7 @@ export class HaStateContentPicker extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${this._getPickerValue()}
@@ -138,6 +138,7 @@ export class HaEntityStatePicker extends LitElement {
return html`
<ha-generic-picker
.hass=${this.hass}
.allowCustomValue=${this.allowCustomValue}
.disabled=${this.disabled || noEntity}
.autofocus=${this.autofocus}
+15 -2
View File
@@ -296,7 +296,13 @@ export class HaStatisticPicker extends LitElement {
hass,
stateObj
);
const searchLabels = computeEntitySearchLabels(stateObj, hass);
const searchLabels = computeEntitySearchLabels(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
output.push({
@@ -390,7 +396,13 @@ export class HaStatisticPicker extends LitElement {
this.hass,
stateObj
);
const searchLabels = computeEntitySearchLabels(stateObj, this.hass);
const searchLabels = computeEntitySearchLabels(
stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
return {
@@ -511,6 +523,7 @@ export class HaStatisticPicker extends LitElement {
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.disabled=${this.disabled}
+2 -5
View File
@@ -230,7 +230,7 @@ export class StateBadge extends LitElement {
const domain = computeDomain(this.stateObj.entity_id);
if (domain === "update") {
cls.set("has-no-radius", true);
} else if (domain === "media_player") {
} else if (domain === "media_player" || domain === "camera") {
cls.set("has-media-image", true);
} else if (this.style.backgroundImage !== "") {
cls.set("has-image", true);
@@ -257,10 +257,7 @@ export class StateBadge extends LitElement {
justify-content: center;
}
:host(.has-image) {
border-radius: var(
--state-badge-with-image-border-radius,
var(--ha-picture-border-radius)
);
border-radius: var(--state-badge-with-image-border-radius, 50%);
}
:host(.has-media-image) {
border-radius: var(--state-badge-with-media-image-border-radius, 8%);
+13 -18
View File
@@ -1,15 +1,10 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { consume } from "../common/decorators/consume";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import { apiContext, configContext } from "../data/context";
import { fetchHassioAddonsInfo } from "../data/hassio/addon";
import type { ValueChangedEvent } from "../types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-alert";
import "./ha-app-icon";
import "./ha-combo-box-item";
@@ -47,13 +42,7 @@ const rowRenderer: RenderItemFunction<AddonPickerItem> = (item) => html`
@customElement("ha-addon-picker")
class HaAddonPicker extends LitElement {
@state() @consumeLocalize() private _localize!: LocalizeFunc;
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@consume({ context: configContext, subscribe: true })
private _config!: ContextType<typeof configContext>;
public hass!: HomeAssistant;
@property() public label?: string;
@@ -84,7 +73,10 @@ class HaAddonPicker extends LitElement {
}
protected render() {
const label = this.label ?? this._localize("ui.components.app-picker.app");
const label =
this.label === undefined && this.hass
? this.hass.localize("ui.components.app-picker.app")
: this.label;
if (this._error) {
return html`<ha-alert alert-type="error">${this._error}</ha-alert>`;
@@ -95,6 +87,7 @@ class HaAddonPicker extends LitElement {
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${label}
.valueRenderer=${this._valueRenderer}
@@ -113,8 +106,8 @@ class HaAddonPicker extends LitElement {
private async _getApps() {
try {
if (isComponentLoaded(this._config.config, "hassio")) {
const addonsInfo = await fetchHassioAddonsInfo(this._api);
if (isComponentLoaded(this.hass.config, "hassio")) {
const addonsInfo = await fetchHassioAddonsInfo(this.hass);
this._addons = addonsInfo.addons
.filter((addon) => addon.version)
.map((addon) => ({
@@ -130,12 +123,14 @@ class HaAddonPicker extends LitElement {
sorting_label: [addon.name, addon.slug].filter(Boolean).join("_"),
}));
} else {
this._error = this._localize(
this._error = this.hass.localize(
"ui.components.app-picker.error.no_supervisor"
);
}
} catch (_err: any) {
this._error = this._localize("ui.components.app-picker.error.fetch_apps");
this._error = this.hass.localize(
"ui.components.app-picker.error.fetch_apps"
);
}
}
-10
View File
@@ -372,16 +372,6 @@ export class HaAnsiToHtml extends LitElement {
return !!numberOfFoundLines;
}
public trimLines(maxLines: number) {
const pre = this._pre;
if (!pre) {
return;
}
while (pre.childElementCount > maxLines && pre.firstElementChild) {
pre.firstElementChild.remove();
}
}
public clear() {
if (this._pre) {
this._pre.innerHTML = "";
@@ -281,6 +281,7 @@ export class HaAreaControlsPicker extends LitElement {
return html`
<ha-generic-picker
.hass=${this.hass}
.value=${this.value || ""}
.disabled=${this.disabled}
.required=${this.required}
+9 -1
View File
@@ -3,12 +3,16 @@ import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import type { HomeAssistant } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-area-picker";
import "./ha-sortable";
@customElement("ha-areas-picker")
export class HaAreasPicker extends LitElement {
export class HaAreasPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ type: Array }) public value?: string[];
@@ -63,6 +67,10 @@ export class HaAreasPicker extends LitElement {
@property({ type: Boolean }) public reorder = false;
protected render() {
if (!this.hass) {
return nothing;
}
const currentAreas = this._currentAreas;
return html`
<ha-sortable
+159 -60
View File
@@ -15,16 +15,14 @@ import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { transform } from "../common/decorators/transform";
import { supportsFeature } from "../common/entity/supports-feature";
import type { LocalizeFunc } from "../common/translations/localize";
import {
createAssistMessageProcessor,
type AssistMessage,
type AssistMessageProcessor,
} from "../data/assist_chat";
import {
runAssistPipeline,
type AssistPipeline,
type ConversationChatLogAssistantDelta,
type ConversationChatLogToolResultDelta,
type PipelineRunEvent,
} from "../data/assist_pipeline";
import type { ChatLogToolResult } from "../data/chat_log";
import {
configContext,
connectionContext,
@@ -45,15 +43,28 @@ import {
findAvailableLanguage,
getTranslation,
} from "../util/common-translation";
import {
documentationUrl,
SECURE_CONNECTION_DOCUMENTATION_PATH,
} from "../util/documentation-url";
import { documentationUrl } from "../util/documentation-url";
import "./ha-alert";
import "./ha-markdown";
import "./input/ha-input";
import type { HaInput } from "./input/ha-input";
interface AssistMessage {
who: string;
text: string | TemplateResult;
thinking: string;
thinking_expanded?: boolean;
tool_calls: Record<
string,
{
tool_name: string;
tool_args: Record<string, unknown>;
result?: ChatLogToolResult;
}
>;
error?: boolean;
}
export const initialPromptToSubmit = (
prompt: string | undefined,
submit: boolean
@@ -528,18 +539,31 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
this._conversation = [...this._conversation!, message];
}
private _showNotSupportedMessage() {
const documentationLink = `[${this._localize(
"ui.components.secure_connection_required.documentation_link"
)}](${documentationUrl(this._config, SECURE_CONNECTION_DOCUMENTATION_PATH)})`;
private async _showNotSupportedMessage() {
this._addMessage({
who: "hass",
text: `${this._localize(
"ui.components.secure_connection_required.microphone"
)}\n\n${this._localize(
"ui.components.secure_connection_required.learn_more",
{ documentation_link: documentationLink }
)}`,
text:
// New lines matter for messages
// prettier-ignore
html`${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_browser"
)}
${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_documentation",
{
documentation_link: html`<a
target="_blank"
rel="noopener noreferrer"
href=${documentationUrl(
this._config,
"/docs/configuration/securing/#remote-access"
)}
>${this._localize(
"ui.dialogs.voice_command.not_supported_microphone_documentation_link"
)}</a>`,
}
)}`,
thinking: "",
tool_calls: {},
});
@@ -559,31 +583,13 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
}
this._stt_binary_handler_id = undefined;
this._audioBuffer = [];
const microphoneError = await this._audioRecorder.start();
if (microphoneError) {
this._audioBuffer = undefined;
this._processing = false;
this._addMessage({
who: "hass",
text: this._localize(
`ui.dialogs.voice_command.microphone_${microphoneError}`
),
thinking: "",
tool_calls: {},
error: true,
});
return;
}
const userMessage: AssistMessage = {
who: "user",
text: "…",
thinking: "",
tool_calls: {},
};
await this._audioRecorder.start();
this._addMessage(userMessage);
@@ -596,9 +602,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
if (event.type === "run-start") {
this._stt_binary_handler_id =
event.data.runner_data.stt_binary_handler_id;
this._audio = new Audio(
this._connection.hassUrl(event.data.tts_output!.url)
);
this._audio = new Audio(event.data.tts_output!.url);
this._audio.play();
this._audio.addEventListener("ended", () => {
this._unloadAudio();
@@ -612,11 +616,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
);
this._audio.addEventListener("error", () => {
this._unloadAudio();
showAlertDialog(this, {
title: this._localize(
"ui.dialogs.voice_command.error_playing_audio"
),
});
showAlertDialog(this, { title: "Error playing audio." });
});
}
@@ -639,9 +639,6 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
hassMessageProcesser.addMessage();
} else if (event.type.startsWith("intent-")) {
hassMessageProcesser.processEvent(event);
if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
}
} else if (event.type === "run-end") {
this._stt_binary_handler_id = undefined;
unsub();
@@ -669,9 +666,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
);
} catch (err: any) {
await showAlertDialog(this, {
title: this._localize(
"ui.dialogs.voice_command.error_starting_pipeline"
),
title: "Error starting pipeline",
text: err.message || err,
});
this._stopListening();
@@ -733,9 +728,6 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
(event) => {
if (event.type.startsWith("intent-")) {
hassMessageProcesser.processEvent(event);
if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
}
}
if (event.type === "intent-end") {
unsub();
@@ -762,11 +754,118 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
}
}
private _createAddHassMessageProcessor(): AssistMessageProcessor {
return createAssistMessageProcessor({
addMessage: (message) => this._addMessage(message),
requestUpdate: () => this.requestUpdate("_conversation"),
});
private _createAddHassMessageProcessor() {
let currentDeltaRole = "";
const progressToNextMessage = () => {
if (
progress.hassMessage.text === "…" &&
!progress.hassMessage.thinking &&
(!progress.hassMessage.tool_calls ||
Object.keys(progress.hassMessage.tool_calls).length === 0)
) {
return;
}
if (progress.hassMessage.text?.endsWith("…")) {
progress.hassMessage.text = progress.hassMessage.text.slice(0, -1);
}
progress.hassMessage = {
who: "hass",
text: "…",
thinking: "",
tool_calls: {},
error: false,
};
this._addMessage(progress.hassMessage);
};
const isAssistantDelta = (
_delta: any
): _delta is Partial<ConversationChatLogAssistantDelta> =>
currentDeltaRole === "assistant";
const isToolResult = (
_delta: any
): _delta is ConversationChatLogToolResultDelta =>
currentDeltaRole === "tool_result";
const progress = {
continueConversation: false,
hassMessage: {
who: "hass",
text: "…",
thinking: "",
tool_calls: {},
error: false,
},
addMessage: () => {
this._addMessage(progress.hassMessage);
},
setError: (error: string) => {
progressToNextMessage();
progress.hassMessage.text = error;
progress.hassMessage.error = true;
this.requestUpdate("_conversation");
},
processEvent: (event: PipelineRunEvent) => {
if (event.type === "intent-progress" && event.data.chat_log_delta) {
const delta = event.data.chat_log_delta;
// new message
if (delta.role) {
currentDeltaRole = delta.role;
}
if (isAssistantDelta(delta)) {
if (delta.content) {
if (progress.hassMessage.text.endsWith("…")) {
progress.hassMessage.text =
progress.hassMessage.text.substring(
0,
progress.hassMessage.text.length - 1
) +
delta.content +
"…";
} else {
progress.hassMessage.text += delta.content + "…";
}
}
if (delta.thinking_content) {
progress.hassMessage.thinking += delta.thinking_content;
}
if (delta.tool_calls) {
for (const toolCall of delta.tool_calls) {
progress.hassMessage.tool_calls[toolCall.id] = toolCall;
}
}
this.requestUpdate("_conversation");
} else if (isToolResult(delta)) {
if (progress.hassMessage.tool_calls[delta.tool_call_id]) {
progress.hassMessage.tool_calls[delta.tool_call_id].result =
delta.result;
this.requestUpdate("_conversation");
}
}
} else if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
progress.continueConversation =
event.data.intent_output.continue_conversation;
const response =
event.data.intent_output.response.speech.plain?.speech;
if (!response) {
return;
}
if (event.data.intent_output.response.response_type === "error") {
progress.setError(response);
} else {
progress.hassMessage.text = response;
this.requestUpdate("_conversation");
}
}
},
};
return progress;
}
static get styles(): CSSResultGroup {
+3 -12
View File
@@ -14,25 +14,16 @@ export class HaBadge extends LitElement {
@property({ type: Boolean, attribute: "icon-only" }) iconOnly = false;
/**
* Accessible name for the badge button when it shows only its icon.
*/
@property({ attribute: "accessible-label" }) public accessibleLabel?: string;
protected render() {
const label = this.label;
const isButton = this.type === "button";
return html`
<div
class="badge ${classMap({
"icon-only": this.iconOnly,
})}"
role=${ifDefined(isButton ? "button" : undefined)}
tabindex=${ifDefined(isButton ? "0" : undefined)}
aria-label=${ifDefined(
isButton && this.iconOnly ? this.accessibleLabel : undefined
)}
role=${ifDefined(this.type === "button" ? "button" : undefined)}
tabindex=${ifDefined(this.type === "button" ? "0" : undefined)}
>
<ha-ripple .disabled=${this.type !== "button"}></ha-ripple>
<slot name="icon"></slot>
@@ -132,7 +123,7 @@ export class HaBadge extends LitElement {
::slotted(img[slot="icon"]) {
width: 30px;
height: 30px;
border-radius: var(--ha-picture-border-radius);
border-radius: var(--ha-border-radius-circle);
object-fit: cover;
overflow: hidden;
margin-left: -10px;
@@ -14,7 +14,7 @@ import {
formatClockCardDate,
} from "../panels/lovelace/cards/clock/clock-date-format";
import type { ClockCardDatePart } from "../panels/lovelace/cards/types";
import type { ValueChangedEvent } from "../types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./chips/ha-assist-chip";
import "./chips/ha-chip-set";
import "./chips/ha-input-chip";
@@ -116,6 +116,8 @@ interface ClockDatePartValueItem {
@customElement("ha-clock-date-format-picker")
export class HaClockDateFormatPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean }) public required = false;
@@ -151,6 +153,7 @@ export class HaClockDateFormatPicker extends LitElement {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${this._getPickerValue()}
Loaded 100 of 589 files, more files were not shown because too many files have changed in this diff. Show more